Compare commits

...
Sign in to create a new pull request.

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
40 changed files with 4223 additions and 45 deletions

View file

@ -15,7 +15,7 @@ jobs:
- name: Set up Go
uses: https://github.com/actions/setup-go@v5
with:
go-version: '1.21'
go-version: '1.24'
cache: true
- name: Install golangci-lint
@ -34,7 +34,7 @@ jobs:
- name: Set up Go
uses: https://github.com/actions/setup-go@v5
with:
go-version: '1.21'
go-version: '1.24'
cache: true
- name: Build application
@ -49,7 +49,7 @@ jobs:
- name: Set up Go
uses: https://github.com/actions/setup-go@v5
with:
go-version: '1.21'
go-version: '1.24'
cache: true
- name: Run unit tests
@ -66,7 +66,7 @@ jobs:
- name: Set up Go
uses: https://github.com/actions/setup-go@v5
with:
go-version: '1.21'
go-version: '1.24'
cache: true
- name: Build production binary

View file

@ -18,7 +18,7 @@ jobs:
- name: Set up Go
uses: https://github.com/actions/setup-go@v5
with:
go-version: '1.21'
go-version: '1.24'
cache: true
- name: Build production binary

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

4
.gitignore vendored
View file

@ -6,6 +6,10 @@ fgj
*.so
*.dylib
# Goreleaser output
dist/
bin/
# Test binary
*.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,6 +5,136 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] — 0.4.0
### Added — Repository Management
- `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).
### Added — Pull Requests
- `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.
### Added — Notifications & Organizations
- `fgj notification list [--all]` / `fgj notification read <id>`
list unread (default) or all notifications; mark individual
threads read.
- `fgj notification {unread,pin,unpin}` — flip thread state
(complements `read`). Uses the Gitea `NotifyStatus` enum.
- `fgj org {list,create,delete}` — list your orgs, create with
visibility/description, delete with confirmation.
- `fgj webhook {list,create,update,delete}` — full CRUD on repo
webhooks: gitea/slack/discord/etc. hook types, event selection,
content type, secret, branch filter, auth header.
### 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
- `fgj actions secret create` stdin handling reworked. Adds `--body`
(inline) and `--body-file` (path or `-` for stdin) flags; interactive
prompts now use hidden input via `term.ReadPassword`; piped stdin is
read whole (prior `fmt.Scanln` broke on spaces/newlines and echoed
the typed value). Empty values are rejected.
- `HostConfig` gains an optional `default: true` field. When no other
signal selects a host (flag, `FGJ_HOST`, git remote, `match_dirs`),
the host marked default wins before the `codeberg.org` fallback.
Multiple `default: true` entries are tolerated with a stderr
warning; alphabetical-first wins.
- Gitea SDK bumped `v0.22.1``v0.23.2` (last release compatible with
Go 1.24; `v0.24+` requires Go 1.26).
### Development
- Switched to standard semver tags (`v0.3.1`, `v0.4.0`, …); retired
letter-suffix scheme (`v0.3.0a``v0.3.0f`) which Go's module resolver
ignored, leaving `go install @latest` pointing at the pre-migration
`v0.3.0` tag.
- Version string is now injected at build time via `-ldflags`; the
hardcoded constant in `cmd/root.go` has been replaced with a
`var version = "dev"` fallback. `make build` derives the version from
`git describe --tags --always --dirty`.
- Added `.goreleaser.yaml` for multi-platform release builds
(linux/darwin/windows/freebsd × amd64/arm64/arm) with SHA256
checksums and auto-generated release notes.
- Added `.gitea/workflows/release.yml` that publishes release artifacts
to the Forgejo release page on tag push.
- Aligned CI Go version (`1.24`) with `go.mod`; previously CI ran on
`1.21` while `go.mod` required `1.24`.
## [0.3.1] - 2026-04-19
### Fixed
- `go install forgejo.zerova.net/public/fgj-sid@latest` now resolves
correctly. Previous releases used letter-suffix tags (`v0.3.0a``f`)
which are not valid Go module versions and were ignored by the
module resolver, leaving `@latest` pinned to `v0.3.0` — a commit
that predates the module-path migration from `codeberg.org/romaintb/fgj`.
## [0.3.0c] - 2026-03-21
### Added

View file

@ -1,17 +1,23 @@
.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:
@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 run - Run the application"
@echo " make test - Run tests"
@echo " make lint - Run golangci-lint"
@echo " make lint-fix - Run golangci-lint with auto-fix"
@echo " make clean - Clean build artifacts"
@echo " make release-snapshot - Build snapshot release artifacts via goreleaser"
@echo " make release-check - Validate .goreleaser.yaml"
build:
go build -o bin/fgj .
go build -ldflags "$(LDFLAGS)" -o bin/fgj .
install: build
install -Dm755 bin/fgj /usr/bin/fgj
@ -29,5 +35,11 @@ lint-fix:
golangci-lint run --fix ./...
clean:
rm -rf bin/
rm -rf bin/ dist/
go clean
release-snapshot:
goreleaser release --snapshot --clean --skip=publish
release-check:
goreleaser check

View file

@ -10,20 +10,25 @@
## Features
- Multi-instance support (works with any Forgejo or Gitea instance)
- Pull request management (create, list, view, merge, diff, comment, review)
- Issue tracking (create, list, view, comment, close, labels)
- Repository operations (view, list, create, edit, clone, fork)
- Label management (list, create, edit, delete)
- Milestone management (list, view, create, edit, delete)
- Wiki page management (list, view, create, edit, delete)
- Issue dependencies (`--add-dependency`, `--remove-dependency`)
- Forgejo Actions (workflow runs, watch/rerun/cancel, enable/disable, secrets, variables)
- Releases (create, upload, delete)
- Pull requests — list, view, create, merge, close, reopen, edit, checkout, clean, diff, comment, review, approve, reject, checks, review-comments, resolve/unresolve
- Issues — create, list, view, comment, close, reopen, edit, labels, dependencies
- Repositories — view, list, create, edit, clone, fork, rename, delete, search, migrate, create-from-template
- Branches — list, rename, delete
- Labels / milestones / wiki — full CRUD
- Organizations — list, create, delete
- Webhooks — list, create, update, delete
- Notifications — list (unread or all), mark read
- Forgejo Actions — workflow runs, watch/rerun/cancel, workflows enable/disable, secrets, variables
- 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
- 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
- Automatic repository and hostname detection from git context
- Directory-scoped host defaults (`match_dirs`)
- Secure authentication with personal access tokens
- XDG Base Directory compliant config location
- AI coding agent friendly

View file

@ -251,12 +251,26 @@ var actionsSecretListCmd = &cobra.Command{
var actionsSecretCreateCmd = &cobra.Command{
Use: "create <name>",
Short: "Create or update a repository secret",
Long: "Create or update a secret for Forgejo Actions. The secret value will be read from stdin.",
Example: ` # Create a secret (will prompt for value)
Long: `Create or update a secret for Forgejo Actions.
The secret value is read from the first available source:
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
# Create a secret for a specific repo
fgj actions secret create API_KEY -R owner/repo`,
# 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),
RunE: runActionsSecretCreate,
}
@ -397,6 +411,8 @@ func init() {
// Add flags for secret commands
addRepoFlags(actionsSecretListCmd)
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)
// Add flags for variable commands
@ -1254,12 +1270,9 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error {
secretName := args[0]
// Read secret value from stdin
fmt.Fprint(ios.ErrOut, "Enter secret value: ")
var secretValue string
_, err = fmt.Scanln(&secretValue)
secretValue, err := readSecretValue(cmd, secretName)
if err != nil {
return fmt.Errorf("failed to read secret value: %w", err)
return err
}
opt := gitea.CreateSecretOption{
@ -1267,12 +1280,12 @@ func runActionsSecretCreate(cmd *cobra.Command, args []string) error {
Data: secretValue,
}
_, err = client.CreateRepoActionSecret(owner, name, opt)
if err != nil {
if _, err := client.CreateRepoActionSecret(owner, name, opt); err != nil {
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
}

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()
}

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")
}

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

@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"strings"
"time"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fgj-sid/internal/api"
@ -29,7 +30,13 @@ var issueListCmd = &cobra.Command{
fgj issue list -s closed -R owner/repo
# Output as JSON
fgj 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,
}
@ -152,6 +159,8 @@ func init() {
issueListCmd.Flags().String("author", "", "Filter by author username")
issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label names")
issueListCmd.Flags().StringP("search", "S", "", "Search keyword filter")
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")
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")
labels, _ := cmd.Flags().GetStringSlice("label")
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)
if err != nil {
@ -227,6 +254,8 @@ func runIssueList(cmd *cobra.Command, args []string) error {
KeyWord: search,
CreatedBy: author,
AssignedBy: assignee,
Since: sinceTime,
Before: beforeTime,
ListOptions: gitea.ListOptions{PageSize: limit},
})
ios.StopSpinner()

View file

@ -48,9 +48,10 @@ var labelCreateCmd = &cobra.Command{
}
var labelEditCmd = &cobra.Command{
Use: "edit <name>",
Short: "Edit a label",
Long: "Edit an existing label in a repository.",
Use: "edit <name>",
Aliases: []string{"update"},
Short: "Edit a label",
Long: "Edit an existing label in a repository.",
Example: ` # Rename a label
fgj label edit bug --name bugfix

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
}

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())
}

108
cmd/pr.go
View file

@ -4,7 +4,9 @@ import (
"fmt"
"net/http"
"os/exec"
"strconv"
"strings"
"time"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/public/fgj-sid/internal/api"
@ -14,6 +16,54 @@ import (
"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{
Use: "pr",
Aliases: []string{"pull-request"},
@ -32,7 +82,13 @@ var prListCmd = &cobra.Command{
fgj pr list -s all -R owner/repo
# Output as JSON
fgj 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,
}
@ -167,6 +223,8 @@ func init() {
prListCmd.Flags().Bool("draft", false, "Filter by draft status")
prListCmd.Flags().String("head", "", "Filter by head 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")
addJSONFlags(prListCmd, "Output pull requests as JSON")
@ -217,6 +275,27 @@ func runPRList(cmd *cobra.Command, args []string) error {
draft, _ := cmd.Flags().GetBool("draft")
head, _ := cmd.Flags().GetString("head")
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)
if err != nil {
@ -249,7 +328,8 @@ func runPRList(cmd *cobra.Command, args []string) error {
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...")
var prs []*gitea.PullRequest
@ -271,6 +351,9 @@ func runPRList(cmd *cobra.Command, args []string) error {
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]
}
@ -354,6 +437,27 @@ func filterPRs(prs []*gitea.PullRequest, author, assignee string, labels []strin
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 {
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
}
}

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
}

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
}

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])
}

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

@ -14,12 +14,16 @@ import (
var cfgFile string
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{
Use: "fgj",
Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line",
Long: `fgj is a command line tool for Forgejo instances (including Codeberg).
It brings pull requests, issues, and other Forgejo concepts to the terminal.`,
Version: "0.3.1",
Version: version,
SilenceErrors: true,
}

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
}

4
go.mod
View file

@ -3,7 +3,8 @@ module forgejo.zerova.net/public/fgj-sid
go 1.24.0
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/viper v1.19.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/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/gojq v0.12.18 // indirect
github.com/itchyny/timefmt-go v0.1.7 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect

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.22.1/go.mod h1:yyF5+GhljqvA30sRDreoyHILruNiy4ASufugzYg0VHM=
code.gitea.io/sdk/gitea v0.23.2 h1:iJB1FDmLegwfwjX8gotBDHdPSbk/ZR8V9VmEJaVsJYg=
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/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
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-20190412213103-97732733099d/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/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

View file

@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/spf13/viper"
@ -20,6 +21,7 @@ type HostConfig struct {
User string `yaml:"user,omitempty"`
GitProtocol string `yaml:"git_protocol,omitempty"`
MatchDirs []string `yaml:"match_dirs,omitempty"`
Default bool `yaml:"default,omitempty"`
Order int `yaml:"-"` // config file order, set at load time
}
@ -134,7 +136,8 @@ func (c *Config) SaveToPath(path string) error {
// 3. Environment variable (FGJ_HOST)
// 4. Auto-detected hostname from git remote
// 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) {
if hostname == "" {
hostname = viper.GetString("hostname")
@ -152,6 +155,10 @@ func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (Host
hostname = c.ResolveHostByPath(cwd)
}
if hostname == "" {
hostname = c.DefaultHost()
}
if hostname == "" {
hostname = "codeberg.org"
}
@ -164,6 +171,27 @@ func (c *Config) GetHost(hostname string, detectedHost string, cwd string) (Host
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
// prefix of cwd. Returns "" if no match is found.
// Both cwd and match_dirs entries are resolved through filepath.EvalSymlinks