diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cd989f4..67d1021 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index d335132..b1d76a4 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..b6b24b6 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore index 07f46ec..de67f36 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ fgj *.so *.dylib +# Goreleaser output +dist/ +bin/ + # Test binary *.test diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..367e6ab --- /dev/null +++ b/.goreleaser.yaml @@ -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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 90f337c..f0ef142 100644 --- a/CHANGELOG.md +++ b/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 ` — 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 ` / `fgj pr reject ` — shortcuts over + `pr review`; `reject` requires a body. +- `fgj pr review-comments ` — list inline review comments across + every review on a PR. +- `fgj pr resolve ` / `fgj pr unresolve ` — + mark review threads (un)resolved. Requires Forgejo 8.x+ / + Gitea 1.22+ server-side. + +### Added — Notifications & Organizations + +- `fgj notification list [--all]` / `fgj notification read ` — + 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 diff --git a/Makefile b/Makefile index d11a86a..bb4657a 100644 --- a/Makefile +++ b/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 diff --git a/README.md b/README.md index 770b787..7391168 100644 --- a/README.md +++ b/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 diff --git a/cmd/actions.go b/cmd/actions.go index c1418ad..9f65af4 100644 --- a/cmd/actions.go +++ b/cmd/actions.go @@ -251,12 +251,26 @@ var actionsSecretListCmd = &cobra.Command{ var actionsSecretCreateCmd = &cobra.Command{ Use: "create ", 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 + 2. --body-file (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 } diff --git a/cmd/actions_run_delete.go b/cmd/actions_run_delete.go new file mode 100644 index 0000000..75a27d1 --- /dev/null +++ b/cmd/actions_run_delete.go @@ -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 ", + 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 +} diff --git a/cmd/admin.go b/cmd/admin.go new file mode 100644 index 0000000..cbc390e --- /dev/null +++ b/cmd/admin.go @@ -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() +} diff --git a/cmd/branch.go b/cmd/branch.go new file mode 100644 index 0000000..a6453af --- /dev/null +++ b/cmd/branch.go @@ -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 ", + 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 ", + 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 +} diff --git a/cmd/branch_protect.go b/cmd/branch_protect.go new file mode 100644 index 0000000..4878954 --- /dev/null +++ b/cmd/branch_protect.go @@ -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 ", + 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 ", + 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") +} diff --git a/cmd/completion_install.go b/cmd/completion_install.go new file mode 100644 index 0000000..c45c870 --- /dev/null +++ b/cmd/completion_install.go @@ -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 +} diff --git a/cmd/issue.go b/cmd/issue.go index f053b1a..d29f4fe 100644 --- a/cmd/issue.go +++ b/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() diff --git a/cmd/label.go b/cmd/label.go index 87cea75..f496711 100644 --- a/cmd/label.go +++ b/cmd/label.go @@ -48,9 +48,10 @@ var labelCreateCmd = &cobra.Command{ } var labelEditCmd = &cobra.Command{ - Use: "edit ", - Short: "Edit a label", - Long: "Edit an existing label in a repository.", + Use: "edit ", + Aliases: []string{"update"}, + Short: "Edit a label", + Long: "Edit an existing label in a repository.", Example: ` # Rename a label fgj label edit bug --name bugfix diff --git a/cmd/logins.go b/cmd/logins.go new file mode 100644 index 0000000..73dea59 --- /dev/null +++ b/cmd/logins.go @@ -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 +} diff --git a/cmd/milestone_issues.go b/cmd/milestone_issues.go new file mode 100644 index 0000000..ddde3ec --- /dev/null +++ b/cmd/milestone_issues.go @@ -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 ...", + 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 ...", + 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 +} diff --git a/cmd/notification.go b/cmd/notification.go new file mode 100644 index 0000000..bd245cf --- /dev/null +++ b/cmd/notification.go @@ -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 ", + 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 +} diff --git a/cmd/notification_states.go b/cmd/notification_states.go new file mode 100644 index 0000000..438a22b --- /dev/null +++ b/cmd/notification_states.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "fmt" + + "code.gitea.io/sdk/gitea" + "github.com/spf13/cobra" +) + +var notificationUnreadCmd = &cobra.Command{ + Use: "unread ", + 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 ", + 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 ", + 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 + } +} diff --git a/cmd/open.go b/cmd/open.go new file mode 100644 index 0000000..cde0a06 --- /dev/null +++ b/cmd/open.go @@ -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() +} diff --git a/cmd/org.go b/cmd/org.go new file mode 100644 index 0000000..8e11804 --- /dev/null +++ b/cmd/org.go @@ -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 ", + 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 ", + 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()) +} diff --git a/cmd/pr.go b/cmd/pr.go index 3ffbfad..d3137c2 100644 --- a/cmd/pr.go +++ b/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: + 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") diff --git a/cmd/pr_approve_reject.go b/cmd/pr_approve_reject.go new file mode 100644 index 0000000..b394c16 --- /dev/null +++ b/cmd/pr_approve_reject.go @@ -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 ", + Aliases: []string{"lgtm"}, + Short: "Approve a pull request", + Long: "Shortcut for 'fgj pr review --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 ", + Short: "Request changes on a pull request", + Long: "Shortcut for 'fgj pr review --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 + } +} diff --git a/cmd/pr_clean.go b/cmd/pr_clean.go new file mode 100644 index 0000000..3b678c0 --- /dev/null +++ b/cmd/pr_clean.go @@ -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 ", + 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 +} diff --git a/cmd/pr_review_comments.go b/cmd/pr_review_comments.go new file mode 100644 index 0000000..c577d59 --- /dev/null +++ b/cmd/pr_review_comments.go @@ -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 ", + 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 ", + 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 ", + 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 +} diff --git a/cmd/release_assets.go b/cmd/release_assets.go new file mode 100644 index 0000000..14532b5 --- /dev/null +++ b/cmd/release_assets.go @@ -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 ", + 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 ", + 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 ...", + 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]) +} diff --git a/cmd/repo_archive.go b/cmd/repo_archive.go new file mode 100644 index 0000000..0c23f6a --- /dev/null +++ b/cmd/repo_archive.go @@ -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 +} diff --git a/cmd/repo_delete.go b/cmd/repo_delete.go new file mode 100644 index 0000000..5cabe72 --- /dev/null +++ b/cmd/repo_delete.go @@ -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 +} diff --git a/cmd/repo_migrate.go b/cmd/repo_migrate.go new file mode 100644 index 0000000..60e548c --- /dev/null +++ b/cmd/repo_migrate.go @@ -0,0 +1,170 @@ +package cmd + +import ( + "fmt" + "strings" + + "code.gitea.io/sdk/gitea" + "github.com/spf13/cobra" +) + +var repoMigrateCmd = &cobra.Command{ + Use: "migrate ", + 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) + } +} diff --git a/cmd/repo_search.go b/cmd/repo_search.go new file mode 100644 index 0000000..a67eed3 --- /dev/null +++ b/cmd/repo_search.go @@ -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 +} diff --git a/cmd/repo_template.go b/cmd/repo_template.go new file mode 100644 index 0000000..0a82faf --- /dev/null +++ b/cmd/repo_template.go @@ -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 ", + 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- +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 +} diff --git a/cmd/root.go b/cmd/root.go index 8234142..eaecb28 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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, } diff --git a/cmd/secret_input.go b/cmd/secret_input.go new file mode 100644 index 0000000..98bbca9 --- /dev/null +++ b/cmd/secret_input.go @@ -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 +} diff --git a/cmd/times.go b/cmd/times.go new file mode 100644 index 0000000..d8fc8d3 --- /dev/null +++ b/cmd/times.go @@ -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 ", + 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 ", + 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 " 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 ", + 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 +} diff --git a/cmd/webhook.go b/cmd/webhook.go new file mode 100644 index 0000000..5b9a6cb --- /dev/null +++ b/cmd/webhook.go @@ -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 ", + Short: "Create a repository webhook", + Long: `Create a webhook that delivers events to . + +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 ", + 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 ", + 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 +} diff --git a/cmd/whoami.go b/cmd/whoami.go new file mode 100644 index 0000000..9955eb8 --- /dev/null +++ b/cmd/whoami.go @@ -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 +} diff --git a/go.mod b/go.mod index 0ecd4a7..d6f219e 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 7618c0c..58b29ed 100644 --- a/go.sum +++ b/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= diff --git a/internal/config/config.go b/internal/config/config.go index e60583d..f326613 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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