feat: repo archive/unarchive + completion install
- fgj repo {archive,unarchive}: toggle a repo's archived state via
EditRepo with *bool Archived. Archive prompts for confirmation
(requires --yes in non-TTY envs); unarchive is reversible, no prompt.
Accepts positional owner/name or -R flag; -R wins when both given.
- fgj completion install [shell]: idempotent install of completion
scripts to shell-standard locations. Auto-detects shell from $SHELL
if omitted. Paths: bash → XDG (or brew prefix on macOS), zsh →
~/.zsh/completions/_fgj, fish → ~/.config/fish/completions/fgj.fish.
--system (bash only) prints the sudo command for /etc paths without
writing. --dry-run prints the target path without writing.
Compares existing-file contents before overwrite to stay idempotent.
Both files built by sub-agents in parallel. Build + vet + test clean.
This commit is contained in:
parent
2d69873f3e
commit
e3d7904929
3 changed files with 360 additions and 0 deletions
|
|
@ -82,6 +82,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
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
|
||||
|
||||
|
|
|
|||
188
cmd/completion_install.go
Normal file
188
cmd/completion_install.go
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var completionInstallCmd = &cobra.Command{
|
||||
Use: "install [shell]",
|
||||
Short: "Install shell completions to a standard location",
|
||||
Long: "Install shell completions for fgj to a shell-appropriate location. If [shell] is omitted, it is detected from $SHELL. Supported: bash, zsh, fish.",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
ValidArgs: []string{"bash", "zsh", "fish"},
|
||||
RunE: runCompletionInstall,
|
||||
}
|
||||
|
||||
func init() {
|
||||
completionCmd.AddCommand(completionInstallCmd)
|
||||
completionInstallCmd.Flags().Bool("system", false, "Install system-wide (bash only; prints required sudo command)")
|
||||
completionInstallCmd.Flags().Bool("dry-run", false, "Print the target path and exit without writing")
|
||||
}
|
||||
|
||||
func runCompletionInstall(cmd *cobra.Command, args []string) error {
|
||||
shell := ""
|
||||
if len(args) == 1 {
|
||||
shell = args[0]
|
||||
} else {
|
||||
detected, err := detectShell()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shell = detected
|
||||
}
|
||||
|
||||
switch shell {
|
||||
case "bash", "zsh", "fish":
|
||||
// supported
|
||||
case "powershell":
|
||||
return fmt.Errorf("powershell auto-install is not supported; run `fgj completion powershell` and save the output manually")
|
||||
default:
|
||||
return fmt.Errorf("unsupported shell: %s (supported: bash, zsh, fish)", shell)
|
||||
}
|
||||
|
||||
system, _ := cmd.Flags().GetBool("system")
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
|
||||
// Generate completion script into a buffer.
|
||||
var buf bytes.Buffer
|
||||
switch shell {
|
||||
case "bash":
|
||||
if err := rootCmd.GenBashCompletion(&buf); err != nil {
|
||||
return fmt.Errorf("failed to generate bash completions: %w", err)
|
||||
}
|
||||
case "zsh":
|
||||
if err := rootCmd.GenZshCompletion(&buf); err != nil {
|
||||
return fmt.Errorf("failed to generate zsh completions: %w", err)
|
||||
}
|
||||
case "fish":
|
||||
if err := rootCmd.GenFishCompletion(&buf, true); err != nil {
|
||||
return fmt.Errorf("failed to generate fish completions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the target path.
|
||||
targetPath, sudoHint, err := completionTargetPath(shell, system)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if sudoHint != "" {
|
||||
// System-wide bash path requires root. Print the command and exit.
|
||||
fmt.Fprintf(ios.Out, "System-wide install requires root. Run:\n %s\n", sudoHint)
|
||||
return nil
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Fprintln(ios.Out, targetPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create parent directory.
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return fmt.Errorf("failed to create %s: %w", filepath.Dir(targetPath), err)
|
||||
}
|
||||
|
||||
// Idempotent write: if the file already exists and matches, skip.
|
||||
if existing, err := os.ReadFile(targetPath); err == nil {
|
||||
if bytes.Equal(existing, buf.Bytes()) {
|
||||
fmt.Fprintf(ios.Out, "Completion already installed at %s (up to date)\n", targetPath)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(targetPath, buf.Bytes(), 0o644); err != nil {
|
||||
return fmt.Errorf("failed to write %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, "%s Installed %s completions to %s\n", cs.SuccessIcon(), shell, targetPath)
|
||||
|
||||
if shell == "zsh" {
|
||||
fmt.Fprintln(ios.Out, "")
|
||||
fmt.Fprintln(ios.Out, "If you haven't already, add the following to your ~/.zshrc:")
|
||||
fmt.Fprintln(ios.Out, " fpath=(~/.zsh/completions $fpath)")
|
||||
fmt.Fprintln(ios.Out, " autoload -U compinit && compinit")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// completionTargetPath resolves the target install path for the given shell.
|
||||
// For bash system-wide installs, it returns an empty path and a sudo hint command
|
||||
// that the caller should print instead of writing.
|
||||
func completionTargetPath(shell string, system bool) (string, string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to resolve home directory: %w", err)
|
||||
}
|
||||
|
||||
switch shell {
|
||||
case "bash":
|
||||
if system {
|
||||
if _, err := os.Stat("/etc/bash_completion.d"); err == nil {
|
||||
exe, exeErr := os.Executable()
|
||||
if exeErr != nil || exe == "" {
|
||||
exe = "fgj"
|
||||
}
|
||||
sudo := fmt.Sprintf("sudo sh -c '%s completion bash > /etc/bash_completion.d/fgj'", exe)
|
||||
return "", sudo, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("--system requested but /etc/bash_completion.d does not exist")
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
if prefix, ok := brewPrefix(); ok {
|
||||
return filepath.Join(prefix, "etc", "bash_completion.d", "fgj"), "", nil
|
||||
}
|
||||
}
|
||||
xdg := os.Getenv("XDG_DATA_HOME")
|
||||
if xdg == "" {
|
||||
xdg = filepath.Join(home, ".local", "share")
|
||||
}
|
||||
return filepath.Join(xdg, "bash-completion", "completions", "fgj"), "", nil
|
||||
|
||||
case "zsh":
|
||||
zdot := os.Getenv("ZDOTDIR")
|
||||
if zdot == "" {
|
||||
zdot = home
|
||||
}
|
||||
return filepath.Join(zdot, ".zsh", "completions", "_fgj"), "", nil
|
||||
|
||||
case "fish":
|
||||
return filepath.Join(home, ".config", "fish", "completions", "fgj.fish"), "", nil
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("unsupported shell: %s", shell)
|
||||
}
|
||||
|
||||
// detectShell reads $SHELL and returns its basename.
|
||||
func detectShell() (string, error) {
|
||||
s := os.Getenv("SHELL")
|
||||
if s == "" {
|
||||
return "", fmt.Errorf("cannot detect shell: $SHELL is empty (pass shell name explicitly)")
|
||||
}
|
||||
return filepath.Base(s), nil
|
||||
}
|
||||
|
||||
// brewPrefix returns the Homebrew prefix if brew is available on PATH.
|
||||
func brewPrefix() (string, bool) {
|
||||
if _, err := exec.LookPath("brew"); err != nil {
|
||||
return "", false
|
||||
}
|
||||
out, err := exec.Command("brew", "--prefix").Output()
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
prefix := strings.TrimSpace(string(out))
|
||||
if prefix == "" {
|
||||
return "", false
|
||||
}
|
||||
return prefix, true
|
||||
}
|
||||
163
cmd/repo_archive.go
Normal file
163
cmd/repo_archive.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/sdk/gitea"
|
||||
"forgejo.zerova.net/public/fgj-sid/internal/api"
|
||||
"forgejo.zerova.net/public/fgj-sid/internal/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var repoArchiveCmd = &cobra.Command{
|
||||
Use: "archive [owner/name]",
|
||||
Short: "Archive a repository",
|
||||
Long: `Mark a repository as archived. Archived repositories remain visible but
|
||||
become read-only: pushes, issues, pull requests, and releases are disabled.
|
||||
|
||||
The target repo may be passed as a positional argument, via -R/--repo, or
|
||||
auto-detected from the current git context. If both positional and -R are
|
||||
given, the -R flag wins.`,
|
||||
Example: ` # Archive a specific repo (prompted confirmation)
|
||||
fgj repo archive owner/name
|
||||
|
||||
# Archive the current repo without prompting
|
||||
fgj repo archive --yes
|
||||
|
||||
# Archive using the -R flag
|
||||
fgj repo archive -R owner/name -y`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runRepoArchive,
|
||||
}
|
||||
|
||||
var repoUnarchiveCmd = &cobra.Command{
|
||||
Use: "unarchive [owner/name]",
|
||||
Short: "Unarchive a repository",
|
||||
Long: `Clear the archived flag on a repository, restoring normal read-write
|
||||
behaviour.
|
||||
|
||||
The target repo may be passed as a positional argument, via -R/--repo, or
|
||||
auto-detected from the current git context. If both positional and -R are
|
||||
given, the -R flag wins.`,
|
||||
Example: ` # Unarchive a specific repo
|
||||
fgj repo unarchive owner/name
|
||||
|
||||
# Unarchive the current repo
|
||||
fgj repo unarchive`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runRepoUnarchive,
|
||||
}
|
||||
|
||||
func init() {
|
||||
repoCmd.AddCommand(repoArchiveCmd)
|
||||
repoCmd.AddCommand(repoUnarchiveCmd)
|
||||
|
||||
addRepoFlags(repoArchiveCmd)
|
||||
repoArchiveCmd.Flags().BoolP("yes", "y", false, "Skip the confirmation prompt")
|
||||
addJSONFlags(repoArchiveCmd, "Output updated repository as JSON")
|
||||
|
||||
addRepoFlags(repoUnarchiveCmd)
|
||||
repoUnarchiveCmd.Flags().BoolP("yes", "y", false, "Skip the confirmation prompt (unused; kept for symmetry with archive)")
|
||||
addJSONFlags(repoUnarchiveCmd, "Output updated repository as JSON")
|
||||
}
|
||||
|
||||
// resolveRepoTarget returns owner/name honouring the "optional positional OR
|
||||
// -R flag" pattern used elsewhere in the CLI: -R wins when both are supplied.
|
||||
func resolveRepoTarget(cmd *cobra.Command, args []string) (string, string, error) {
|
||||
var repo string
|
||||
if len(args) > 0 {
|
||||
repo = args[0]
|
||||
}
|
||||
if r, _ := cmd.Flags().GetString("repo"); r != "" {
|
||||
repo = r
|
||||
}
|
||||
return parseRepo(repo)
|
||||
}
|
||||
|
||||
func runRepoArchive(cmd *cobra.Command, args []string) error {
|
||||
owner, name, err := resolveRepoTarget(cmd, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slug := fmt.Sprintf("%s/%s", owner, name)
|
||||
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
||||
|
||||
if !skipConfirm {
|
||||
if !ios.IsStdinTTY() {
|
||||
return fmt.Errorf("refusing to archive %s without a TTY; pass -y/--yes to confirm non-interactively", slug)
|
||||
}
|
||||
prompt := fmt.Sprintf("Archive %s? This disables issues/PRs/pushes. [y/N]: ", slug)
|
||||
answer, err := promptLine(prompt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if answer != "y" && answer != "Y" && answer != "yes" && answer != "Yes" && answer != "YES" {
|
||||
fmt.Fprintln(ios.ErrOut, "Aborted.")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
archived := true
|
||||
opt := gitea.EditRepoOption{Archived: &archived}
|
||||
|
||||
ios.StartSpinner("Archiving repository...")
|
||||
repository, _, err := client.EditRepo(owner, name, opt)
|
||||
ios.StopSpinner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to archive %s: %w", slug, err)
|
||||
}
|
||||
|
||||
if wantJSON(cmd) {
|
||||
return outputJSON(cmd, repository)
|
||||
}
|
||||
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, "%s Archived %s\n", cs.SuccessIcon(), slug)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runRepoUnarchive(cmd *cobra.Command, args []string) error {
|
||||
owner, name, err := resolveRepoTarget(cmd, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slug := fmt.Sprintf("%s/%s", owner, name)
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost(), getCwd())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
archived := false
|
||||
opt := gitea.EditRepoOption{Archived: &archived}
|
||||
|
||||
ios.StartSpinner("Unarchiving repository...")
|
||||
repository, _, err := client.EditRepo(owner, name, opt)
|
||||
ios.StopSpinner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unarchive %s: %w", slug, err)
|
||||
}
|
||||
|
||||
if wantJSON(cmd) {
|
||||
return outputJSON(cmd, repository)
|
||||
}
|
||||
|
||||
cs := ios.ColorScheme()
|
||||
fmt.Fprintf(ios.Out, "%s Unarchived %s\n", cs.SuccessIcon(), slug)
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue