175 lines
4.5 KiB
Go
175 lines
4.5 KiB
Go
package cmd
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"forgejo.zerova.net/public/fj/internal/git"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
var cfgFile string
|
|
var jsonErrors bool
|
|
|
|
var rootCmd = &cobra.Command{
|
|
Use: "fj",
|
|
Short: "Forgejo CLI tool - work seamlessly with Forgejo from the command line",
|
|
Long: `fj 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.2",
|
|
SilenceErrors: true,
|
|
}
|
|
|
|
// JSONErrors reports whether the --json-errors flag is set.
|
|
func JSONErrors() bool {
|
|
return jsonErrors
|
|
}
|
|
|
|
func Execute() error {
|
|
return rootCmd.Execute()
|
|
}
|
|
|
|
func init() {
|
|
cobra.OnInitialize(initConfig)
|
|
|
|
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/fj/config.yaml)")
|
|
rootCmd.PersistentFlags().BoolVar(&jsonErrors, "json-errors", false, "output errors as structured JSON to stderr")
|
|
rootCmd.PersistentFlags().String("hostname", "", "Forgejo instance hostname")
|
|
_ = viper.BindPFlag("hostname", rootCmd.PersistentFlags().Lookup("hostname"))
|
|
}
|
|
|
|
func initConfig() {
|
|
if cfgFile != "" {
|
|
viper.SetConfigFile(cfgFile)
|
|
} else {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
fmt.Fprintln(ios.ErrOut, err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
configDir := home + "/.config/fj"
|
|
legacyDir := home + "/.config/fgj"
|
|
|
|
// Migrate from ~/.config/fgj/ if the new dir doesn't exist yet.
|
|
if _, err := os.Stat(configDir); os.IsNotExist(err) {
|
|
if info, err := os.Stat(legacyDir); err == nil && info.IsDir() {
|
|
if copyErr := migrateConfigDir(legacyDir, configDir); copyErr == nil {
|
|
fmt.Fprintln(ios.ErrOut, "notice: migrated config from ~/.config/fgj/ to ~/.config/fj/")
|
|
fmt.Fprintln(ios.ErrOut, " you can remove ~/.config/fgj/ when ready")
|
|
}
|
|
}
|
|
}
|
|
|
|
_ = os.MkdirAll(configDir, 0755)
|
|
|
|
viper.AddConfigPath(configDir)
|
|
viper.SetConfigType("yaml")
|
|
viper.SetConfigName("config")
|
|
}
|
|
|
|
viper.AutomaticEnv()
|
|
viper.SetEnvPrefix("FJ")
|
|
|
|
_ = viper.ReadInConfig()
|
|
}
|
|
|
|
// parseRepo parses the repository string in the format "owner/name".
|
|
// If not provided, it attempts to auto-detect from the git repository.
|
|
func parseRepo(repo string) (string, string, error) {
|
|
// If repo flag is provided, use it
|
|
if repo != "" {
|
|
parts := strings.Split(repo, "/")
|
|
if len(parts) != 2 {
|
|
return "", "", fmt.Errorf("invalid repository format: %s (expected: owner/name)", repo)
|
|
}
|
|
return parts[0], parts[1], nil
|
|
}
|
|
|
|
// Try to auto-detect from git
|
|
owner, name, err := git.DetectRepo()
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("repository flag is required (use -R owner/name) or run from a git repository: %w", err)
|
|
}
|
|
|
|
return owner, name, nil
|
|
}
|
|
|
|
// getDetectedHost attempts to auto-detect the Forgejo instance hostname.
|
|
// Returns empty string if detection fails, which will fall back to other methods.
|
|
func getDetectedHost() string {
|
|
host, err := git.DetectHost()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return host
|
|
}
|
|
|
|
// getCwd returns the current working directory, or "" on error.
|
|
func getCwd() string {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return cwd
|
|
}
|
|
|
|
// promptLine prints a prompt to stderr and reads a line from stdin.
|
|
func promptLine(prompt string) (string, error) {
|
|
fmt.Fprint(ios.ErrOut, prompt)
|
|
var buf [1024]byte
|
|
n, err := ios.In.Read(buf[:])
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading input: %w", err)
|
|
}
|
|
return strings.TrimSpace(string(buf[:n])), nil
|
|
}
|
|
|
|
// parseIssueArg parses an issue/PR number from various formats:
|
|
// "123", "#123", "https://host/owner/repo/pulls/123", "https://host/owner/repo/issues/123"
|
|
func parseIssueArg(arg string) (int64, error) {
|
|
arg = strings.TrimPrefix(arg, "#")
|
|
// Try URL format
|
|
if strings.HasPrefix(arg, "http") {
|
|
parts := strings.Split(strings.TrimRight(arg, "/"), "/")
|
|
arg = parts[len(parts)-1]
|
|
}
|
|
return strconv.ParseInt(arg, 10, 64)
|
|
}
|
|
|
|
// migrateConfigDir copies all files from src to dst (one level, no subdirs).
|
|
func migrateConfigDir(src, dst string) error {
|
|
if err := os.MkdirAll(dst, 0755); err != nil {
|
|
return err
|
|
}
|
|
entries, err := os.ReadDir(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
in, err := os.Open(filepath.Join(src, e.Name()))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := os.OpenFile(filepath.Join(dst, e.Name()), os.O_CREATE|os.O_WRONLY, 0600)
|
|
if err != nil {
|
|
in.Close()
|
|
return err
|
|
}
|
|
_, err = io.Copy(out, in)
|
|
in.Close()
|
|
out.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|