Compare commits
8 commits
main
...
feat/v0.4-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3d7904929 | ||
|
|
2d69873f3e | ||
|
|
d15deaf064 | ||
|
|
4eeef2ceca | ||
|
|
424fb63a8b | ||
|
|
adccd6f6f7 | ||
|
|
17ca49d0c5 | ||
|
|
d4b5b79541 |
40 changed files with 4223 additions and 45 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
36
.gitea/workflows/release.yml
Normal file
36
.gitea/workflows/release.yml
Normal 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
4
.gitignore
vendored
|
|
@ -6,6 +6,10 @@ fgj
|
|||
*.so
|
||||
*.dylib
|
||||
|
||||
# Goreleaser output
|
||||
dist/
|
||||
bin/
|
||||
|
||||
# Test binary
|
||||
*.test
|
||||
|
||||
|
|
|
|||
91
.goreleaser.yaml
Normal file
91
.goreleaser.yaml
Normal 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 }}
|
||||
130
CHANGELOG.md
130
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
20
Makefile
20
Makefile
|
|
@ -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
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
99
cmd/actions_run_delete.go
Normal 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
85
cmd/admin.go
Normal 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
185
cmd/branch.go
Normal 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
204
cmd/branch_protect.go
Normal 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
188
cmd/completion_install.go
Normal 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
|
||||
}
|
||||
31
cmd/issue.go
31
cmd/issue.go
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ var labelCreateCmd = &cobra.Command{
|
|||
|
||||
var labelEditCmd = &cobra.Command{
|
||||
Use: "edit <name>",
|
||||
Aliases: []string{"update"},
|
||||
Short: "Edit a label",
|
||||
Long: "Edit an existing label in a repository.",
|
||||
Example: ` # Rename a label
|
||||
|
|
|
|||
160
cmd/logins.go
Normal file
160
cmd/logins.go
Normal 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
164
cmd/milestone_issues.go
Normal 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
137
cmd/notification.go
Normal 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
|
||||
}
|
||||
60
cmd/notification_states.go
Normal file
60
cmd/notification_states.go
Normal 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
104
cmd/open.go
Normal 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
186
cmd/org.go
Normal 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
108
cmd/pr.go
|
|
@ -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
102
cmd/pr_approve_reject.go
Normal 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
98
cmd/pr_clean.go
Normal 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
171
cmd/pr_review_comments.go
Normal 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
310
cmd/release_assets.go
Normal 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
163
cmd/repo_archive.go
Normal 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
78
cmd/repo_delete.go
Normal 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
170
cmd/repo_migrate.go
Normal 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
146
cmd/repo_search.go
Normal 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
113
cmd/repo_template.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
68
cmd/secret_input.go
Normal 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
386
cmd/times.go
Normal 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
293
cmd/webhook.go
Normal 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
53
cmd/whoami.go
Normal 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
4
go.mod
|
|
@ -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
6
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue