diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..3d5673c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,30 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + - master + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Validate i18n messages + run: go run ./internal/i18n/cmd/check + + - name: Scan i18n key references + run: go run ./internal/i18n/cmd/check --scan-code + + - name: Run Go tests + run: go test ./... diff --git a/README.md b/README.md index 77ec2d6..396012b 100644 --- a/README.md +++ b/README.md @@ -247,9 +247,15 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus # Create an issue gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..." +# Create an issue with metadata +gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" --priority-id 3 --tag-ids 4,5 --assigner-ids 7 + # View an issue gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123 +# Update issue metadata +gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15 + # Close an issue gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 @@ -267,8 +273,22 @@ gitlink-cli issue +assigners --owner Gitlink --repo forgeplus # List issue authors gitlink-cli issue +authors --owner Gitlink --repo forgeplus + +# List issue priorities +gitlink-cli issue +priorities --owner Gitlink --repo forgeplus + +# List issue tags +gitlink-cli issue +tags --owner Gitlink --repo forgeplus --only-name + +# List issue statuses +gitlink-cli issue +statuses --owner Gitlink --repo forgeplus ``` +`issue +view`, `issue +update`, `issue +close`, and `issue +comment` prefer +`--number` / `-n` for the issue number shown in the web URL. `--id` / `-i` +is accepted as a compatibility alias for the same web issue number, not the +global database ID. + ### Label Management ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index db3547b..b85bbfd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -258,9 +258,15 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus # 创建 Issue gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..." +# 创建带元数据的 Issue +gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" --priority-id 3 --tag-ids 4,5 --assigner-ids 7 + # 查看 Issue gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123 +# 更新 Issue 元数据 +gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15 + # 关闭 Issue gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 @@ -278,8 +284,21 @@ gitlink-cli issue +assigners --owner Gitlink --repo forgeplus # 列出 Issue 发布人 gitlink-cli issue +authors --owner Gitlink --repo forgeplus + +# 列出 Issue 优先级 +gitlink-cli issue +priorities --owner Gitlink --repo forgeplus + +# 列出 Issue 标签 +gitlink-cli issue +tags --owner Gitlink --repo forgeplus --only-name + +# 列出 Issue 状态 +gitlink-cli issue +statuses --owner Gitlink --repo forgeplus ``` +`issue +view`、`issue +update`、`issue +close` 和 `issue +comment` 推荐使用 +`--number` / `-n` 传网页 URL 中的 Issue 编号。`--id` / `-i` 是同一网页 Issue +编号的兼容别名,不是数据库内部 ID。 + ### 标签管理 ```bash diff --git a/cmd/api/api.go b/cmd/api/api.go index a8f1f2d..a9df7b1 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -13,14 +13,19 @@ import ( "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" ) -func NewAPICmd() *cobra.Command { +func NewAPICmd(translators ...*i18n.Translator) *cobra.Command { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } apiCmd := &cobra.Command{ Use: "api ", - Short: "Make raw API requests to GitLink", - Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`, + Short: tr.T("cmd.api.short"), + Long: tr.T("cmd.api.long"), Example: ` gitlink-cli api GET /users/me gitlink-cli api GET /projects --query 'page=1&limit=10' gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}' @@ -29,11 +34,11 @@ func NewAPICmd() *cobra.Command { RunE: runAPI, } - apiCmd.Flags().String("body", "", "Request body (JSON string)") - apiCmd.Flags().String("body-file", "", "Read request body JSON from a file") - apiCmd.Flags().Bool("body-stdin", false, "Read request body JSON from stdin") - apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)") - apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)") + apiCmd.Flags().String("body", "", tr.T("flag.api.body")) + apiCmd.Flags().String("body-file", "", tr.T("flag.api.body_file")) + apiCmd.Flags().Bool("body-stdin", false, tr.T("flag.api.body_stdin")) + apiCmd.Flags().String("query", "", tr.T("flag.api.query")) + apiCmd.Flags().StringSlice("header", nil, tr.T("flag.api.header")) return apiCmd } diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index e9af757..25653d7 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -2,141 +2,182 @@ package auth import ( "bufio" + "errors" "fmt" + "io" "os" "strings" - "syscall" "github.com/spf13/cobra" "golang.org/x/term" internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth" + "github.com/gitlink-org/gitlink-cli/internal/i18n" ) const envTokenVar = "GITLINK_TOKEN" -func NewAuthCmd() *cobra.Command { +var ( + storeToken = internalAuth.StoreToken + loadToken = internalAuth.LoadToken +) + +func NewAuthCmd(translators ...*i18n.Translator) *cobra.Command { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } cmd := &cobra.Command{ Use: "auth", - Short: "Authentication commands", + Short: tr.T("cmd.auth.short"), } - cmd.AddCommand(newLoginCmd()) - cmd.AddCommand(newLogoutCmd()) - cmd.AddCommand(newStatusCmd()) + cmd.AddCommand(newLoginCmd(tr)) + cmd.AddCommand(newLogoutCmd(tr)) + cmd.AddCommand(newStatusCmd(tr)) return cmd } -func newLoginCmd() *cobra.Command { +func newLoginCmd(tr *i18n.Translator) *cobra.Command { var tokenMode bool cmd := &cobra.Command{ Use: "login", - Short: "Login to GitLink", + Short: tr.T("cmd.auth.login.short"), RunE: func(cmd *cobra.Command, args []string) error { if tokenMode { - return loginWithToken() + return loginWithToken(cmd.InOrStdin(), cmd.OutOrStdout(), tr) } - return loginWithPassword() + return loginWithPassword(cmd.InOrStdin(), cmd.OutOrStdout(), tr) }, } - cmd.Flags().BoolVar(&tokenMode, "token", false, "Login by pasting an existing token") + cmd.Flags().BoolVar(&tokenMode, "token", false, tr.T("flag.auth.token")) return cmd } -func loginWithPassword() error { - reader := bufio.NewReader(os.Stdin) - - fmt.Print("Username/Email/Phone: ") +func loginWithPassword(in io.Reader, out io.Writer, tr *i18n.Translator) error { + reader := bufio.NewReader(in) + if _, err := fmt.Fprint(out, tr.T("prompt.auth.username")); err != nil { + return err + } username, _ := reader.ReadString('\n') username = strings.TrimSpace(username) - fmt.Print("Password: ") - passwordBytes, err := term.ReadPassword(syscall.Stdin) + if _, err := fmt.Fprint(out, tr.T("prompt.auth.password")); err != nil { + return err + } + passwordBytes, err := readPassword(in, reader) if err != nil { return fmt.Errorf("failed to read password: %w", err) } - fmt.Println() + if _, err := fmt.Fprintln(out); err != nil { + return err + } password := string(passwordBytes) result, err := internalAuth.Login(username, password) if err != nil { - return fmt.Errorf("login failed: %w", err) + return errors.New(tr.Tf("error.auth.login_failed", i18n.Args{"message": err.Error()})) } - fmt.Printf("✓ Logged in as %s\n", result.Login) - return nil + _, err = fmt.Fprintln(out, tr.Tf("success.auth.logged_in_as", i18n.Args{"login": result.Login})) + return err } -func loginWithToken() error { - reader := bufio.NewReader(os.Stdin) - fmt.Print("Paste your token: ") +func readPassword(in io.Reader, reader *bufio.Reader) ([]byte, error) { + if file, ok := in.(*os.File); ok { + fd := int(file.Fd()) + if term.IsTerminal(fd) { + return term.ReadPassword(fd) + } + } + password, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return nil, err + } + return []byte(strings.TrimRight(password, "\r\n")), nil +} + +func loginWithToken(in io.Reader, out io.Writer, tr *i18n.Translator) error { + reader := bufio.NewReader(in) + if _, err := fmt.Fprint(out, tr.T("prompt.auth.token")); err != nil { + return err + } token, _ := reader.ReadString('\n') token = strings.TrimSpace(token) if token == "" { - return fmt.Errorf("token cannot be empty") + return errors.New(tr.T("error.auth.token_empty")) } - if err := internalAuth.StoreToken(token); err != nil { - return fmt.Errorf("failed to store token: %w", err) + if err := storeToken(token); err != nil { + return errors.New(tr.Tf("error.auth.store_token_failed", i18n.Args{"message": err.Error()})) } - fmt.Println("✓ Token saved") - return nil + _, err := fmt.Fprintln(out, tr.T("success.auth.token_saved")) + return err } -func newLogoutCmd() *cobra.Command { +func newLogoutCmd(tr *i18n.Translator) *cobra.Command { return &cobra.Command{ Use: "logout", - Short: "Logout from GitLink", + Short: tr.T("cmd.auth.logout.short"), RunE: func(cmd *cobra.Command, args []string) error { if err := internalAuth.DeleteToken(); err != nil { - return fmt.Errorf("failed to delete token: %w", err) + return errors.New(tr.Tf("error.auth.delete_token_failed", i18n.Args{"message": err.Error()})) } - fmt.Println("✓ Logged out") - return nil + _, err := fmt.Fprintln(cmd.OutOrStdout(), tr.T("success.auth.logged_out")) + return err }, } } -func newStatusCmd() *cobra.Command { +func newStatusCmd(tr *i18n.Translator) *cobra.Command { return &cobra.Command{ Use: "status", - Short: "Show authentication status", + Short: tr.T("cmd.auth.status.short"), RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() // Check env var token first if envToken := os.Getenv(envTokenVar); envToken != "" { - fmt.Printf("✓ Logged in via %s environment variable\n", envTokenVar) + if _, err := fmt.Fprintln(out, tr.Tf("success.auth.logged_in_via_env", i18n.Args{"env": envTokenVar})); err != nil { + return err + } } - token, err := internalAuth.LoadToken() + token, err := loadToken() if err != nil || token == "" { if os.Getenv(envTokenVar) == "" { - fmt.Println("✗ Not logged in") - fmt.Println(" Run: gitlink-cli auth login") - fmt.Printf(" Or set %s environment variable\n", envTokenVar) + if _, err := fmt.Fprintln(out, tr.T("warning.auth.not_logged_in")); err != nil { + return err + } + if _, err := fmt.Fprintln(out, tr.T("output.auth.login_hint")); err != nil { + return err + } + if _, err := fmt.Fprintln(out, tr.Tf("output.auth.env_hint", i18n.Args{"env": envTokenVar})); err != nil { + return err + } } return nil } user, err := internalAuth.GetCurrentUser() if err != nil { - fmt.Printf("✓ Token stored (but cannot verify: %v)\n", err) - return nil + _, err := fmt.Fprintln(out, tr.Tf("warning.auth.token_unverified", i18n.Args{"message": err.Error()})) + return err } login, _ := user["login"].(string) name, _ := user["name"].(string) if login != "" { - fmt.Printf("✓ Logged in as %s", login) + text := tr.Tf("success.auth.logged_in_as", i18n.Args{"login": login}) if name != "" { - fmt.Printf(" (%s)", name) + text = fmt.Sprintf("%s (%s)", text, name) } - fmt.Println() - } else { - fmt.Println("✓ Token stored (user info unavailable)") + _, err := fmt.Fprintln(out, text) + return err } - return nil + _, err = fmt.Fprintln(out, tr.T("warning.auth.user_unavailable")) + return err }, } } diff --git a/cmd/cmd_test.go b/cmd/cmd_test.go index 7e17142..04f114a 100644 --- a/cmd/cmd_test.go +++ b/cmd/cmd_test.go @@ -1,46 +1,68 @@ package cmd import ( + "bytes" + "strings" "testing" ) -func TestInit(t *testing.T) { - // init() runs automatically when package is imported. - // Verify rootCmd has the expected settings. - if rootCmd.Use != "gitlink-cli" { - t.Fatalf("Use = %q", rootCmd.Use) +func TestNewRootCmdDefaults(t *testing.T) { + root, err := NewRootCmd(RootOptions{Version: "test"}, nil) + if err != nil { + t.Fatal(err) } - if rootCmd.SilenceUsage != true { + if root.Use != "gitlink-cli" { + t.Fatalf("Use = %q", root.Use) + } + if !root.SilenceUsage { t.Fatal("expected SilenceUsage=true") } } -func TestExecute(t *testing.T) { - rootCmd.SetArgs([]string{"--help"}) - if err := Execute(); err != nil { - t.Fatalf("Execute error: %v", err) +func TestRootHelp(t *testing.T) { + root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--help"}}, nil) + if err != nil { + t.Fatal(err) + } + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatalf("help command error: %v", err) } } func TestVersionCmd(t *testing.T) { - rootCmd.SetArgs([]string{"version"}) - if err := rootCmd.Execute(); err != nil { + root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"version"}}, nil) + if err != nil { + t.Fatal(err) + } + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { t.Fatalf("version command error: %v", err) } + if got := strings.TrimSpace(out.String()); got != "gitlink-cli test" { + t.Fatalf("version output = %q", got) + } } func TestRootCmdHasSubcommands(t *testing.T) { + root, err := NewRootCmd(RootOptions{Version: "test"}, nil) + if err != nil { + t.Fatal(err) + } names := map[string]bool{} - for _, sub := range rootCmd.Commands() { + for _, sub := range root.Commands() { names[sub.Use] = true } - // At minimum, these core commands should exist for _, want := range []string{"auth", "config", "version"} { if !names[want] { t.Fatalf("missing subcommand: %s", want) } } - if len(rootCmd.Commands()) < 4 { - t.Fatalf("expected at least 4 subcommands, got %d", len(rootCmd.Commands())) + if len(root.Commands()) < 4 { + t.Fatalf("expected at least 4 subcommands, got %d", len(root.Commands())) } } diff --git a/cmd/cmdutil/globals.go b/cmd/cmdutil/globals.go index 859992a..bea4c28 100644 --- a/cmd/cmdutil/globals.go +++ b/cmd/cmdutil/globals.go @@ -6,4 +6,5 @@ var ( Repo string Format string Debug bool + Lang string ) diff --git a/cmd/config/config.go b/cmd/config/config.go index d7dc6ad..dec6c9e 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -1,59 +1,80 @@ package config import ( + "errors" "fmt" "github.com/spf13/cobra" internalConfig "github.com/gitlink-org/gitlink-cli/internal/config" + "github.com/gitlink-org/gitlink-cli/internal/i18n" ) -func NewConfigCmd() *cobra.Command { +func NewConfigCmd(translators ...*i18n.Translator) *cobra.Command { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } cmd := &cobra.Command{ Use: "config", - Short: "Manage gitlink-cli configuration", + Short: tr.T("cmd.config.short"), } - cmd.AddCommand(newInitCmd()) - cmd.AddCommand(newSetCmd()) - cmd.AddCommand(newGetCmd()) - cmd.AddCommand(newListCmd()) + cmd.AddCommand(newInitCmd(tr)) + cmd.AddCommand(newSetCmd(tr)) + cmd.AddCommand(newGetCmd(tr)) + cmd.AddCommand(newListCmd(tr)) return cmd } -func newInitCmd() *cobra.Command { +func newInitCmd(translators ...*i18n.Translator) *cobra.Command { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } return &cobra.Command{ Use: "init", - Short: "Initialize configuration file", + Short: tr.T("cmd.config.init.short"), RunE: func(cmd *cobra.Command, args []string) error { cfg := internalConfig.DefaultConfig() if err := internalConfig.Save(cfg); err != nil { - return fmt.Errorf("failed to save config: %w", err) + return errors.New(tr.Tf("error.config.save_failed", i18n.Args{"message": err.Error()})) } - fmt.Printf("✓ Config initialized at %s\n", internalConfig.ConfigPath()) - return nil + _, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.initialized", i18n.Args{"path": internalConfig.ConfigPath()})) + return err }, } } -func newSetCmd() *cobra.Command { +func newSetCmd(translators ...*i18n.Translator) *cobra.Command { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } return &cobra.Command{ Use: "set ", - Short: "Set a configuration value", + Short: tr.T("cmd.config.set.short"), Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { if err := internalConfig.Set(args[0], args[1]); err != nil { return err } - fmt.Printf("✓ %s = %s\n", args[0], args[1]) - return nil + _, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.set", i18n.Args{ + "key": args[0], + "value": args[1], + })) + return err }, } } -func newGetCmd() *cobra.Command { +func newGetCmd(translators ...*i18n.Translator) *cobra.Command { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } return &cobra.Command{ Use: "get ", - Short: "Get a configuration value", + Short: tr.T("cmd.config.get.short"), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { val, err := internalConfig.Get(args[0]) @@ -61,30 +82,49 @@ func newGetCmd() *cobra.Command { return err } if val == "" { - fmt.Printf("%s: (not set)\n", args[0]) - } else { - fmt.Printf("%s: %s\n", args[0], val) + _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], tr.T("output.config.not_set")) + return err } - return nil + _, err = fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], val) + return err }, } } -func newListCmd() *cobra.Command { +func newListCmd(translators ...*i18n.Translator) *cobra.Command { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } return &cobra.Command{ Use: "list", - Short: "List all configuration values", + Short: tr.T("cmd.config.list.short"), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := internalConfig.Load() if err != nil { return err } - fmt.Printf("base_url: %s\n", cfg.BaseURL) - fmt.Printf("default_format: %s\n", cfg.Format) - fmt.Printf("editor: %s\n", cfg.Editor) - fmt.Printf("pager: %s\n", cfg.Pager) - fmt.Printf("\nConfig file: %s\n", internalConfig.ConfigPath()) - return nil + out := cmd.OutOrStdout() + if _, err := fmt.Fprintf(out, "base_url: %s\n", cfg.BaseURL); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "default_format: %s\n", cfg.Format); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "editor: %s\n", cfg.Editor); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "pager: %s\n", cfg.Pager); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "lang: %s\n", cfg.Lang); err != nil { + return err + } + if _, err := fmt.Fprintln(out); err != nil { + return err + } + _, err = fmt.Fprintln(out, tr.Tf("output.config.file", i18n.Args{"path": internalConfig.ConfigPath()})) + return err }, } } diff --git a/cmd/root.go b/cmd/root.go index 441a8ed..26278d8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" @@ -10,45 +11,117 @@ import ( authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" configCmd "github.com/gitlink-org/gitlink-cli/cmd/config" + internalConfig "github.com/gitlink-org/gitlink-cli/internal/config" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts" ) var Version = "dev" -var rootCmd = &cobra.Command{ - Use: "gitlink-cli", - Short: "GitLink CLI — command-line tool for gitlink.org.cn", - Long: `gitlink-cli is a command-line interface for the GitLink (确实开源) platform, providing repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows.`, - SilenceUsage: true, - SilenceErrors: true, +type RootOptions struct { + Version string + Args []string + Env map[string]string + ConfigLang string } -func init() { - rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "Repository owner (auto-detected from git remote)") - rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "Repository name (auto-detected from git remote)") - rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "Output format: json, table, yaml (default: table)") - rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output") +func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) { + if tr == nil { + var err error + tr, err = newTranslator(opts.Args, opts.Env, opts.ConfigLang) + if err != nil { + return nil, err + } + } - rootCmd.AddCommand(authCmd.NewAuthCmd()) - rootCmd.AddCommand(apiCmd.NewAPICmd()) - rootCmd.AddCommand(configCmd.NewConfigCmd()) - rootCmd.AddCommand(versionCmd) + version := opts.Version + if version == "" { + version = Version + } - shortcuts.RegisterAll(rootCmd) + rootCmd := &cobra.Command{ + Use: "gitlink-cli", + Short: tr.T("cmd.root.short"), + Long: tr.T("cmd.root.long"), + SilenceUsage: true, + SilenceErrors: true, + } + + rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", tr.T("flag.owner")) + rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", tr.T("flag.repo")) + rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", tr.T("flag.format")) + rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, tr.T("flag.debug")) + rootCmd.PersistentFlags().StringVar(&cmdutil.Lang, "lang", "", tr.T("flag.lang")) + + rootCmd.AddCommand(authCmd.NewAuthCmd(tr)) + rootCmd.AddCommand(apiCmd.NewAPICmd(tr)) + rootCmd.AddCommand(configCmd.NewConfigCmd(tr)) + rootCmd.AddCommand(newVersionCmd(version, tr)) + + shortcuts.RegisterAll(rootCmd, tr) + + if opts.Args != nil { + rootCmd.SetArgs(opts.Args) + } + return rootCmd, nil } -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print version information", - Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("gitlink-cli %s\n", Version) - }, +func newVersionCmd(version string, tr *i18n.Translator) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: tr.T("cmd.version.short"), + RunE: func(cmd *cobra.Command, args []string) error { + _, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.version", i18n.Args{"version": version})) + return err + }, + } } func Execute() error { + args := os.Args[1:] + rootCmd, err := NewRootCmd(RootOptions{ + Version: Version, + Args: args, + }, nil) + if err != nil { + fmt.Fprintln(os.Stderr, err) + return err + } + if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) return err } return nil } + +func newTranslator(args []string, env map[string]string, configLang string) (*i18n.Translator, error) { + available, err := i18n.AvailableLocales() + if err != nil { + return nil, err + } + if env == nil { + env = i18n.EnvMap() + } + if configLang == "" { + configLang = loadConfigLangBestEffort() + } + resolved := i18n.ResolveLocaleDetailed(i18n.ResolveOptions{ + ExplicitLang: i18n.PreScanLang(args), + Env: env, + ConfigLang: configLang, + }, available) + if !resolved.Supported && (resolved.Source == "flag" || resolved.Source == "env") { + tr := i18n.Default() + return nil, errors.New(tr.Tf("error.unsupported_language", i18n.Args{"lang": resolved.Requested})) + } + return i18n.New(i18n.Options{Locale: resolved.Locale}) +} + +func loadConfigLangBestEffort() string { + cfg, err := internalConfig.Load() + if err != nil { + return "" + } + return cfg.Lang +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..35f810b --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,310 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" +) + +func TestRootHelpUsesSelectedLocale(t *testing.T) { + tr, err := i18n.New(i18n.Options{Locale: "zh-CN"}) + if err != nil { + t.Fatal(err) + } + root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--help"}}, tr) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + help := out.String() + if !strings.Contains(help, "用于管理 GitLink 上的仓库") { + t.Fatalf("expected Chinese root long help, got:\n%s", help) + } + if !strings.Contains(help, "仓库操作") { + t.Fatalf("expected Chinese shortcut group help, got:\n%s", help) + } + if !strings.Contains(help, "认证命令") || !strings.Contains(help, "管理 gitlink-cli 配置") { + t.Fatalf("expected Chinese core command help, got:\n%s", help) + } + if !strings.Contains(help, "--lang") || !strings.Contains(help, "显示语言") { + t.Fatalf("expected localized lang flag help, got:\n%s", help) + } +} + +func TestRootHelpUsesExplicitLang(t *testing.T) { + root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--lang", "zh-CN", "--help"}, Env: map[string]string{}}, nil) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + help := out.String() + for _, want := range []string{"用于管理 GitLink", "显示语言", "仓库"} { + if !strings.Contains(help, want) { + t.Fatalf("expected %q in help, got:\n%s", want, help) + } + } +} + +func TestRootHelpUsesEnvLang(t *testing.T) { + root, err := NewRootCmd(RootOptions{ + Version: "test", + Args: []string{"repo", "--help"}, + Env: map[string]string{"GITLINK_LANG": "zh-CN"}, + }, nil) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + help := out.String() + for _, want := range []string{"仓库操作", "仓库所有者", "仓库名称"} { + if !strings.Contains(help, want) { + t.Fatalf("expected %q in help, got:\n%s", want, help) + } + } +} + +func TestExplicitLangOverridesConfigLang(t *testing.T) { + root, err := NewRootCmd(RootOptions{ + Version: "test", + Args: []string{"--lang", "en-US", "--help"}, + Env: map[string]string{}, + ConfigLang: "zh-CN", + }, nil) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + help := out.String() + if !strings.Contains(help, "Repository operations") { + t.Fatalf("expected English help, got:\n%s", help) + } + if strings.Contains(help, "仓库操作") { + t.Fatalf("expected explicit en-US to override config zh-CN, got:\n%s", help) + } +} + +func TestUnsupportedExplicitLangReturnsError(t *testing.T) { + _, err := NewRootCmd(RootOptions{ + Version: "test", + Args: []string{"--lang", "fr-FR", "--help"}, + Env: map[string]string{}, + }, nil) + if err == nil { + t.Fatal("expected unsupported language error") + } + if !strings.Contains(err.Error(), "unsupported language") { + t.Fatalf("expected unsupported language error, got %q", err.Error()) + } +} + +func TestRequireArgUsesLocalizedError(t *testing.T) { + root, err := NewRootCmd(RootOptions{ + Version: "test", + Args: []string{"--lang", "zh-CN", "repo", "+create"}, + Env: map[string]string{}, + }, nil) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + err = root.Execute() + if err == nil { + t.Fatal("expected missing required flag error") + } + if !strings.Contains(err.Error(), "缺少必需参数") { + t.Fatalf("expected localized missing flag error, got %q", err.Error()) + } +} + +func TestCoreCommandHelpUsesSelectedLocale(t *testing.T) { + tr, err := i18n.New(i18n.Options{Locale: "zh-CN"}) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + args []string + want []string + }{ + { + args: []string{"api", "--help"}, + want: []string{"向 GitLink API 发送任意 HTTP 请求", "--body", "请求体(JSON 字符串)"}, + }, + { + args: []string{"auth", "login", "--help"}, + want: []string{"登录 GitLink", "--token", "通过粘贴已有 Token 登录"}, + }, + { + args: []string{"config", "--help"}, + want: []string{"管理 gitlink-cli 配置", "初始化配置文件", "列出所有配置项"}, + }, + } + + for _, tc := range cases { + root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatalf("%v: %v", tc.args, err) + } + + help := out.String() + for _, want := range tc.want { + if !strings.Contains(help, want) { + t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help) + } + } + } +} + +func TestShortcutHelpUsesSelectedLocale(t *testing.T) { + tr, err := i18n.New(i18n.Options{Locale: "zh-CN"}) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + args []string + want []string + }{ + { + args: []string{"repo", "+create", "--help"}, + want: []string{"创建新仓库", "--name", "仓库名称", "--private", "设为私有仓库"}, + }, + { + args: []string{"pr", "+review", "--help"}, + want: []string{"创建拉取请求评审", "--content", "评审内容", "--dry-run"}, + }, + } + + for _, tc := range cases { + root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatalf("%v: %v", tc.args, err) + } + + help := out.String() + for _, want := range tc.want { + if !strings.Contains(help, want) { + t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help) + } + } + } +} + +func TestRemainingShortcutHelpUsesSelectedLocale(t *testing.T) { + tr, err := i18n.New(i18n.Options{Locale: "zh-CN"}) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + args []string + want []string + }{ + { + args: []string{"branch", "+create", "--help"}, + want: []string{"创建分支", "--from", "源分支或 Commit"}, + }, + { + args: []string{"release", "+create", "--help"}, + want: []string{"创建发布", "--prerelease", "标记为预发布"}, + }, + { + args: []string{"webhook", "+create", "--help"}, + want: []string{"创建仓库 Webhook", "--events", "逗号分隔的事件"}, + }, + { + args: []string{"ci", "+logs", "--help"}, + want: []string{"查看构建日志", "--build", "构建编号"}, + }, + } + + for _, tc := range cases { + root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatalf("%v: %v", tc.args, err) + } + + help := out.String() + for _, want := range tc.want { + if !strings.Contains(help, want) { + t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help) + } + } + } +} + +func TestVersionUsesInjectedVersion(t *testing.T) { + tr, err := i18n.New(i18n.Options{Locale: "en-US"}) + if err != nil { + t.Fatal(err) + } + root, err := NewRootCmd(RootOptions{Version: "1.2.3", Args: []string{"version"}}, tr) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + if got := strings.TrimSpace(out.String()); got != "gitlink-cli 1.2.3" { + t.Fatalf("version output = %q", got) + } +} diff --git a/doc/changes/issue-id-alias.md b/doc/changes/issue-id-alias.md new file mode 100644 index 0000000..e0e2f94 --- /dev/null +++ b/doc/changes/issue-id-alias.md @@ -0,0 +1,24 @@ +# Issue ID Alias + +## Summary + +`issue +view`, `issue +close`, `issue +update`, and `issue +comment` now accept +`--id` / `-i` as a compatibility alias for `--number` / `-n`. + +The alias uses the same project-level issue number shown in the web URL, for +example `issues/123`. It is not the global database ID. + +`--number` remains the preferred flag and takes precedence when both flags are +provided. + +## Examples + +```bash +gitlink-cli issue +view --owner Gitlink --repo forgeplus --id 123 +gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 +gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 --body "Fixed" +``` + +## Submitter + +Wang Yue diff --git a/doc/changes/issue-metadata-fields.md b/doc/changes/issue-metadata-fields.md new file mode 100644 index 0000000..2246203 --- /dev/null +++ b/doc/changes/issue-metadata-fields.md @@ -0,0 +1,36 @@ +# Issue Metadata Fields + +## Summary + +`issue +create` and `issue +update` now support common GitLink Issue metadata fields. +When updating or closing an Issue, the shortcut also carries the current metadata +back to the API so unrelated fields are not reset by partial updates. + +## Added flags + +| Flag | API field | +|------|-----------| +| `--priority-id` | `priority_id` | +| `--tag-ids` | `issue_tag_ids` | +| `--assigner-ids` | `assigner_ids` | +| `--branch` | `branch_name` | +| `--start-date` | `start_date` | +| `--due-date` | `due_date` | + +`issue +create --label` is also mapped as a single tag ID for backward compatibility. + +## Examples + +```bash +gitlink-cli issue +create --owner Gitlink --repo forgeplus \ + --title "Bug: login failed" \ + --priority-id 3 \ + --tag-ids 4,5 \ + --assigner-ids 7 + +gitlink-cli issue +update --owner Gitlink --repo forgeplus \ + --number 123 \ + --priority-id 4 \ + --branch bugfix/login \ + --due-date 2026-06-15 +``` diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 0000000..23efe57 --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,77 @@ +# GitLink CLI i18n Guide + +## Goals + +GitLink CLI localizes human-facing command-line text while keeping machine-readable output stable. The i18n layer is infrastructure, not a place to store every string in the project. + +## Translate + +- Cobra command `Short`, `Long`, and human-facing examples. +- Flag usage text. +- User-facing errors. +- Interactive prompts. +- Success messages. +- Warnings. +- Confirmation messages. +- Table column labels when the output is meant for humans. + +## Do Not Translate + +- JSON field names. +- Raw API response bodies. +- Debug logs and developer diagnostics. +- Machine-readable status enum values. +- HTTP methods, paths, query keys, and payload field names. +- Long-form README documentation. +- Test assertion descriptions. + +## Key Names + +Use stable, descriptive keys: + +- `cmd.*` for command help. +- `flag.*` for flag usage. +- `error.*` for user-facing errors. +- `prompt.*` for interactive input prompts. +- `success.*` for successful user-facing operations. +- `warning.*` for warnings. +- `confirm.*` for confirmation prompts. +- `table.*` for human table headers. + +Do not invent numbered keys such as `msg001`. Prefer names that describe ownership and intent, for example `error.missing_required_flag`. + +## Adding Text + +1. Add the key to `internal/i18n/locales/en-US.json`. +2. Add the same key to every other locale, including `zh-CN.json`. +3. Keep placeholders identical across locales, for example `{name}`. +4. Use `tr.T("key")` or `tr.Tf("key", i18n.Args{...})`. +5. Run: + + ```powershell + go run ./internal/i18n/cmd/check + go test ./... + ``` + +Use `go run ./internal/i18n/cmd/check --fix` to format locale JSON. + +Use `go run ./internal/i18n/cmd/check --scan-code` before opening a PR. The scanner is intentionally lightweight: + +- Name command-construction translators `tr` when calling `tr.T(...)` or `tr.Tf(...)`. +- Use `ctx.Tr.T(...)` or `ctx.Tr.Tf(...)` in runtime shortcut code. +- Avoid calling translator methods through other variable names such as `translator.T(...)`; the current scan may not detect them. +- Do not add new `i18n.Default().T(...)` or `i18n.Default().Tf(...)` usages. + +## Runtime Access + +Command construction receives `*i18n.Translator` from `NewRootCmd`. Shortcut execution receives the same translator through `RuntimeContext.Tr`. + +New command code should receive a translator explicitly. `i18n.Default()` exists only as a legacy migration fallback and should not be used for new command paths. + +## Review Checklist + +- Locale JSON is sorted and formatted with two spaces. +- Every locale has the same keys as `en-US`. +- Template placeholders match across locales. +- New command/runtime text uses i18n only when it is human-facing. +- JSON output, API raw responses, debug logs, and machine-readable values are unchanged. diff --git a/internal/config/config.go b/internal/config/config.go index e8ec429..0b3f269 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { Format string `yaml:"default_format"` Editor string `yaml:"editor,omitempty"` Pager string `yaml:"pager,omitempty"` + Lang string `yaml:"lang,omitempty"` } func DefaultConfig() *Config { @@ -85,6 +86,8 @@ func Get(key string) (string, error) { return cfg.Editor, nil case "pager": return cfg.Pager, nil + case "lang": + return cfg.Lang, nil default: return "", nil } @@ -104,6 +107,8 @@ func Set(key, value string) error { cfg.Editor = value case "pager": cfg.Pager = value + case "lang": + cfg.Lang = value } return Save(cfg) } diff --git a/internal/i18n/args.go b/internal/i18n/args.go new file mode 100644 index 0000000..0eb08fb --- /dev/null +++ b/internal/i18n/args.go @@ -0,0 +1,4 @@ +package i18n + +// Args contains named values used by parameterized messages. +type Args map[string]any diff --git a/internal/i18n/cmd/check/main.go b/internal/i18n/cmd/check/main.go new file mode 100644 index 0000000..7361d65 --- /dev/null +++ b/internal/i18n/cmd/check/main.go @@ -0,0 +1,163 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" +) + +func main() { + fix := flag.Bool("fix", false, "format locale JSON files") + scanCode := flag.Bool("scan-code", false, "scan Go source for referenced i18n keys") + flag.Parse() + + problems, err := i18n.Validate(i18n.NewEmbedLoader(), "en-US") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if len(problems) > 0 { + for _, problem := range problems { + fmt.Fprintln(os.Stderr, problem.String()) + } + os.Exit(1) + } + if err := checkLocaleFormat(*fix); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if *scanCode { + if err := checkCodeReferences(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + fmt.Println("i18n messages are valid") +} + +func checkLocaleFormat(fix bool) error { + files, err := filepath.Glob(filepath.Join("internal", "i18n", "locales", "*.json")) + if err != nil { + return err + } + for _, path := range files { + data, err := os.ReadFile(path) + if err != nil { + return err + } + formatted, err := formatJSON(data) + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + if string(data) == string(formatted) { + continue + } + if fix { + if err := os.WriteFile(path, formatted, 0600); err != nil { + return err + } + continue + } + return fmt.Errorf("%s: locale JSON is not formatted; run go run ./internal/i18n/cmd/check --fix", path) + } + return nil +} + +func formatJSON(data []byte) ([]byte, error) { + var messages map[string]string + if err := json.Unmarshal(data, &messages); err != nil { + return nil, err + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(messages); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func checkCodeReferences() error { + loader := i18n.NewEmbedLoader() + base, err := loader.Load("en-US") + if err != nil { + return err + } + used, defaultUses, err := scanCodeKeys([]string{"cmd", "shortcuts", "internal"}) + if err != nil { + return err + } + var missing []string + for key := range used { + if _, ok := base[key]; !ok { + missing = append(missing, key) + } + } + sort.Strings(missing) + if len(missing) > 0 { + return fmt.Errorf("missing i18n key references: %s", strings.Join(missing, ", ")) + } + for _, item := range defaultUses { + fmt.Fprintf(os.Stderr, "warning: avoid new i18n.Default() usage at %s\n", item) + } + return nil +} + +func scanCodeKeys(roots []string) (map[string]struct{}, []string, error) { + keyPattern := regexp.MustCompile(`(?:tr|ctx\.Tr|i18n\.Default\(\))\.T(?:f)?\("([^"]+)"`) + defaultPattern := regexp.MustCompile(`i18n\.Default\(\)\.T(?:f)?\("([^"]+)"`) + used := map[string]struct{}{} + var defaultUses []string + for _, root := range roots { + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + if strings.Contains(filepath.ToSlash(path), "internal/i18n/locales") { + return filepath.SkipDir + } + return nil + } + if entry.Type()&os.ModeSymlink != 0 { + return nil + } + if filepath.Ext(path) != ".go" { + return nil + } + if strings.HasSuffix(path, "_test.go") { + return nil + } + if !entry.Type().IsRegular() { + return nil + } + data, err := os.ReadFile(path) // #nosec G122 -- dev-only scan over repo roots; symlinks are skipped above. + if err != nil { + return err + } + text := string(data) + for _, match := range keyPattern.FindAllStringSubmatch(text, -1) { + used[match[1]] = struct{}{} + } + for _, match := range defaultPattern.FindAllStringSubmatchIndex(text, -1) { + line := 1 + strings.Count(text[:match[0]], "\n") + defaultUses = append(defaultUses, fmt.Sprintf("%s:%d", filepath.ToSlash(path), line)) + } + return nil + }) + if err != nil { + return nil, nil, err + } + } + sort.Strings(defaultUses) + return used, defaultUses, nil +} diff --git a/internal/i18n/doc.go b/internal/i18n/doc.go new file mode 100644 index 0000000..1b19950 --- /dev/null +++ b/internal/i18n/doc.go @@ -0,0 +1,5 @@ +// Package i18n provides localized user-facing messages for the CLI. +// +// It intentionally does not localize machine-readable output such as JSON +// field names, API response bodies, or debug diagnostics. +package i18n diff --git a/internal/i18n/loader.go b/internal/i18n/loader.go new file mode 100644 index 0000000..ff29b1e --- /dev/null +++ b/internal/i18n/loader.go @@ -0,0 +1,66 @@ +package i18n + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "path/filepath" + "sort" + "strings" +) + +//go:embed locales/*.json +var embeddedLocales embed.FS + +// Loader loads locale messages from a backing store. +type Loader interface { + Load(locale string) (map[string]string, error) + AvailableLocales() ([]string, error) +} + +type embedLoader struct { + fs fs.FS +} + +// NewEmbedLoader returns the default loader backed by embedded locale files. +func NewEmbedLoader() Loader { + return embedLoader{fs: embeddedLocales} +} + +func (l embedLoader) Load(locale string) (map[string]string, error) { + locale = NormalizeLocale(locale) + path := filepath.ToSlash(filepath.Join("locales", locale+".json")) + data, err := fs.ReadFile(l.fs, path) + if err != nil { + return nil, fmt.Errorf("load locale %s: %w", locale, err) + } + + var messages map[string]string + if err := json.Unmarshal(data, &messages); err != nil { + return nil, fmt.Errorf("parse locale %s: %w", locale, err) + } + return messages, nil +} + +func (l embedLoader) AvailableLocales() ([]string, error) { + entries, err := fs.ReadDir(l.fs, "locales") + if err != nil { + return nil, err + } + + locales := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + locales = append(locales, strings.TrimSuffix(entry.Name(), ".json")) + } + sort.Strings(locales) + return locales, nil +} + +// AvailableLocales returns locales available from the embedded loader. +func AvailableLocales() ([]string, error) { + return NewEmbedLoader().AvailableLocales() +} diff --git a/internal/i18n/locale.go b/internal/i18n/locale.go new file mode 100644 index 0000000..0ec7131 --- /dev/null +++ b/internal/i18n/locale.go @@ -0,0 +1,100 @@ +package i18n + +import ( + "strings" +) + +// NormalizeLocale converts common locale spellings to a stable BCP-47-like form. +func NormalizeLocale(locale string) string { + locale = strings.TrimSpace(locale) + if locale == "" { + return "" + } + if idx := strings.IndexByte(locale, '.'); idx >= 0 { + locale = locale[:idx] + } + locale = strings.ReplaceAll(locale, "_", "-") + + parts := strings.Split(locale, "-") + normalized := make([]string, 0, len(parts)) + for i, part := range parts { + if part == "" { + continue + } + switch { + case i == 0: + normalized = append(normalized, strings.ToLower(part)) + case len(part) == 2: + normalized = append(normalized, strings.ToUpper(part)) + case len(part) == 4: + normalized = append(normalized, strings.ToUpper(part[:1])+strings.ToLower(part[1:])) + default: + normalized = append(normalized, part) + } + } + return strings.Join(normalized, "-") +} + +// MatchLocale resolves requested to one of available using exact, safe alias, +// then fallback matching. +func MatchLocale(requested string, available []string, fallback string) string { + return matchLocale(requested, available, fallback).Locale +} + +type localeMatch struct { + Locale string + Requested string + Fallbacked bool + Supported bool +} + +func matchLocale(requested string, available []string, fallback string) localeMatch { + fallback = NormalizeLocale(fallback) + if fallback == "" { + fallback = defaultFallbackLocale + } + if len(available) == 0 { + return localeMatch{Locale: fallback, Requested: NormalizeLocale(requested), Fallbacked: true} + } + + byLocale := make(map[string]string, len(available)) + for _, locale := range available { + normalized := NormalizeLocale(locale) + byLocale[normalized] = normalized + } + + candidate := NormalizeLocale(requested) + if candidate != "" { + if matched, ok := byLocale[candidate]; ok { + return localeMatch{Locale: matched, Requested: candidate, Supported: true} + } + if alias := localeAlias(candidate); alias != "" { + if matched, ok := byLocale[alias]; ok { + return localeMatch{Locale: matched, Requested: candidate, Supported: true} + } + } + } + + if matched, ok := byLocale[fallback]; ok { + return localeMatch{Locale: matched, Requested: candidate, Fallbacked: candidate != "", Supported: false} + } + return localeMatch{Locale: NormalizeLocale(available[0]), Requested: candidate, Fallbacked: candidate != "", Supported: false} +} + +func primaryLanguage(locale string) string { + if idx := strings.IndexByte(locale, '-'); idx >= 0 { + return locale[:idx] + } + return locale +} + +func localeAlias(locale string) string { + switch { + case locale == "zh" || locale == "zh-CN" || locale == "zh-Hans" || locale == "zh-Hans-CN": + return "zh-CN" + case primaryLanguage(locale) == "en": + return "en-US" + default: + return "" + } +} diff --git a/internal/i18n/locale_test.go b/internal/i18n/locale_test.go new file mode 100644 index 0000000..90dd4a6 --- /dev/null +++ b/internal/i18n/locale_test.go @@ -0,0 +1,35 @@ +package i18n + +import "testing" + +func TestNormalizeLocale(t *testing.T) { + cases := map[string]string{ + "zh_CN": "zh-CN", + "zh_CN.UTF-8": "zh-CN", + "zh-Hans-CN": "zh-Hans-CN", + "EN_us": "en-US", + " en-US ": "en-US", + "zh-hans-cn.utf": "zh-Hans-CN", + } + for input, want := range cases { + if got := NormalizeLocale(input); got != want { + t.Fatalf("NormalizeLocale(%q) = %q, want %q", input, got, want) + } + } +} + +func TestMatchLocale(t *testing.T) { + available := []string{"en-US", "zh-CN"} + cases := map[string]string{ + "zh_CN": "zh-CN", + "zh-Hans-CN": "zh-CN", + "zh": "zh-CN", + "en": "en-US", + "fr-FR": "en-US", + } + for input, want := range cases { + if got := MatchLocale(input, available, "en-US"); got != want { + t.Fatalf("MatchLocale(%q) = %q, want %q", input, got, want) + } + } +} diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json new file mode 100644 index 0000000..d9f3c34 --- /dev/null +++ b/internal/i18n/locales/en-US.json @@ -0,0 +1,188 @@ +{ + "cmd.api.long": "Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.", + "cmd.api.short": "Make raw API requests to GitLink", + "cmd.auth.login.short": "Login to GitLink", + "cmd.auth.logout.short": "Logout from GitLink", + "cmd.auth.short": "Authentication commands", + "cmd.auth.status.short": "Show authentication status", + "cmd.branch.create.short": "Create a branch", + "cmd.branch.delete.short": "Delete a branch", + "cmd.branch.list.short": "List branches", + "cmd.branch.protect.short": "Set branch protection", + "cmd.branch.short": "Branch operations", + "cmd.branch.unprotect.short": "Remove branch protection", + "cmd.ci.builds.short": "List CI builds", + "cmd.ci.logs.short": "View build logs", + "cmd.ci.restart.short": "Restart a build", + "cmd.ci.short": "CI/CD operations", + "cmd.ci.stop.short": "Stop a build", + "cmd.config.get.short": "Get a configuration value", + "cmd.config.init.short": "Initialize configuration file", + "cmd.config.list.short": "List all configuration values", + "cmd.config.set.short": "Set a configuration value", + "cmd.config.short": "Manage gitlink-cli configuration", + "cmd.issue.batch_close.long": "Close filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote close operations. Use restrictive filters and a small limit.\n\nExamples:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", + "cmd.issue.batch_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", + "cmd.issue.batch_label.long": "Add a label to filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.\n\nExamples:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", + "cmd.issue.batch_label.short": "Add a label to filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", + "cmd.issue.batch_list.long": "List issue batch maintenance candidates without changing remote data.\n\nExamples:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json", + "cmd.issue.batch_list.short": "List issue batch maintenance candidates without changing remote data", + "cmd.issue.close.short": "Close an issue", + "cmd.issue.comment.short": "Add a comment to an issue", + "cmd.issue.create.short": "Create a new issue", + "cmd.issue.list.short": "List issues", + "cmd.issue.short": "Issue operations", + "cmd.issue.update.short": "Update an issue", + "cmd.issue.view.short": "View issue details", + "cmd.org.create.short": "Create an organization", + "cmd.org.info.short": "Show organization details", + "cmd.org.list.short": "List organizations", + "cmd.org.members.short": "List organization members", + "cmd.org.short": "Organization operations", + "cmd.pr.close.short": "Close a pull request", + "cmd.pr.comment.short": "Add a comment to a pull request", + "cmd.pr.create.short": "Create a pull request", + "cmd.pr.diff.short": "Show diff for a pull request", + "cmd.pr.files.short": "List changed files in a pull request", + "cmd.pr.list.short": "List pull requests", + "cmd.pr.merge.short": "Merge a pull request", + "cmd.pr.review.short": "Create a pull request review", + "cmd.pr.reviews.short": "List pull request reviews", + "cmd.pr.short": "Pull request operations", + "cmd.pr.version_diff.short": "Show diff for a pull request patchset version", + "cmd.pr.versions.short": "List pull request patchset versions", + "cmd.pr.view.short": "View pull request details", + "cmd.release.create.short": "Create a release", + "cmd.release.delete.short": "Delete a release", + "cmd.release.list.short": "List releases", + "cmd.release.short": "Release operations", + "cmd.release.view.short": "View release details", + "cmd.repo.create.short": "Create a new repository", + "cmd.repo.delete.short": "Delete a repository", + "cmd.repo.fork.short": "Fork a repository", + "cmd.repo.info.short": "Show repository details", + "cmd.repo.list.short": "List repositories for a user or organization", + "cmd.repo.short": "Repository operations", + "cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.", + "cmd.root.short": "GitLink CLI - command-line tool for GitLink", + "cmd.search.repos.short": "Search repositories", + "cmd.search.short": "Search operations", + "cmd.search.users.short": "Search users", + "cmd.user.info.short": "Show user profile", + "cmd.user.me.short": "Show current authenticated user", + "cmd.user.short": "User operations", + "cmd.version.short": "Print version information", + "cmd.webhook.create.short": "Create a repository webhook", + "cmd.webhook.delete.short": "Delete a repository webhook", + "cmd.webhook.list.short": "List repository webhooks", + "cmd.webhook.short": "Webhook operations", + "cmd.webhook.tasks.short": "List webhook delivery tasks", + "cmd.webhook.test.short": "Trigger a test delivery for a webhook", + "cmd.webhook.update.short": "Update a repository webhook while preserving unspecified fields when available", + "cmd.webhook.view.short": "View webhook details", + "error.auth.delete_token_failed": "failed to delete token: {message}", + "error.auth.login_failed": "login failed: {message}", + "error.auth.store_token_failed": "failed to store token: {message}", + "error.auth.token_empty": "token cannot be empty", + "error.config.save_failed": "failed to save config: {message}", + "error.missing_required_flag": "required flag --{name} is missing", + "error.unsupported_language": "unsupported language: {lang}", + "flag.api.body": "Request body (JSON string)", + "flag.api.body_file": "Read request body JSON from a file", + "flag.api.body_stdin": "Read request body JSON from stdin", + "flag.api.header": "Additional headers (key:value)", + "flag.api.query": "Query parameters (key=val&key2=val2)", + "flag.auth.token": "Login by pasting an existing token", + "flag.branch.from": "Source branch or commit", + "flag.branch.name": "Branch name", + "flag.ci.build": "Build number", + "flag.ci.stage": "Stage number", + "flag.ci.step": "Step number", + "flag.comment.body": "Comment body", + "flag.debug": "Enable debug output", + "flag.description": "Description", + "flag.dry_run": "Preview the request without creating it", + "flag.format": "Output format: json, table, yaml (default: table)", + "flag.issue.add_label": "Label to add to each matching issue", + "flag.issue.assignee": "Assignee login", + "flag.issue.batch.reason": "Optional reason shown in the batch result", + "flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.", + "flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7", + "flag.issue.batch_close.state": "Filter by issue state before closing", + "flag.issue.batch_label.state": "Filter by issue state", + "flag.issue.batch_list.limit": "Maximum issues to return, capped at 100", + "flag.issue.batch_process.limit": "Maximum issues to process, capped at 100", + "flag.issue.body": "Issue description", + "flag.issue.label": "Label ID", + "flag.issue.label_filter": "Filter by existing label", + "flag.issue.milestone": "Milestone ID", + "flag.issue.new_body": "New description", + "flag.issue.new_state": "New state: open, closed, or numeric status_id", + "flag.issue.new_title": "New title", + "flag.issue.number": "Issue number (as shown in the web URL)", + "flag.issue.older_than_days": "Only include issues inactive for at least this many days", + "flag.issue.state": "Filter by state: open, closed, all", + "flag.issue.title": "Issue title", + "flag.lang": "Display language", + "flag.limit": "Items per page", + "flag.org.id": "Organization ID", + "flag.org.id_or_login": "Organization ID or login", + "flag.org.name": "Organization name", + "flag.owner": "Repository owner (auto-detected from git remote)", + "flag.page": "Page number", + "flag.pr.base": "Target branch", + "flag.pr.body": "PR description", + "flag.pr.file": "Filter diff by file path", + "flag.pr.head": "Source branch", + "flag.pr.id": "PR number", + "flag.pr.merge_method": "Merge method: merge, rebase, squash", + "flag.pr.review_commit": "Commit SHA to attach the review to", + "flag.pr.review_content": "Review content", + "flag.pr.review_status": "Review status: common, approved, rejected", + "flag.pr.review_status_filter": "Filter review status: common, approved, rejected", + "flag.pr.state": "Filter: open, merged, closed", + "flag.pr.title": "PR title", + "flag.pr.version_id": "Patchset version ID", + "flag.release.body": "Release notes", + "flag.release.id": "Release ID", + "flag.release.id_or_tag": "Release ID or tag", + "flag.release.name": "Release name", + "flag.release.prerelease": "Mark as prerelease (true/false)", + "flag.release.tag": "Tag name", + "flag.release.target": "Target branch", + "flag.repo": "Repository name (auto-detected from git remote)", + "flag.repo.category": "Filter: manage/mirror/sync/fork/all (default: manage)", + "flag.repo.description": "Repository description", + "flag.repo.name": "Repository name", + "flag.repo.private": "Make repository private (true/false)", + "flag.search.keyword": "Search keyword", + "flag.user": "User login (default: current user)", + "flag.user.login": "User login name", + "flag.webhook.active": "Whether the webhook is active: true or false", + "flag.webhook.branch_filter": "Branch glob filter for push/create/delete events", + "flag.webhook.content_type": "Payload content type: json or form", + "flag.webhook.events": "Comma-separated events, for example: push,issues_only", + "flag.webhook.http_method": "HTTP method: POST or GET", + "flag.webhook.id": "Webhook ID", + "flag.webhook.secret": "Webhook secret", + "flag.webhook.secret_update": "Webhook secret. Pass it again if the server does not return existing secrets.", + "flag.webhook.type": "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", + "flag.webhook.url": "Webhook target URL", + "output.auth.env_hint": " Or set {env} environment variable", + "output.auth.login_hint": " Run: gitlink-cli auth login", + "output.config.file": "Config file: {path}", + "output.config.not_set": "(not set)", + "output.version": "gitlink-cli {version}", + "prompt.auth.password": "Password: ", + "prompt.auth.token": "Paste your access token: ", + "prompt.auth.username": "Username/Email/Phone: ", + "success.auth.logged_in_as": "✓ Logged in as {login}", + "success.auth.logged_in_via_env": "✓ Logged in via {env} environment variable", + "success.auth.logged_out": "✓ Logged out", + "success.auth.token_saved": "✓ Token saved", + "success.config.initialized": "✓ Config initialized at {path}", + "success.config.set": "✓ {key} = {value}", + "warning.auth.not_logged_in": "✗ Not logged in", + "warning.auth.token_unverified": "✓ Token stored (but cannot verify: {message})", + "warning.auth.user_unavailable": "✓ Token stored (user info unavailable)" +} diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json new file mode 100644 index 0000000..848e605 --- /dev/null +++ b/internal/i18n/locales/zh-CN.json @@ -0,0 +1,188 @@ +{ + "cmd.api.long": "向 GitLink API 发送任意 HTTP 请求。认证信息会自动注入。", + "cmd.api.short": "向 GitLink 发起原始 API 请求", + "cmd.auth.login.short": "登录 GitLink", + "cmd.auth.logout.short": "退出 GitLink 登录", + "cmd.auth.short": "认证命令", + "cmd.auth.status.short": "显示认证状态", + "cmd.branch.create.short": "创建分支", + "cmd.branch.delete.short": "删除分支", + "cmd.branch.list.short": "列出分支", + "cmd.branch.protect.short": "设置分支保护", + "cmd.branch.short": "分支操作", + "cmd.branch.unprotect.short": "移除分支保护", + "cmd.ci.builds.short": "列出 CI 构建", + "cmd.ci.logs.short": "查看构建日志", + "cmd.ci.restart.short": "重启构建", + "cmd.ci.short": "CI/CD 操作", + "cmd.ci.stop.short": "停止构建", + "cmd.config.get.short": "获取配置项", + "cmd.config.init.short": "初始化配置文件", + "cmd.config.list.short": "列出所有配置项", + "cmd.config.set.short": "设置配置项", + "cmd.config.short": "管理 gitlink-cli 配置", + "cmd.issue.batch_close.long": "批量关闭筛选后的议题。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端关闭操作。请使用严格筛选条件和较小 limit。\n\n示例:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", + "cmd.issue.batch_close.short": "批量关闭筛选后的议题。默认 dry-run;传入 --yes 后执行。", + "cmd.issue.batch_label.long": "给筛选后的议题批量添加标签。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端标签操作。当前实现不会在 API 端点不可用时伪造写入结果。\n\n示例:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", + "cmd.issue.batch_label.short": "给筛选后的议题批量添加标签。默认 dry-run;传入 --yes 后执行。", + "cmd.issue.batch_list.long": "列出议题批量维护候选项,不修改远端数据。\n\n示例:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json", + "cmd.issue.batch_list.short": "列出议题批量维护候选项,不修改远端数据", + "cmd.issue.close.short": "关闭议题", + "cmd.issue.comment.short": "给议题添加评论", + "cmd.issue.create.short": "创建新议题", + "cmd.issue.list.short": "列出议题", + "cmd.issue.short": "议题操作", + "cmd.issue.update.short": "更新议题", + "cmd.issue.view.short": "查看议题详情", + "cmd.org.create.short": "创建组织", + "cmd.org.info.short": "显示组织详情", + "cmd.org.list.short": "列出组织", + "cmd.org.members.short": "列出组织成员", + "cmd.org.short": "组织操作", + "cmd.pr.close.short": "关闭拉取请求", + "cmd.pr.comment.short": "给拉取请求添加评论", + "cmd.pr.create.short": "创建拉取请求", + "cmd.pr.diff.short": "显示拉取请求 diff", + "cmd.pr.files.short": "列出拉取请求中的变更文件", + "cmd.pr.list.short": "列出拉取请求", + "cmd.pr.merge.short": "合并拉取请求", + "cmd.pr.review.short": "创建拉取请求评审", + "cmd.pr.reviews.short": "列出拉取请求评审", + "cmd.pr.short": "拉取请求操作", + "cmd.pr.version_diff.short": "显示拉取请求补丁集版本 diff", + "cmd.pr.versions.short": "列出拉取请求补丁集版本", + "cmd.pr.view.short": "查看拉取请求详情", + "cmd.release.create.short": "创建发布", + "cmd.release.delete.short": "删除发布", + "cmd.release.list.short": "列出发布", + "cmd.release.short": "发布操作", + "cmd.release.view.short": "查看发布详情", + "cmd.repo.create.short": "创建新仓库", + "cmd.repo.delete.short": "删除仓库", + "cmd.repo.fork.short": "Fork 仓库", + "cmd.repo.info.short": "显示仓库详情", + "cmd.repo.list.short": "列出用户或组织的仓库", + "cmd.repo.short": "仓库操作", + "cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。", + "cmd.root.short": "GitLink CLI - GitLink 命令行工具", + "cmd.search.repos.short": "搜索仓库", + "cmd.search.short": "搜索操作", + "cmd.search.users.short": "搜索用户", + "cmd.user.info.short": "显示用户资料", + "cmd.user.me.short": "显示当前认证用户", + "cmd.user.short": "用户操作", + "cmd.version.short": "打印版本信息", + "cmd.webhook.create.short": "创建仓库 Webhook", + "cmd.webhook.delete.short": "删除仓库 Webhook", + "cmd.webhook.list.short": "列出仓库 Webhook", + "cmd.webhook.short": "Webhook 操作", + "cmd.webhook.tasks.short": "列出 Webhook 投递任务", + "cmd.webhook.test.short": "触发 Webhook 测试投递", + "cmd.webhook.update.short": "更新仓库 Webhook,并在可用时保留未指定字段", + "cmd.webhook.view.short": "查看 Webhook 详情", + "error.auth.delete_token_failed": "删除 Token 失败:{message}", + "error.auth.login_failed": "登录失败:{message}", + "error.auth.store_token_failed": "保存 Token 失败:{message}", + "error.auth.token_empty": "Token 不能为空", + "error.config.save_failed": "保存配置失败:{message}", + "error.missing_required_flag": "缺少必需参数 --{name}", + "error.unsupported_language": "不支持的语言:{lang}", + "flag.api.body": "请求体(JSON 字符串)", + "flag.api.body_file": "从文件读取 JSON 请求体", + "flag.api.body_stdin": "从标准输入读取 JSON 请求体", + "flag.api.header": "附加请求头(key:value)", + "flag.api.query": "查询参数(key=val&key2=val2)", + "flag.auth.token": "通过粘贴已有 Token 登录", + "flag.branch.from": "源分支或 Commit", + "flag.branch.name": "分支名称", + "flag.ci.build": "构建编号", + "flag.ci.stage": "阶段编号", + "flag.ci.step": "步骤编号", + "flag.comment.body": "评论内容", + "flag.debug": "启用调试输出", + "flag.description": "描述", + "flag.dry_run": "预览请求,不实际创建", + "flag.format": "输出格式:json、table、yaml(默认:table)", + "flag.issue.add_label": "要添加到每个匹配议题的标签", + "flag.issue.assignee": "负责人登录名", + "flag.issue.batch.reason": "批量结果中显示的可选原因", + "flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。", + "flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7", + "flag.issue.batch_close.state": "关闭前按议题状态筛选", + "flag.issue.batch_label.state": "按议题状态筛选", + "flag.issue.batch_list.limit": "最多返回的议题数,上限 100", + "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", + "flag.issue.body": "议题描述", + "flag.issue.label": "标签 ID", + "flag.issue.label_filter": "按已有标签筛选", + "flag.issue.milestone": "里程碑 ID", + "flag.issue.new_body": "新描述", + "flag.issue.new_state": "新状态:open、closed 或数字 status_id", + "flag.issue.new_title": "新标题", + "flag.issue.number": "议题编号(网页 URL 中显示的编号)", + "flag.issue.older_than_days": "只包含至少这么多天未活动的议题", + "flag.issue.state": "按状态筛选:open、closed、all", + "flag.issue.title": "议题标题", + "flag.lang": "显示语言", + "flag.limit": "每页条目数", + "flag.org.id": "组织 ID", + "flag.org.id_or_login": "组织 ID 或登录名", + "flag.org.name": "组织名称", + "flag.owner": "仓库所有者(自动从 git remote 检测)", + "flag.page": "页码", + "flag.pr.base": "目标分支", + "flag.pr.body": "PR 描述", + "flag.pr.file": "按文件路径筛选 diff", + "flag.pr.head": "源分支", + "flag.pr.id": "PR 编号", + "flag.pr.merge_method": "合并方式:merge、rebase、squash", + "flag.pr.review_commit": "关联评审的 Commit SHA", + "flag.pr.review_content": "评审内容", + "flag.pr.review_status": "评审状态:common、approved、rejected", + "flag.pr.review_status_filter": "按评审状态筛选:common、approved、rejected", + "flag.pr.state": "筛选:open、merged、closed", + "flag.pr.title": "PR 标题", + "flag.pr.version_id": "补丁集版本 ID", + "flag.release.body": "发布说明", + "flag.release.id": "发布 ID", + "flag.release.id_or_tag": "发布 ID 或标签", + "flag.release.name": "发布名称", + "flag.release.prerelease": "标记为预发布(true/false)", + "flag.release.tag": "标签名称", + "flag.release.target": "目标分支", + "flag.repo": "仓库名称(自动从 git remote 检测)", + "flag.repo.category": "筛选:manage/mirror/sync/fork/all(默认:manage)", + "flag.repo.description": "仓库描述", + "flag.repo.name": "仓库名称", + "flag.repo.private": "设为私有仓库(true/false)", + "flag.search.keyword": "搜索关键词", + "flag.user": "用户登录名(默认:当前用户)", + "flag.user.login": "用户登录名", + "flag.webhook.active": "Webhook 是否启用:true 或 false", + "flag.webhook.branch_filter": "用于 push/create/delete 事件的分支 glob 筛选", + "flag.webhook.content_type": "Payload 内容类型:json 或 form", + "flag.webhook.events": "逗号分隔的事件,例如:push,issues_only", + "flag.webhook.http_method": "HTTP 方法:POST 或 GET", + "flag.webhook.id": "Webhook ID", + "flag.webhook.secret": "Webhook 密钥", + "flag.webhook.secret_update": "Webhook 密钥。如果服务端不返回已有密钥,请再次传入。", + "flag.webhook.type": "Webhook 类型:gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", + "flag.webhook.url": "Webhook 目标 URL", + "output.auth.env_hint": " 或设置 {env} 环境变量", + "output.auth.login_hint": " 运行:gitlink-cli auth login", + "output.config.file": "配置文件:{path}", + "output.config.not_set": "(未设置)", + "output.version": "gitlink-cli {version}", + "prompt.auth.password": "密码:", + "prompt.auth.token": "粘贴你的访问 Token:", + "prompt.auth.username": "用户名/邮箱/手机号:", + "success.auth.logged_in_as": "✓ 已登录为 {login}", + "success.auth.logged_in_via_env": "✓ 已通过 {env} 环境变量登录", + "success.auth.logged_out": "✓ 已退出登录", + "success.auth.token_saved": "✓ Token 已保存", + "success.config.initialized": "✓ 配置已初始化:{path}", + "success.config.set": "✓ 已设置 {key} = {value}", + "warning.auth.not_logged_in": "✗ 未登录", + "warning.auth.token_unverified": "✓ Token 已保存(但无法验证:{message})", + "warning.auth.user_unavailable": "✓ Token 已保存(用户信息不可用)" +} diff --git a/internal/i18n/options.go b/internal/i18n/options.go new file mode 100644 index 0000000..a1f055d --- /dev/null +++ b/internal/i18n/options.go @@ -0,0 +1,10 @@ +package i18n + +const defaultFallbackLocale = "en-US" + +// Options controls Translator construction. +type Options struct { + Locale string + FallbackLocale string + Loader Loader +} diff --git a/internal/i18n/resolver.go b/internal/i18n/resolver.go new file mode 100644 index 0000000..5e2e694 --- /dev/null +++ b/internal/i18n/resolver.go @@ -0,0 +1,95 @@ +package i18n + +import ( + "os" + "strings" +) + +// ResolveOptions contains locale inputs ordered by caller intent. +type ResolveOptions struct { + ExplicitLang string + Env map[string]string + ConfigLang string +} + +type ResolvedLocale struct { + Locale string + Source string + Requested string + Fallbacked bool + Supported bool +} + +// ResolveLocale resolves a locale using CLI flag, env, config, system env, fallback. +func ResolveLocale(opts ResolveOptions, available []string) string { + return ResolveLocaleDetailed(opts, available).Locale +} + +func ResolveLocaleDetailed(opts ResolveOptions, available []string) ResolvedLocale { + candidates := []struct { + source string + value string + }{ + {source: "flag", value: opts.ExplicitLang}, + {source: "env", value: envValue(opts.Env, "GITLINK_LANG")}, + {source: "config", value: opts.ConfigLang}, + {source: "lc_all", value: envValue(opts.Env, "LC_ALL")}, + {source: "lang", value: envValue(opts.Env, "LANG")}, + } + for _, candidate := range candidates { + if strings.TrimSpace(candidate.value) == "" { + continue + } + match := matchLocale(candidate.value, available, defaultFallbackLocale) + return ResolvedLocale{ + Locale: match.Locale, + Source: candidate.source, + Requested: match.Requested, + Fallbacked: match.Fallbacked, + Supported: match.Supported, + } + } + match := matchLocale(defaultFallbackLocale, available, defaultFallbackLocale) + return ResolvedLocale{ + Locale: match.Locale, + Source: "default", + Requested: match.Requested, + Fallbacked: match.Fallbacked, + Supported: match.Supported, + } +} + +// PreScanLang reads --lang before Cobra constructs localized help text. +func PreScanLang(args []string) string { + for i, arg := range args { + if arg == "--lang" { + if i+1 < len(args) { + return args[i+1] + } + return "" + } + if strings.HasPrefix(arg, "--lang=") { + return strings.TrimPrefix(arg, "--lang=") + } + } + return "" +} + +// EnvMap returns process environment as a string map. +func EnvMap() map[string]string { + env := make(map[string]string) + for _, item := range os.Environ() { + key, value, ok := strings.Cut(item, "=") + if ok { + env[key] = value + } + } + return env +} + +func envValue(env map[string]string, key string) string { + if env == nil { + return os.Getenv(key) + } + return env[key] +} diff --git a/internal/i18n/resolver_test.go b/internal/i18n/resolver_test.go new file mode 100644 index 0000000..07360df --- /dev/null +++ b/internal/i18n/resolver_test.go @@ -0,0 +1,31 @@ +package i18n + +import "testing" + +func TestPreScanLang(t *testing.T) { + cases := []struct { + args []string + want string + }{ + {[]string{"--lang", "zh-CN", "repo"}, "zh-CN"}, + {[]string{"repo", "--lang=zh-CN"}, "zh-CN"}, + {[]string{"repo"}, ""}, + } + for _, tc := range cases { + if got := PreScanLang(tc.args); got != tc.want { + t.Fatalf("PreScanLang(%v) = %q, want %q", tc.args, got, tc.want) + } + } +} + +func TestResolveLocalePriority(t *testing.T) { + available := []string{"en-US", "zh-CN"} + got := ResolveLocale(ResolveOptions{ + ExplicitLang: "en-US", + Env: map[string]string{"GITLINK_LANG": "zh-CN"}, + ConfigLang: "zh-CN", + }, available) + if got != "en-US" { + t.Fatalf("ResolveLocale() = %q, want en-US", got) + } +} diff --git a/internal/i18n/schema/messages.schema.json b/internal/i18n/schema/messages.schema.json new file mode 100644 index 0000000..ebc8f53 --- /dev/null +++ b/internal/i18n/schema/messages.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "GitLink CLI locale messages", + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "propertyNames": { + "pattern": "^(cmd|flag|error|output|prompt|success|warning|table)\\.[a-z0-9_.-]+$" + } +} diff --git a/internal/i18n/template.go b/internal/i18n/template.go new file mode 100644 index 0000000..21c299c --- /dev/null +++ b/internal/i18n/template.go @@ -0,0 +1,41 @@ +package i18n + +import ( + "fmt" + "regexp" + "sort" +) + +var placeholderPattern = regexp.MustCompile(`\{([A-Za-z_][A-Za-z0-9_]*)\}`) + +func renderTemplate(message string, args Args) string { + if len(args) == 0 { + return message + } + return placeholderPattern.ReplaceAllStringFunc(message, func(match string) string { + name := match[1 : len(match)-1] + value, ok := args[name] + if !ok || value == nil { + return match + } + if stringer, ok := value.(fmt.Stringer); ok { + return stringer.String() + } + return fmt.Sprint(value) + }) +} + +func extractTemplateArgs(message string) []string { + matches := placeholderPattern.FindAllStringSubmatch(message, -1) + seen := make(map[string]struct{}, len(matches)) + for _, match := range matches { + seen[match[1]] = struct{}{} + } + + args := make([]string, 0, len(seen)) + for arg := range seen { + args = append(args, arg) + } + sort.Strings(args) + return args +} diff --git a/internal/i18n/template_test.go b/internal/i18n/template_test.go new file mode 100644 index 0000000..19e4730 --- /dev/null +++ b/internal/i18n/template_test.go @@ -0,0 +1,24 @@ +package i18n + +import "testing" + +func TestRenderTemplateKeepsMissingArgs(t *testing.T) { + got := renderTemplate("Delete {owner}/{repo}", Args{"owner": "alice"}) + want := "Delete alice/{repo}" + if got != want { + t.Fatalf("renderTemplate() = %q, want %q", got, want) + } +} + +func TestExtractTemplateArgs(t *testing.T) { + got := extractTemplateArgs("Delete {owner}/{repo}/{owner}") + want := []string{"owner", "repo"} + if len(got) != len(want) { + t.Fatalf("args length = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("arg[%d] = %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/internal/i18n/translator.go b/internal/i18n/translator.go new file mode 100644 index 0000000..170f3bd --- /dev/null +++ b/internal/i18n/translator.go @@ -0,0 +1,89 @@ +package i18n + +import "fmt" + +// Translator resolves localized messages with fallback behavior suitable for CLI use. +type Translator struct { + locale string + fallbackLocale string + messages map[string]string + fallback map[string]string +} + +// Default returns an English translator for legacy migration only. +// New command code should receive *Translator explicitly. +func Default() *Translator { + tr, err := New(Options{Locale: defaultFallbackLocale}) + if err != nil { + return &Translator{ + locale: defaultFallbackLocale, + fallbackLocale: defaultFallbackLocale, + messages: map[string]string{}, + fallback: map[string]string{}, + } + } + return tr +} + +// New constructs a Translator. Missing messages fall back to FallbackLocale. +func New(opts Options) (*Translator, error) { + loader := opts.Loader + if loader == nil { + loader = NewEmbedLoader() + } + + available, err := loader.AvailableLocales() + if err != nil { + return nil, err + } + + fallbackLocale := opts.FallbackLocale + if fallbackLocale == "" { + fallbackLocale = defaultFallbackLocale + } + fallbackLocale = MatchLocale(fallbackLocale, available, defaultFallbackLocale) + locale := MatchLocale(opts.Locale, available, fallbackLocale) + + fallback, err := loader.Load(fallbackLocale) + if err != nil { + return nil, fmt.Errorf("load fallback locale: %w", err) + } + + messages := fallback + if locale != fallbackLocale { + messages, err = loader.Load(locale) + if err != nil { + return nil, fmt.Errorf("load locale: %w", err) + } + } + + return &Translator{ + locale: locale, + fallbackLocale: fallbackLocale, + messages: messages, + fallback: fallback, + }, nil +} + +func (t *Translator) Locale() string { + return t.locale +} + +// T returns a localized message, falling back to en-US and then the key itself. +func (t *Translator) T(key string) string { + if t == nil { + return key + } + if value, ok := t.messages[key]; ok { + return value + } + if value, ok := t.fallback[key]; ok { + return value + } + return key +} + +// Tf returns a localized message with {name} placeholders rendered from args. +func (t *Translator) Tf(key string, args Args) string { + return renderTemplate(t.T(key), args) +} diff --git a/internal/i18n/translator_test.go b/internal/i18n/translator_test.go new file mode 100644 index 0000000..541047c --- /dev/null +++ b/internal/i18n/translator_test.go @@ -0,0 +1,79 @@ +package i18n + +import ( + "errors" + "testing" +) + +type mapLoader struct { + messages map[string]map[string]string +} + +func (l mapLoader) Load(locale string) (map[string]string, error) { + messages, ok := l.messages[locale] + if !ok { + return nil, errors.New("missing locale") + } + return messages, nil +} + +func (l mapLoader) AvailableLocales() ([]string, error) { + locales := make([]string, 0, len(l.messages)) + for locale := range l.messages { + locales = append(locales, locale) + } + return locales, nil +} + +func TestTranslatorReturnsLocalizedMessage(t *testing.T) { + tr, err := New(Options{ + Locale: "zh-CN", + Loader: mapLoader{messages: map[string]map[string]string{ + "en-US": {"cmd.root.short": "GitLink CLI"}, + "zh-CN": {"cmd.root.short": "GitLink 命令行"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + + if got := tr.T("cmd.root.short"); got != "GitLink 命令行" { + t.Fatalf("expected localized message, got %q", got) + } +} + +func TestTranslatorFallsBackToBaseThenKey(t *testing.T) { + tr, err := New(Options{ + Locale: "zh-CN", + Loader: mapLoader{messages: map[string]map[string]string{ + "en-US": {"cmd.root.short": "GitLink CLI"}, + "zh-CN": {}, + }}, + }) + if err != nil { + t.Fatal(err) + } + + if got := tr.T("cmd.root.short"); got != "GitLink CLI" { + t.Fatalf("expected fallback message, got %q", got) + } + if got := tr.T("cmd.missing.short"); got != "cmd.missing.short" { + t.Fatalf("expected key fallback, got %q", got) + } +} + +func TestTranslatorRendersArgs(t *testing.T) { + tr, err := New(Options{ + Locale: "en-US", + Loader: mapLoader{messages: map[string]map[string]string{ + "en-US": {"output.version": "gitlink-cli {version}"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + + if got := tr.Tf("output.version", Args{"version": "1.2.3"}); got != "gitlink-cli 1.2.3" { + t.Fatalf("expected rendered message, got %q", got) + } +} diff --git a/internal/i18n/validate.go b/internal/i18n/validate.go new file mode 100644 index 0000000..99bb4e9 --- /dev/null +++ b/internal/i18n/validate.go @@ -0,0 +1,104 @@ +package i18n + +import ( + "fmt" + "reflect" + "regexp" + "sort" + "strings" +) + +var keyPattern = regexp.MustCompile(`^(cmd|flag|error|prompt|success|warning|confirm|table|output)\.[a-z0-9_.-]+$`) + +// Problem describes a locale validation issue. +type Problem struct { + Locale string + Key string + Message string +} + +func (p Problem) String() string { + if p.Key == "" { + return fmt.Sprintf("%s: %s", p.Locale, p.Message) + } + return fmt.Sprintf("%s:%s: %s", p.Locale, p.Key, p.Message) +} + +// Validate checks all locales against baseLocale. +func Validate(loader Loader, baseLocale string) ([]Problem, error) { + if loader == nil { + loader = NewEmbedLoader() + } + baseLocale = NormalizeLocale(baseLocale) + if baseLocale == "" { + baseLocale = defaultFallbackLocale + } + + locales, err := loader.AvailableLocales() + if err != nil { + return nil, err + } + sort.Strings(locales) + + allMessages := make(map[string]map[string]string, len(locales)) + for _, locale := range locales { + normalized := NormalizeLocale(locale) + if normalized != locale { + return []Problem{{Locale: locale, Message: "locale filename is not normalized"}}, nil + } + messages, err := loader.Load(locale) + if err != nil { + return nil, err + } + allMessages[locale] = messages + } + + base, ok := allMessages[baseLocale] + if !ok { + return []Problem{{Locale: baseLocale, Message: "base locale is missing"}}, nil + } + + var problems []Problem + for key, value := range base { + problems = append(problems, validateMessage(baseLocale, key, value)...) + } + + for _, locale := range locales { + messages := allMessages[locale] + for key, value := range messages { + problems = append(problems, validateMessage(locale, key, value)...) + if _, ok := base[key]; !ok { + problems = append(problems, Problem{Locale: locale, Key: key, Message: "key is not present in base locale"}) + } + } + for key, baseValue := range base { + value, ok := messages[key] + if !ok { + problems = append(problems, Problem{Locale: locale, Key: key, Message: "missing key"}) + continue + } + baseArgs := extractTemplateArgs(baseValue) + args := extractTemplateArgs(value) + if !reflect.DeepEqual(baseArgs, args) { + problems = append(problems, Problem{ + Locale: locale, + Key: key, + Message: fmt.Sprintf("template args mismatch: expected {%s}, got {%s}", strings.Join(baseArgs, ","), strings.Join(args, ",")), + }) + } + } + } + + return problems, nil +} + +func validateMessage(locale, key, value string) []Problem { + var problems []Problem + if !keyPattern.MatchString(key) { + problems = append(problems, Problem{Locale: locale, Key: key, Message: "key does not match naming rules"}) + } + if strings.TrimSpace(value) == "" { + problems = append(problems, Problem{Locale: locale, Key: key, Message: "message is empty"}) + } + return problems +} diff --git a/internal/i18n/validate_test.go b/internal/i18n/validate_test.go new file mode 100644 index 0000000..d5d2cf1 --- /dev/null +++ b/internal/i18n/validate_test.go @@ -0,0 +1,31 @@ +package i18n + +import "testing" + +func TestValidateEmbeddedLocales(t *testing.T) { + problems, err := Validate(NewEmbedLoader(), "en-US") + if err != nil { + t.Fatal(err) + } + if len(problems) > 0 { + t.Fatalf("expected no problems, got %v", problems) + } +} + +func TestValidateFindsMissingKeyAndArgMismatch(t *testing.T) { + problems, err := Validate(mapLoader{messages: map[string]map[string]string{ + "en-US": { + "cmd.root.short": "Hello {name}", + "flag.owner": "Owner", + }, + "zh-CN": { + "cmd.root.short": "你好", + }, + }}, "en-US") + if err != nil { + t.Fatal(err) + } + if len(problems) != 2 { + t.Fatalf("expected 2 problems, got %d: %v", len(problems), problems) + } +} diff --git a/shortcuts/branch/branch.go b/shortcuts/branch/branch.go index 37684b6..0393ad5 100644 --- a/shortcuts/branch/branch.go +++ b/shortcuts/branch/branch.go @@ -4,17 +4,19 @@ import ( "fmt" "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List branches", + Description: tr.T("cmd.branch.list.short"), Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -32,10 +34,10 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a branch", + Description: tr.T("cmd.branch.create.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Branch name", Required: true}, - {Name: "from", Short: "f", Usage: "Source branch or commit", Default: "master"}, + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, + {Name: "from", Short: "f", Usage: tr.T("flag.branch.from"), Default: "master"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -59,9 +61,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "delete", - Description: "Delete a branch", + Description: tr.T("cmd.branch.delete.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Branch name", Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -80,9 +82,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "protect", - Description: "Set branch protection", + Description: tr.T("cmd.branch.protect.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Branch name", Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -101,9 +103,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "unprotect", - Description: "Remove branch protection", + Description: tr.T("cmd.branch.unprotect.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Branch name", Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -119,3 +121,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/ci/ci.go b/shortcuts/ci/ci.go index eb15ad3..6794c47 100644 --- a/shortcuts/ci/ci.go +++ b/shortcuts/ci/ci.go @@ -4,17 +4,19 @@ import ( "fmt" "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "builds", - Description: "List CI builds", + Description: tr.T("cmd.ci.builds.short"), Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -32,11 +34,11 @@ func Shortcuts() []*common.Shortcut { }, { Name: "logs", - Description: "View build logs", + Description: tr.T("cmd.ci.logs.short"), Flags: []common.Flag{ - {Name: "build", Short: "b", Usage: "Build number", Required: true}, - {Name: "stage", Short: "s", Usage: "Stage number", Default: "1"}, - {Name: "step", Usage: "Step number", Default: "1"}, + {Name: "build", Short: "b", Usage: tr.T("flag.ci.build"), Required: true}, + {Name: "stage", Short: "s", Usage: tr.T("flag.ci.stage"), Default: "1"}, + {Name: "step", Usage: tr.T("flag.ci.step"), Default: "1"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -60,9 +62,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "restart", - Description: "Restart a build", + Description: tr.T("cmd.ci.restart.short"), Flags: []common.Flag{ - {Name: "build", Short: "b", Usage: "Build number", Required: true}, + {Name: "build", Short: "b", Usage: tr.T("flag.ci.build"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -78,9 +80,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "stop", - Description: "Stop a build", + Description: tr.T("cmd.ci.stop.short"), Flags: []common.Flag{ - {Name: "build", Short: "b", Usage: "Build number", Required: true}, + {Name: "build", Short: "b", Usage: tr.T("flag.ci.build"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -96,3 +98,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 03f67e2..9bdc5bc 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -1,17 +1,22 @@ package common import ( - "fmt" "strconv" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/spf13/cobra" ) // MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand. -func MountShortcut(parent *cobra.Command, s *Shortcut) { +func MountShortcut(parent *cobra.Command, s *Shortcut, translators ...*i18n.Translator) { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } cmd := &cobra.Command{ Use: "+" + s.Name, Short: s.Description, + Long: s.Long, RunE: func(cmd *cobra.Command, args []string) error { // Collect flag values flagValues := make(map[string]string) @@ -27,10 +32,16 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) { } } - ctx, err := NewRuntimeContext(flagValues) + ctx, err := NewRuntimeContext(flagValues, tr) if err != nil { return err } + for _, f := range s.Flags { + if f.Required && flagValues[f.Name] == "" { + _, err := ctx.RequireArg(f.Name) + return err + } + } return s.Run(ctx) }, @@ -49,19 +60,18 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) { } else { cmd.Flags().String(f.Name, f.Default, f.Usage) } - if f.Required { - if err := cmd.MarkFlagRequired(f.Name); err != nil { - panic(fmt.Sprintf("failed to mark flag %s as required: %v", f.Name, err)) - } - } } parent.AddCommand(cmd) } // MountShortcuts mounts multiple shortcuts under a parent command. -func MountShortcuts(parent *cobra.Command, shortcuts []*Shortcut) { +func MountShortcuts(parent *cobra.Command, shortcuts []*Shortcut, translators ...*i18n.Translator) { + var tr *i18n.Translator + if len(translators) > 0 { + tr = translators[0] + } for _, s := range shortcuts { - MountShortcut(parent, s) + MountShortcut(parent, s, tr) } } diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 15441c9..87a052a 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -2,12 +2,14 @@ package common import ( "encoding/json" + "errors" "fmt" "net/url" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/internal/context" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" ) @@ -15,6 +17,7 @@ import ( type Shortcut struct { Name string Description string + Long string Flags []Flag Run func(ctx *RuntimeContext) error } @@ -36,10 +39,15 @@ type RuntimeContext struct { Repo string Format string Args map[string]string + Tr *i18n.Translator } // NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo. -func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) { +func NewRuntimeContext(args map[string]string, translators ...*i18n.Translator) (*RuntimeContext, error) { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } cli, err := client.New() if err != nil { return nil, err @@ -57,6 +65,7 @@ func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) { Repo: cmdutil.Repo, Format: format, Args: args, + Tr: tr, }, nil } @@ -113,7 +122,7 @@ func (ctx *RuntimeContext) Arg(name string) string { func (ctx *RuntimeContext) RequireArg(name string) (string, error) { v := ctx.Arg(name) if v == "" { - return "", fmt.Errorf("required flag --%s is missing", name) + return "", errors.New(ctx.Tr.Tf("error.missing_required_flag", i18n.Args{"name": name})) } return v, nil } diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 0459921..808d7fd 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -18,18 +19,26 @@ func v1RepoPath(ctx *common.RuntimeContext) string { type existingIssue struct { Subject string Description string + StatusID interface{} + PriorityID interface{} + TagIDs []interface{} + AssignerIDs []interface{} + BranchName string + StartDate string + DueDate string } -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ newBatchCloseShortcut(), { Name: "list", - Description: "List issues", + Description: tr.T("cmd.issue.list.short"), Flags: []common.Flag{ - {Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "state", Short: "s", Usage: tr.T("flag.issue.state"), Default: "open"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -51,13 +60,19 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a new issue", + Description: tr.T("cmd.issue.create.short"), Flags: []common.Flag{ - {Name: "title", Short: "t", Usage: "Issue title", Required: true}, - {Name: "body", Short: "b", Usage: "Issue description"}, - {Name: "assignee", Short: "a", Usage: "Assignee login"}, - {Name: "milestone", Short: "m", Usage: "Milestone ID"}, - {Name: "label", Usage: "Label ID"}, + {Name: "title", Short: "t", Usage: tr.T("flag.issue.title"), Required: true}, + {Name: "body", Short: "b", Usage: tr.T("flag.issue.body")}, + {Name: "assignee", Short: "a", Usage: tr.T("flag.issue.assignee")}, + {Name: "milestone", Short: "m", Usage: tr.T("flag.issue.milestone")}, + {Name: "label", Usage: tr.T("flag.issue.label")}, + {Name: "priority-id", Usage: "Priority ID", Default: "2"}, + {Name: "tag-ids", Usage: "Comma-separated issue tag IDs"}, + {Name: "assigner-ids", Usage: "Comma-separated issue assigner IDs"}, + {Name: "branch", Usage: "Linked branch name"}, + {Name: "start-date", Usage: "Start date (YYYY-MM-DD)"}, + {Name: "due-date", Usage: "Due date (YYYY-MM-DD)"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -82,6 +97,9 @@ func Shortcuts() []*common.Shortcut { if m := ctx.Arg("milestone"); m != "" { body["fixed_version_id"] = m } + if err := applyIssueMetadataArgs(ctx, body); err != nil { + return err + } env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body) if err != nil { return err @@ -91,11 +109,8 @@ func Shortcuts() []*common.Shortcut { }, { Name: "view", - Description: "View issue details", - Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)"}, - {Name: "id", Usage: "Alias for --number; uses the issue number from the web URL"}, - }, + Description: tr.T("cmd.issue.view.short"), + Flags: issueNumberFlags(), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -113,15 +128,13 @@ func Shortcuts() []*common.Shortcut { }, { Name: "close", - Description: "Close an issue", - Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, - }, + Description: tr.T("cmd.issue.close.short"), + Flags: issueNumberFlags(), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := ctx.RequireArg("number") + number, err := issueNumberArg(ctx) if err != nil { return err } @@ -133,8 +146,9 @@ func Shortcuts() []*common.Shortcut { body := map[string]interface{}{ "subject": current.Subject, "description": current.Description, - "status_id": 5, // 5 = closed } + preserveIssueMetadata(body, current) + body["status_id"] = 5 // 5 = closed env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) if err != nil { return err @@ -144,26 +158,31 @@ func Shortcuts() []*common.Shortcut { }, { Name: "update", - Description: "Update an issue", - Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, - {Name: "title", Short: "t", Usage: "New title"}, - {Name: "body", Short: "b", Usage: "New description"}, - {Name: "state", Short: "s", Usage: "New state: open, closed, or numeric status_id"}, - }, + Description: tr.T("cmd.issue.update.short"), + Flags: appendIssueNumberFlags( + common.Flag{Name: "title", Short: "t", Usage: tr.T("flag.issue.new_title")}, + common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.issue.new_body")}, + common.Flag{Name: "state", Short: "s", Usage: tr.T("flag.issue.new_state")}, + common.Flag{Name: "priority-id", Usage: "New priority ID"}, + common.Flag{Name: "tag-ids", Usage: "Comma-separated issue tag IDs"}, + common.Flag{Name: "assigner-ids", Usage: "Comma-separated issue assigner IDs"}, + common.Flag{Name: "branch", Usage: "Linked branch name"}, + common.Flag{Name: "start-date", Usage: "Start date (YYYY-MM-DD)"}, + common.Flag{Name: "due-date", Usage: "Due date (YYYY-MM-DD)"}, + ), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := ctx.RequireArg("number") + number, err := issueNumberArg(ctx) if err != nil { return err } title := ctx.Arg("title") description := ctx.Arg("body") state := ctx.Arg("state") - if title == "" && description == "" && state == "" { - return fmt.Errorf("at least one of --title, --body, or --state is required") + if title == "" && description == "" && state == "" && !hasIssueMetadataArgs(ctx) { + return fmt.Errorf("at least one update field is required") } current, err := fetchExistingIssue(ctx, number) @@ -175,6 +194,7 @@ func Shortcuts() []*common.Shortcut { "subject": current.Subject, "description": current.Description, } + preserveIssueMetadata(body, current) if t := ctx.Arg("title"); t != "" { body["subject"] = t } @@ -188,6 +208,9 @@ func Shortcuts() []*common.Shortcut { } body["status_id"] = statusID } + if err := applyIssueMetadataArgs(ctx, body); err != nil { + return err + } env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) if err != nil { return err @@ -197,16 +220,15 @@ func Shortcuts() []*common.Shortcut { }, { Name: "comment", - Description: "Add a comment to an issue", - Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, - {Name: "body", Short: "b", Usage: "Comment body", Required: true}, - }, + Description: tr.T("cmd.issue.comment.short"), + Flags: appendIssueNumberFlags( + common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, + ), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := ctx.RequireArg("number") + number, err := issueNumberArg(ctx) if err != nil { return err } @@ -266,9 +288,112 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "priorities", + Description: "List issue priorities", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Search keyword"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + if keyword := ctx.Arg("keyword"); keyword != "" { + q.Set("keyword", keyword) + } + env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_priorities", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "tags", + Description: "List issue tags", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Search keyword"}, + {Name: "only-name", Usage: "Only return tag names and IDs", Bool: true, Default: "false"}, + {Name: "order-by", Usage: "Order by: updated_on, created_on, issues_count"}, + {Name: "order-direction", Usage: "Order direction: asc or desc"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + if keyword := ctx.Arg("keyword"); keyword != "" { + q.Set("keyword", keyword) + } + if parseBool(ctx.Arg("only-name")) { + q.Set("only_name", "true") + } + if orderBy := ctx.Arg("order-by"); orderBy != "" { + q.Set("order_by", orderBy) + } + if orderDirection := ctx.Arg("order-direction"); orderDirection != "" { + q.Set("order_direction", orderDirection) + } + env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_tags", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "statuses", + Description: "List issue statuses", + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_statues", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} + +func issueNumberFlags() []common.Flag { + return []common.Flag{ + {Name: "number", Short: "n", Usage: "Issue number from the web URL (preferred)"}, + {Name: "id", Short: "i", Usage: "Compatibility alias for --number; this is not the database ID"}, + } +} + +func appendIssueNumberFlags(flags ...common.Flag) []common.Flag { + return append(issueNumberFlags(), flags...) +} + +func issueNumberArg(ctx *common.RuntimeContext) (string, error) { + if number := strings.TrimSpace(ctx.Arg("number")); number != "" { + return number, nil + } + if id := strings.TrimSpace(ctx.Arg("id")); id != "" { + return id, nil + } + return "", fmt.Errorf("required flag --number is missing (or use --id as a compatibility alias)") +} + // normalizeIssueListIDs adds "number" (project_issues_index) and renames // "id" to "database_id" so the user-facing output uses the project-level // issue number, not the global database primary key. @@ -316,9 +441,76 @@ func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIss return &existingIssue{ Subject: subject, Description: description, + StatusID: nestedIssueID(issueData, "status"), + PriorityID: nestedIssueID(issueData, "priority"), + TagIDs: issueObjectIDs(issueData, "tags", "issue_tags"), + AssignerIDs: issueObjectIDs(issueData, "assigners"), + BranchName: stringField(issueData, "branch_name"), + StartDate: stringField(issueData, "start_date"), + DueDate: stringField(issueData, "due_date"), }, nil } +func preserveIssueMetadata(body map[string]interface{}, issue *existingIssue) { + if issue.StatusID != nil { + body["status_id"] = issue.StatusID + } + if issue.PriorityID != nil { + body["priority_id"] = issue.PriorityID + } + if len(issue.TagIDs) > 0 { + body["issue_tag_ids"] = issue.TagIDs + } + if len(issue.AssignerIDs) > 0 { + body["assigner_ids"] = issue.AssignerIDs + } + if issue.BranchName != "" { + body["branch_name"] = issue.BranchName + } + if issue.StartDate != "" { + body["start_date"] = issue.StartDate + } + if issue.DueDate != "" { + body["due_date"] = issue.DueDate + } +} + +func nestedIssueID(data map[string]interface{}, key string) interface{} { + item, ok := data[key].(map[string]interface{}) + if !ok { + return nil + } + return item["id"] +} + +func issueObjectIDs(data map[string]interface{}, keys ...string) []interface{} { + for _, key := range keys { + items, ok := data[key].([]interface{}) + if !ok { + continue + } + ids := make([]interface{}, 0, len(items)) + for _, item := range items { + obj, ok := item.(map[string]interface{}) + if !ok { + continue + } + if id, ok := obj["id"]; ok { + ids = append(ids, id) + } + } + if len(ids) > 0 { + return ids + } + } + return nil +} + +func stringField(data map[string]interface{}, key string) string { + value, _ := data[key].(string) + return value +} + func normalizeIssueStatus(state string) (interface{}, error) { switch strings.ToLower(strings.TrimSpace(state)) { case "open": @@ -333,12 +525,77 @@ func normalizeIssueStatus(state string) (interface{}, error) { } } -func issueNumberArg(ctx *common.RuntimeContext) (string, error) { - if number := strings.TrimSpace(ctx.Arg("number")); number != "" { - return number, nil +func hasIssueMetadataArgs(ctx *common.RuntimeContext) bool { + for _, name := range []string{"priority-id", "tag-ids", "label", "assigner-ids", "branch", "start-date", "due-date"} { + if ctx.Arg(name) != "" { + return true + } } - if id := strings.TrimSpace(ctx.Arg("id")); id != "" { - return id, nil - } - return "", fmt.Errorf("required flag --number (or --id alias) not set") + return false +} + +func applyIssueMetadataArgs(ctx *common.RuntimeContext, body map[string]interface{}) error { + if priority := ctx.Arg("priority-id"); priority != "" { + priorityID, err := parseIssueID(priority, "priority-id") + if err != nil { + return err + } + body["priority_id"] = priorityID + } + tagIDs := ctx.Arg("tag-ids") + if label := ctx.Arg("label"); label != "" { + if tagIDs != "" { + return fmt.Errorf("--label cannot be used with --tag-ids") + } + tagIDs = label + } + if tagIDs != "" { + ids, err := parseIssueIDList(tagIDs, "tag-ids") + if err != nil { + return err + } + body["issue_tag_ids"] = ids + } + if assignerIDs := ctx.Arg("assigner-ids"); assignerIDs != "" { + ids, err := parseIssueIDList(assignerIDs, "assigner-ids") + if err != nil { + return err + } + body["assigner_ids"] = ids + } + if branch := ctx.Arg("branch"); branch != "" { + body["branch_name"] = branch + } + if startDate := ctx.Arg("start-date"); startDate != "" { + body["start_date"] = startDate + } + if dueDate := ctx.Arg("due-date"); dueDate != "" { + body["due_date"] = dueDate + } + return nil +} + +func parseIssueIDList(value, flagName string) ([]int, error) { + parts := strings.Split(value, ",") + ids := make([]int, 0, len(parts)) + for _, part := range parts { + id, err := parseIssueID(part, flagName) + if err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, nil +} + +func parseIssueID(value, flagName string) (int, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return 0, fmt.Errorf("--%s contains an empty ID", flagName) + } + id, err := strconv.Atoi(trimmed) + if err != nil || id <= 0 { + return 0, fmt.Errorf("--%s must contain positive numeric IDs", flagName) + } + return id, nil } diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index c821e54..e9e3389 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/gitlink-org/gitlink-cli/internal/client" @@ -14,12 +15,18 @@ func runShortcut(t *testing.T, server *httptest.Server, name string, args map[st t.Helper() shortcut := findShortcut(t, name) ctx := &common.RuntimeContext{ - Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, Owner: "owner", Repo: "repo", Format: "json", Args: args, } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } return shortcut.Run(ctx) } @@ -34,9 +41,25 @@ func findShortcut(t *testing.T, name string) *common.Shortcut { return nil } -func writeJSON(w http.ResponseWriter, v interface{}) { +func newIssueTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) { + t.Helper() w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} + +func writeText(t *testing.T, w http.ResponseWriter, status int, text string) { + t.Helper() + w.WriteHeader(status) + if _, err := w.Write([]byte(text)); err != nil { + t.Fatalf("write response: %v", err) + } } func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { @@ -55,10 +78,26 @@ func assertEqual(t *testing.T, got interface{}, want interface{}) { } } +func assertNumberSlice(t *testing.T, got interface{}, want []float64) { + t.Helper() + values, ok := got.([]interface{}) + if !ok { + t.Fatalf("got %v (%T), want numeric slice", got, got) + } + if len(values) != len(want) { + t.Fatalf("got %v, want %v", values, want) + } + for i, value := range values { + if value != want[i] { + t.Fatalf("got %v, want %v", values, want) + } + } +} + // --- list --- func TestIssueList(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { t.Fatalf("expected GET, got %s", r.Method) } @@ -68,10 +107,10 @@ func TestIssueList(t *testing.T) { if r.URL.Query().Get("state") != "open" { t.Fatalf("expected state=open, got %s", r.URL.Query().Get("state")) } - writeJSON(w, []interface{}{ + writeJSON(t, w, []interface{}{ map[string]interface{}{"id": float64(1), "subject": "bug"}, }) - })) + }) defer server.Close() err := runShortcut(t, server, "list", map[string]string{"state": "open", "page": "1", "limit": "20"}) @@ -84,7 +123,7 @@ func TestIssueList(t *testing.T) { func TestIssueCreate(t *testing.T) { var payload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Fatalf("expected POST, got %s", r.Method) } @@ -92,8 +131,8 @@ func TestIssueCreate(t *testing.T) { t.Fatalf("unexpected path: %s", r.URL.Path) } payload = decodeJSON(t, r) - writeJSON(w, map[string]interface{}{"id": float64(1), "subject": "bug"}) - })) + writeJSON(t, w, map[string]interface{}{"id": float64(1), "subject": "bug"}) + }) defer server.Close() err := runShortcut(t, server, "create", map[string]string{ @@ -109,10 +148,45 @@ func TestIssueCreate(t *testing.T) { assertEqual(t, payload["assigned_to_id"], "alice") } +func TestIssueCreateSupportsMetadataFields(t *testing.T) { + var createPayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/issues.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + createPayload = decodeJSON(t, r) + writeJSON(t, w, createPayload) + }) + defer server.Close() + + err := runShortcut(t, server, "create", map[string]string{ + "title": "New issue", + "body": "With metadata", + "priority-id": "3", + "tag-ids": "4,5", + "assigner-ids": "7,8", + "branch": "feature/metadata", + "start-date": "2026-05-01", + "due-date": "2026-05-31", + }) + if err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + + assertEqual(t, createPayload["subject"], "New issue") + assertEqual(t, createPayload["description"], "With metadata") + assertEqual(t, createPayload["priority_id"], float64(3)) + assertNumberSlice(t, createPayload["issue_tag_ids"], []float64{4, 5}) + assertNumberSlice(t, createPayload["assigner_ids"], []float64{7, 8}) + assertEqual(t, createPayload["branch_name"], "feature/metadata") + assertEqual(t, createPayload["start_date"], "2026-05-01") + assertEqual(t, createPayload["due_date"], "2026-05-31") +} + func TestIssueCreateMissingTitle(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected") - })) + }) defer server.Close() err := runShortcut(t, server, "create", map[string]string{}) @@ -121,18 +195,18 @@ func TestIssueCreateMissingTitle(t *testing.T) { } } -// --- view --- +// --- view/id alias --- func TestIssueView(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "GET" { t.Fatalf("expected GET, got %s", r.Method) } if r.URL.Path != "/v1/owner/repo/issues/42.json" { t.Fatalf("unexpected path: %s", r.URL.Path) } - writeJSON(w, map[string]interface{}{"id": float64(42), "subject": "bug"}) - })) + writeJSON(t, w, map[string]interface{}{"id": float64(42), "subject": "bug"}) + }) defer server.Close() err := runShortcut(t, server, "view", map[string]string{"number": "42"}) @@ -142,9 +216,9 @@ func TestIssueView(t *testing.T) { } func TestIssueViewMissingNumber(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected") - })) + }) defer server.Close() err := runShortcut(t, server, "view", map[string]string{}) @@ -153,47 +227,64 @@ func TestIssueViewMissingNumber(t *testing.T) { } } -func TestIssueViewAcceptsIDAsNumberAlias(t *testing.T) { +func TestIssueViewAcceptsIDAlias(t *testing.T) { var requestedPath string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { requestedPath = r.URL.Path - if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/29.json" { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42.json" { t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - writeJSON(w, map[string]interface{}{ - "project_issues_index": 29, + writeJSON(t, w, map[string]interface{}{ + "project_issues_index": 42, "subject": "Issue from web URL", }) - })) + }) defer server.Close() - err := runShortcut(t, server, "view", map[string]string{"id": "29"}) + err := runShortcut(t, server, "view", map[string]string{"id": "42"}) if err != nil { t.Fatalf("view shortcut failed: %v", err) } + assertEqual(t, requestedPath, "/v1/owner/repo/issues/42.json") +} - assertEqual(t, requestedPath, "/v1/owner/repo/issues/29.json") +func TestIssueNumberTakesPrecedenceOverIDAlias(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues/42.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"subject": "Existing title"}) + }) + defer server.Close() + + err := runShortcut(t, server, "view", map[string]string{ + "number": "42", + "id": "99", + }) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } } // --- close --- func TestIssueClose(t *testing.T) { var patchPayload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "id": float64(42), "subject": "Existing title", "description": "Existing description", }) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": patchPayload = decodeJSON(t, r) - writeJSON(w, patchPayload) + writeJSON(t, w, patchPayload) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() err := runShortcut(t, server, "close", map[string]string{"number": "42"}) @@ -205,11 +296,67 @@ func TestIssueClose(t *testing.T) { assertEqual(t, patchPayload["status_id"], float64(5)) } +func TestIssueCloseAcceptsIDAlias(t *testing.T) { + var updatePayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": + writeJSON(t, w, map[string]interface{}{ + "subject": "Existing title", + "description": "Existing description", + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + updatePayload = decodeJSON(t, r) + writeJSON(t, w, updatePayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runShortcut(t, server, "close", map[string]string{"id": "42"}) + if err != nil { + t.Fatalf("close shortcut failed: %v", err) + } + assertEqual(t, updatePayload["status_id"], float64(5)) +} + +func TestIssueClosePreservesCurrentMetadata(t *testing.T) { + var updatePayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": + writeJSON(t, w, map[string]interface{}{ + "subject": "Existing title", + "priority": map[string]interface{}{"id": 3}, + "tags": []map[string]interface{}{ + {"id": 4}, + }, + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + updatePayload = decodeJSON(t, r) + writeJSON(t, w, updatePayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runShortcut(t, server, "close", map[string]string{"number": "42"}) + if err != nil { + t.Fatalf("close shortcut failed: %v", err) + } + assertEqual(t, updatePayload["subject"], "Existing title") + assertEqual(t, updatePayload["status_id"], float64(5)) + assertEqual(t, updatePayload["priority_id"], float64(3)) + assertNumberSlice(t, updatePayload["issue_tag_ids"], []float64{4}) +} + func TestIssueCloseFetchFails(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) - writeJSON(w, map[string]interface{}{"error": "not found"}) - })) + writeJSON(t, w, map[string]interface{}{"error": "not found"}) + }) defer server.Close() err := runShortcut(t, server, "close", map[string]string{"number": "999"}) @@ -222,21 +369,21 @@ func TestIssueCloseFetchFails(t *testing.T) { func TestIssueUpdateTitle(t *testing.T) { var patchPayload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "id": float64(42), "subject": "Existing title", "description": "Existing description", }) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": patchPayload = decodeJSON(t, r) - writeJSON(w, patchPayload) + writeJSON(t, w, patchPayload) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() err := runShortcut(t, server, "update", map[string]string{"number": "42", "title": "New title", "state": "closed"}) @@ -250,21 +397,21 @@ func TestIssueUpdateTitle(t *testing.T) { func TestIssueUpdateDescription(t *testing.T) { var patchPayload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "id": float64(42), "subject": "Existing title", "description": "Existing description", }) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": patchPayload = decodeJSON(t, r) - writeJSON(w, patchPayload) + writeJSON(t, w, patchPayload) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() err := runShortcut(t, server, "update", map[string]string{"number": "42", "body": "New description"}) @@ -277,21 +424,21 @@ func TestIssueUpdateDescription(t *testing.T) { func TestIssueUpdateNumericState(t *testing.T) { var patchPayload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "id": float64(42), "subject": "bug", "description": "desc", }) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": patchPayload = decodeJSON(t, r) - writeJSON(w, map[string]interface{}{"id": float64(42)}) + writeJSON(t, w, map[string]interface{}{"id": float64(42)}) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() err := runShortcut(t, server, "update", map[string]string{"number": "42", "state": "3"}) @@ -301,11 +448,128 @@ func TestIssueUpdateNumericState(t *testing.T) { assertEqual(t, patchPayload["status_id"], float64(3)) } -func TestIssueUpdateInvalidState(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +func TestIssueUpdateAcceptsIDAlias(t *testing.T) { + var updatePayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ + "subject": "Existing title", + "description": "Existing description", + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + updatePayload = decodeJSON(t, r) + writeJSON(t, w, updatePayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runShortcut(t, server, "update", map[string]string{ + "id": "42", + "title": "New title", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + assertEqual(t, updatePayload["subject"], "New title") + assertEqual(t, updatePayload["description"], "Existing description") +} + +func TestIssueUpdatePreservesCurrentMetadata(t *testing.T) { + var updatePayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": + writeJSON(t, w, map[string]interface{}{ + "subject": "Existing title", + "description": "Existing description", + "status": map[string]interface{}{"id": 1}, + "priority": map[string]interface{}{"id": 2}, + "tags": []map[string]interface{}{ + {"id": 7}, + {"id": 8}, + }, + "assigners": []map[string]interface{}{ + {"id": 9}, + }, + "branch_name": "main", + "start_date": "2026-05-01", + "due_date": "2026-05-31", + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + updatePayload = decodeJSON(t, r) + writeJSON(t, w, updatePayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runShortcut(t, server, "update", map[string]string{ + "number": "42", + "title": "New title", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + assertEqual(t, updatePayload["subject"], "New title") + assertEqual(t, updatePayload["description"], "Existing description") + assertEqual(t, updatePayload["status_id"], float64(1)) + assertEqual(t, updatePayload["priority_id"], float64(2)) + assertNumberSlice(t, updatePayload["issue_tag_ids"], []float64{7, 8}) + assertNumberSlice(t, updatePayload["assigner_ids"], []float64{9}) + assertEqual(t, updatePayload["branch_name"], "main") + assertEqual(t, updatePayload["start_date"], "2026-05-01") + assertEqual(t, updatePayload["due_date"], "2026-05-31") +} + +func TestIssueUpdateSupportsMetadataFields(t *testing.T) { + var updatePayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": + writeJSON(t, w, map[string]interface{}{ + "subject": "Existing title", + "description": "Existing description", + }) + case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": + updatePayload = decodeJSON(t, r) + writeJSON(t, w, updatePayload) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + err := runShortcut(t, server, "update", map[string]string{ + "number": "42", + "priority-id": "4", + "tag-ids": "6,7", + "assigner-ids": "8", + "branch": "bugfix/metadata", + "start-date": "2026-06-01", + "due-date": "2026-06-15", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + assertEqual(t, updatePayload["subject"], "Existing title") + assertEqual(t, updatePayload["description"], "Existing description") + assertEqual(t, updatePayload["priority_id"], float64(4)) + assertNumberSlice(t, updatePayload["issue_tag_ids"], []float64{6, 7}) + assertNumberSlice(t, updatePayload["assigner_ids"], []float64{8}) + assertEqual(t, updatePayload["branch_name"], "bugfix/metadata") + assertEqual(t, updatePayload["start_date"], "2026-06-01") + assertEqual(t, updatePayload["due_date"], "2026-06-15") +} + +func TestIssueUpdateInvalidState(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": + writeJSON(t, w, map[string]interface{}{ "id": float64(42), "subject": "bug", "description": "desc", @@ -313,7 +577,7 @@ func TestIssueUpdateInvalidState(t *testing.T) { default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() err := runShortcut(t, server, "update", map[string]string{"number": "42", "state": "invalid"}) @@ -323,9 +587,9 @@ func TestIssueUpdateInvalidState(t *testing.T) { } func TestIssueUpdateNoChanges(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected") - })) + }) defer server.Close() err := runShortcut(t, server, "update", map[string]string{"number": "42"}) @@ -334,11 +598,34 @@ func TestIssueUpdateNoChanges(t *testing.T) { } } +func TestIssueRejectsInvalidMetadataIDs(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid metadata should not call API, got %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + cases := []struct { + name string + args map[string]string + }{ + {name: "bad priority", args: map[string]string{"title": "x", "priority-id": "abc"}}, + {name: "empty tag", args: map[string]string{"title": "x", "tag-ids": "1,,2"}}, + {name: "label conflicts with tag ids", args: map[string]string{"title": "x", "label": "1", "tag-ids": "2"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := runShortcut(t, server, "create", tc.args); err == nil { + t.Fatal("expected metadata validation error") + } + }) + } +} + // --- comment --- func TestIssueComment(t *testing.T) { var payload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Fatalf("expected POST, got %s", r.Method) } @@ -346,8 +633,8 @@ func TestIssueComment(t *testing.T) { t.Fatalf("unexpected path: %s", r.URL.Path) } payload = decodeJSON(t, r) - writeJSON(w, map[string]interface{}{"id": float64(1), "message": "ok"}) - })) + writeJSON(t, w, map[string]interface{}{"id": float64(1), "message": "ok"}) + }) defer server.Close() err := runShortcut(t, server, "comment", map[string]string{"number": "42", "body": "test comment"}) @@ -357,10 +644,31 @@ func TestIssueComment(t *testing.T) { assertEqual(t, payload["notes"], "test comment") } +func TestIssueCommentAcceptsIDAlias(t *testing.T) { + var commentPayload map[string]interface{} + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/issues/42/journals.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + commentPayload = decodeJSON(t, r) + writeJSON(t, w, commentPayload) + }) + defer server.Close() + + err := runShortcut(t, server, "comment", map[string]string{ + "id": "42", + "body": "Fixed", + }) + if err != nil { + t.Fatalf("comment shortcut failed: %v", err) + } + assertEqual(t, commentPayload["notes"], "Fixed") +} + func TestIssueCommentMissingBody(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected") - })) + }) defer server.Close() err := runShortcut(t, server, "comment", map[string]string{"number": "42"}) @@ -369,24 +677,52 @@ func TestIssueCommentMissingBody(t *testing.T) { } } +func TestIssueNumberOrIDIsRequired(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + cases := []struct { + name string + args map[string]string + }{ + {name: "view", args: map[string]string{}}, + {name: "close", args: map[string]string{}}, + {name: "update", args: map[string]string{"title": "New title"}}, + {name: "comment", args: map[string]string{"body": "Fixed"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := runShortcut(t, server, tc.name, tc.args) + if err == nil { + t.Fatal("expected missing issue number error") + } + if !strings.Contains(err.Error(), "--number") || !strings.Contains(err.Error(), "--id") { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + // --- batch-close --- func TestBatchClosePreservesCurrentDescription(t *testing.T) { var updatePayload map[string]interface{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "subject": "Existing title", "description": "Existing description", }) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": updatePayload = decodeJSON(t, r) - writeJSON(w, updatePayload) + writeJSON(t, w, updatePayload) default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() err := runShortcut(t, server, "batch-close", map[string]string{ @@ -396,16 +732,15 @@ func TestBatchClosePreservesCurrentDescription(t *testing.T) { if err != nil { t.Fatalf("batch-close shortcut failed: %v", err) } - assertEqual(t, updatePayload["subject"], "Existing title") assertEqual(t, updatePayload["description"], "Existing description") assertEqual(t, updatePayload["status_id"], float64(5)) } func TestBatchCloseDryRun(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected in dry-run mode") - })) + }) defer server.Close() err := runShortcut(t, server, "batch-close", map[string]string{ @@ -418,9 +753,9 @@ func TestBatchCloseDryRun(t *testing.T) { } func TestBatchCloseNoNumbers(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { t.Fatal("no API call expected") - })) + }) defer server.Close() err := runShortcut(t, server, "batch-close", map[string]string{}) @@ -430,53 +765,162 @@ func TestBatchCloseNoNumbers(t *testing.T) { } func TestBatchCloseFetchFails(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - w.Write([]byte("not found")) - })) + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeText(t, w, http.StatusNotFound, "not found") + }) defer server.Close() - err := runShortcut(t, server, "batch-close", map[string]string{ - "numbers": "99", - }) + err := runShortcut(t, server, "batch-close", map[string]string{"numbers": "99"}) if err == nil { t.Fatal("expected error when fetch fails") } } func TestBatchCloseWithFailedClose(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/1.json": - writeJSON(w, map[string]interface{}{"subject": "Issue 1", "description": "desc1"}) + writeJSON(t, w, map[string]interface{}{"subject": "Issue 1", "description": "desc1"}) case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/2.json": - writeJSON(w, map[string]interface{}{"subject": "Issue 2", "description": "desc2"}) + writeJSON(t, w, map[string]interface{}{"subject": "Issue 2", "description": "desc2"}) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/1.json": - writeJSON(w, map[string]interface{}{"subject": "Issue 1", "description": "desc1", "status_id": float64(5)}) + writeJSON(t, w, map[string]interface{}{"subject": "Issue 1", "description": "desc1", "status_id": float64(5)}) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/2.json": - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() - err := runShortcut(t, server, "batch-close", map[string]string{ - "numbers": "1, 2", - }) + err := runShortcut(t, server, "batch-close", map[string]string{"numbers": "1, 2"}) if err == nil { t.Fatal("expected error when some issues fail to close") } } +// --- issue users --- + +func TestIssueAssignersShortcutWithKeyword(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_assigners.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + assertEqual(t, r.URL.Query().Get("keyword"), "alice") + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "assigners": []map[string]interface{}{ + {"id": 7, "name": "Alice", "login": "alice"}, + }, + }) + }) + defer server.Close() + + err := runShortcut(t, server, "assigners", map[string]string{"keyword": "alice"}) + if err != nil { + t.Fatalf("assigners shortcut failed: %v", err) + } +} + +func TestIssueAuthorsShortcutWithKeyword(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_authors.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + assertEqual(t, r.URL.Query().Get("keyword"), "bob") + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "authors": []map[string]interface{}{ + {"id": 8, "name": "Bob", "login": "bob"}, + }, + }) + }) + defer server.Close() + + err := runShortcut(t, server, "authors", map[string]string{"keyword": "bob"}) + if err != nil { + t.Fatalf("authors shortcut failed: %v", err) + } +} + +// --- metadata lookup shortcuts --- + +func TestIssuePrioritiesShortcut(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_priorities.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + assertEqual(t, r.URL.Query().Get("keyword"), "normal") + writeJSON(t, w, []map[string]interface{}{ + {"id": 2, "name": "Normal"}, + }) + }) + defer server.Close() + + err := runShortcut(t, server, "priorities", map[string]string{"keyword": "normal"}) + if err != nil { + t.Fatalf("priorities shortcut failed: %v", err) + } +} + +func TestIssueTagsShortcutWithFilters(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_tags.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + query := r.URL.Query() + assertEqual(t, query.Get("keyword"), "bug") + assertEqual(t, query.Get("only_name"), "true") + assertEqual(t, query.Get("order_by"), "issues_count") + assertEqual(t, query.Get("order_direction"), "desc") + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "issue_tags": []map[string]interface{}{ + {"id": 3, "name": "bug"}, + }, + }) + }) + defer server.Close() + + err := runShortcut(t, server, "tags", map[string]string{ + "keyword": "bug", + "only-name": "true", + "order-by": "issues_count", + "order-direction": "desc", + }) + if err != nil { + t.Fatalf("tags shortcut failed: %v", err) + } +} + +func TestIssueStatusesShortcut(t *testing.T) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issue_statues.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + assertEqual(t, r.URL.Query().Get("page"), "2") + assertEqual(t, r.URL.Query().Get("limit"), "10") + writeJSON(t, w, map[string]interface{}{ + "total_count": 1, + "statues": []map[string]interface{}{ + {"id": 1, "name": "Open"}, + }, + }) + }) + defer server.Close() + + err := runShortcut(t, server, "statuses", map[string]string{"page": "2", "limit": "10"}) + if err != nil { + t.Fatalf("statuses shortcut failed: %v", err) + } +} + // --- HTTP error paths --- func TestIssueListHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeText(t, w, http.StatusInternalServerError, "server error") + }) defer server.Close() err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}) @@ -486,10 +930,9 @@ func TestIssueListHTTPError(t *testing.T) { } func TestIssueCreateHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeText(t, w, http.StatusInternalServerError, "server error") + }) defer server.Close() err := runShortcut(t, server, "create", map[string]string{"title": "test"}) @@ -499,10 +942,9 @@ func TestIssueCreateHTTPError(t *testing.T) { } func TestIssueViewHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeText(t, w, http.StatusInternalServerError, "server error") + }) defer server.Close() err := runShortcut(t, server, "view", map[string]string{"number": "42"}) @@ -512,10 +954,9 @@ func TestIssueViewHTTPError(t *testing.T) { } func TestIssueCommentHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) - })) + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeText(t, w, http.StatusInternalServerError, "server error") + }) defer server.Close() err := runShortcut(t, server, "comment", map[string]string{"number": "42", "body": "test"}) @@ -525,19 +966,18 @@ func TestIssueCommentHTTPError(t *testing.T) { } func TestIssueUpdateHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "id": float64(42), "subject": "bug", "description": "desc", }) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() err := runShortcut(t, server, "update", map[string]string{"number": "42", "title": "new"}) @@ -547,19 +987,18 @@ func TestIssueUpdateHTTPError(t *testing.T) { } func TestIssueCloseHTTPError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { switch { case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json": - writeJSON(w, map[string]interface{}{ + writeJSON(t, w, map[string]interface{}{ "id": float64(42), "subject": "bug", "description": "desc", }) case r.Method == "PATCH" && r.URL.Path == "/v1/owner/repo/issues/42.json": - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("server error")) + writeText(t, w, http.StatusInternalServerError, "server error") default: t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } - })) + }) defer server.Close() err := runShortcut(t, server, "close", map[string]string{"number": "42"}) @@ -569,9 +1008,9 @@ func TestIssueCloseHTTPError(t *testing.T) { } func TestFetchExistingIssueBadData(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, "not a map") - })) + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, "not a map") + }) defer server.Close() ctx := &common.RuntimeContext{ @@ -586,9 +1025,9 @@ func TestFetchExistingIssueBadData(t *testing.T) { } func TestFetchExistingIssueNoSubject(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, map[string]interface{}{"id": float64(1)}) - })) + server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, map[string]interface{}{"id": float64(1)}) + }) defer server.Close() ctx := &common.RuntimeContext{ diff --git a/shortcuts/org/org.go b/shortcuts/org/org.go index 4cd6299..f0b5e72 100644 --- a/shortcuts/org/org.go +++ b/shortcuts/org/org.go @@ -4,17 +4,19 @@ import ( "fmt" "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List organizations", + Description: tr.T("cmd.org.list.short"), Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { q := url.Values{} @@ -29,9 +31,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "info", - Description: "Show organization details", + Description: tr.T("cmd.org.info.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Organization ID or login", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.org.id_or_login"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { id, _ := ctx.RequireArg("id") @@ -44,11 +46,11 @@ func Shortcuts() []*common.Shortcut { }, { Name: "members", - Description: "List organization members", + Description: tr.T("cmd.org.members.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Organization ID", Required: true}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { id, _ := ctx.RequireArg("id") @@ -64,10 +66,10 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create an organization", + Description: tr.T("cmd.org.create.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Organization name", Required: true}, - {Name: "description", Short: "d", Usage: "Description"}, + {Name: "name", Short: "n", Usage: tr.T("flag.org.name"), Required: true}, + {Name: "description", Short: "d", Usage: tr.T("flag.description")}, }, Run: func(ctx *common.RuntimeContext) error { name, _ := ctx.RequireArg("name") @@ -86,3 +88,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 5fd983b..afabca8 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -5,19 +5,21 @@ import ( "net/url" "strings" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List pull requests", + Description: tr.T("cmd.pr.list.short"), Flags: []common.Flag{ - {Name: "state", Short: "s", Usage: "Filter: open, merged, closed", Default: "open"}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "state", Short: "s", Usage: tr.T("flag.pr.state"), Default: "open"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -38,12 +40,12 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a pull request", + Description: tr.T("cmd.pr.create.short"), Flags: []common.Flag{ - {Name: "title", Short: "t", Usage: "PR title", Required: true}, - {Name: "body", Short: "b", Usage: "PR description"}, - {Name: "head", Usage: "Source branch", Required: true}, - {Name: "base", Usage: "Target branch", Default: "master"}, + {Name: "title", Short: "t", Usage: tr.T("flag.pr.title"), Required: true}, + {Name: "body", Short: "b", Usage: tr.T("flag.pr.body")}, + {Name: "head", Usage: tr.T("flag.pr.head"), Required: true}, + {Name: "base", Usage: tr.T("flag.pr.base"), Default: "master"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -72,9 +74,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "view", - Description: "View pull request details", + Description: tr.T("cmd.pr.view.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -93,10 +95,10 @@ func Shortcuts() []*common.Shortcut { }, { Name: "merge", - Description: "Merge a pull request", + Description: tr.T("cmd.pr.merge.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, - {Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "method", Short: "m", Usage: tr.T("flag.pr.merge_method"), Default: "merge"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -119,9 +121,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "close", - Description: "Close a pull request", + Description: tr.T("cmd.pr.close.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -158,9 +160,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "files", - Description: "List changed files in a pull request", + Description: tr.T("cmd.pr.files.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -176,9 +178,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "diff", - Description: "Show diff for a pull request", + Description: tr.T("cmd.pr.diff.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -194,9 +196,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "versions", - Description: "List pull request patchset versions", + Description: tr.T("cmd.pr.versions.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -215,11 +217,11 @@ func Shortcuts() []*common.Shortcut { }, { Name: "version-diff", - Description: "Show diff for a pull request patchset version", + Description: tr.T("cmd.pr.version_diff.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, - {Name: "version-id", Short: "v", Usage: "Patchset version ID", Required: true}, - {Name: "file", Short: "f", Usage: "Filter diff by file path"}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "version-id", Short: "v", Usage: tr.T("flag.pr.version_id"), Required: true}, + {Name: "file", Short: "f", Usage: tr.T("flag.pr.file")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -252,10 +254,10 @@ func Shortcuts() []*common.Shortcut { }, { Name: "reviews", - Description: "List pull request reviews", + Description: tr.T("cmd.pr.reviews.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, - {Name: "status", Short: "s", Usage: "Filter review status: common, approved, rejected"}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "status", Short: "s", Usage: tr.T("flag.pr.review_status_filter")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -281,13 +283,13 @@ func Shortcuts() []*common.Shortcut { }, { Name: "review", - Description: "Create a pull request review", + Description: tr.T("cmd.pr.review.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, - {Name: "status", Short: "s", Usage: "Review status: common, approved, rejected", Default: "common"}, - {Name: "content", Short: "c", Usage: "Review content", Required: true}, - {Name: "commit", Short: "m", Usage: "Commit SHA to attach the review to"}, - {Name: "dry-run", Usage: "Preview the review request without creating it", Bool: true, Default: "false"}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "status", Short: "s", Usage: tr.T("flag.pr.review_status"), Default: "common"}, + {Name: "content", Short: "c", Usage: tr.T("flag.pr.review_content"), Required: true}, + {Name: "commit", Short: "m", Usage: tr.T("flag.pr.review_commit")}, + {Name: "dry-run", Usage: tr.T("flag.dry_run"), Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -328,15 +330,29 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } + + // Also post a journal comment so the review is visible in the PR conversation. + prEnv, journalErr := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil) + if journalErr == nil { + if issueID, extractErr := extractIssueID(prEnv); extractErr == nil { + statusLabel := map[string]string{ + "approved": "approved", "rejected": "rejected", "common": "commented", + }[status] + summary := fmt.Sprintf("## Review: %s\n\n%s", statusLabel, content) + ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), + map[string]interface{}{"notes": summary}) + } + } + return ctx.Output(env) }, }, { Name: "comment", - Description: "Add a comment to a pull request", + Description: tr.T("cmd.pr.comment.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, - {Name: "body", Short: "b", Usage: "Comment body", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -367,6 +383,13 @@ func Shortcuts() []*common.Shortcut { } } +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} + func prV1Path(ctx *common.RuntimeContext, id string) string { return fmt.Sprintf("/v1/%s/%s/pulls/%s", ctx.Owner, ctx.Repo, id) } diff --git a/shortcuts/register.go b/shortcuts/register.go index 50fa41e..3c814e0 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -3,6 +3,7 @@ package shortcuts import ( "github.com/spf13/cobra" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -23,42 +24,46 @@ import ( ) // RegisterAll mounts all shortcut groups onto the root command. -func RegisterAll(root *cobra.Command) { +func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } groups := map[string][]*common.Shortcut{ - "repo": repo.Shortcuts(), - "issue": issue.Shortcuts(), + "repo": repo.Shortcuts(tr), + "issue": issue.Shortcuts(tr), "label": label.Shortcuts(), "member": member.Shortcuts(), "milestone": milestone.Shortcuts(), "pipeline": pipeline.Shortcuts(), - "pr": pr.Shortcuts(), - "release": release.Shortcuts(), - "branch": branch.Shortcuts(), - "org": org.Shortcuts(), - "user": user.Shortcuts(), - "search": search.Shortcuts(), - "ci": ci.Shortcuts(), + "pr": pr.Shortcuts(tr), + "release": release.Shortcuts(tr), + "branch": branch.Shortcuts(tr), + "org": org.Shortcuts(tr), + "user": user.Shortcuts(tr), + "search": search.Shortcuts(tr), + "ci": ci.Shortcuts(tr), "compare": compare.Shortcuts(), - "webhook": webhook.Shortcuts(), + "webhook": webhook.Shortcuts(tr), "workflow": workflow.Shortcuts(), } descriptions := map[string]string{ - "repo": "Repository operations", - "issue": "Issue operations", + "repo": tr.T("cmd.repo.short"), + "issue": tr.T("cmd.issue.short"), "label": "Issue label operations", "member": "Repository member operations", "milestone": "Milestone operations", "pipeline": "Pipeline operations", - "pr": "Pull request operations", - "release": "Release operations", - "branch": "Branch operations", - "org": "Organization operations", - "user": "User operations", - "search": "Search operations", - "ci": "CI/CD operations", + "pr": tr.T("cmd.pr.short"), + "release": tr.T("cmd.release.short"), + "branch": tr.T("cmd.branch.short"), + "org": tr.T("cmd.org.short"), + "user": tr.T("cmd.user.short"), + "search": tr.T("cmd.search.short"), + "ci": tr.T("cmd.ci.short"), "compare": "Compare branches, tags, or commits", - "webhook": "Webhook operations", + "webhook": tr.T("cmd.webhook.short"), "workflow": "AI agent workflow analysis", } @@ -67,7 +72,7 @@ func RegisterAll(root *cobra.Command) { Use: name, Short: descriptions[name], } - common.MountShortcuts(groupCmd, shortcuts) + common.MountShortcuts(groupCmd, shortcuts, tr) root.AddCommand(groupCmd) } } diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index 06240bc..c93d6a0 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -4,18 +4,20 @@ import ( "fmt" "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List releases", + Description: tr.T("cmd.release.list.short"), Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -33,13 +35,13 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a release", + Description: tr.T("cmd.release.create.short"), Flags: []common.Flag{ - {Name: "tag", Short: "t", Usage: "Tag name", Required: true}, - {Name: "name", Short: "n", Usage: "Release name", Required: true}, - {Name: "body", Short: "b", Usage: "Release notes"}, - {Name: "target", Usage: "Target branch", Default: "master"}, - {Name: "prerelease", Usage: "Mark as prerelease (true/false)", Default: "false"}, + {Name: "tag", Short: "t", Usage: tr.T("flag.release.tag"), Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.release.name"), Required: true}, + {Name: "body", Short: "b", Usage: tr.T("flag.release.body")}, + {Name: "target", Usage: tr.T("flag.release.target"), Default: "master"}, + {Name: "prerelease", Usage: tr.T("flag.release.prerelease"), Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -69,9 +71,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "view", - Description: "View release details", + Description: tr.T("cmd.release.view.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Release ID or tag", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -87,9 +89,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "delete", - Description: "Delete a release", + Description: tr.T("cmd.release.delete.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Release ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.release.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -117,3 +119,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 4d621ad..ea4f6ee 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -4,19 +4,21 @@ import ( "fmt" "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List repositories for a user or organization", + Description: tr.T("cmd.repo.list.short"), Flags: []common.Flag{ - {Name: "user", Short: "u", Usage: "User login (default: current user)"}, - {Name: "category", Short: "c", Usage: "Filter: manage/mirror/sync/fork/all (default: manage)", Default: "manage"}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "user", Short: "u", Usage: tr.T("flag.user"), Default: ""}, + {Name: "category", Short: "c", Usage: tr.T("flag.repo.category"), Default: "manage"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { user := ctx.Arg("user") @@ -40,7 +42,7 @@ func Shortcuts() []*common.Shortcut { }, { Name: "info", - Description: "Show repository details", + Description: tr.T("cmd.repo.info.short"), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -79,11 +81,11 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a new repository", + Description: tr.T("cmd.repo.create.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Repository name", Required: true}, - {Name: "description", Short: "d", Usage: "Repository description"}, - {Name: "private", Usage: "Make repository private (true/false)", Default: "false"}, + {Name: "name", Short: "n", Usage: tr.T("flag.repo.name"), Required: true}, + {Name: "description", Short: "d", Usage: tr.T("flag.repo.description")}, + {Name: "private", Usage: tr.T("flag.repo.private"), Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { name, err := ctx.RequireArg("name") @@ -121,7 +123,7 @@ func Shortcuts() []*common.Shortcut { }, { Name: "fork", - Description: "Fork a repository", + Description: tr.T("cmd.repo.fork.short"), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -135,7 +137,7 @@ func Shortcuts() []*common.Shortcut { }, { Name: "delete", - Description: "Delete a repository", + Description: tr.T("cmd.repo.delete.short"), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -149,3 +151,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/search/search.go b/shortcuts/search/search.go index 443dec3..a0ee4c2 100644 --- a/shortcuts/search/search.go +++ b/shortcuts/search/search.go @@ -3,18 +3,20 @@ package search import ( "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "repos", - Description: "Search repositories", + Description: tr.T("cmd.search.repos.short"), Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: "Search keyword", Required: true}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword"), Required: true}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { keyword, _ := ctx.RequireArg("keyword") @@ -31,11 +33,11 @@ func Shortcuts() []*common.Shortcut { }, { Name: "users", - Description: "Search users", + Description: tr.T("cmd.search.users.short"), Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: "Search keyword", Required: true}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword"), Required: true}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { keyword, _ := ctx.RequireArg("keyword") @@ -52,3 +54,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/user/user.go b/shortcuts/user/user.go index cfcaa2a..563db88 100644 --- a/shortcuts/user/user.go +++ b/shortcuts/user/user.go @@ -3,14 +3,16 @@ package user import ( "fmt" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "me", - Description: "Show current authenticated user", + Description: tr.T("cmd.user.me.short"), Run: func(ctx *common.RuntimeContext) error { env, err := ctx.CallAPI("GET", "/users/me", nil) if err != nil { @@ -21,9 +23,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "info", - Description: "Show user profile", + Description: tr.T("cmd.user.info.short"), Flags: []common.Flag{ - {Name: "login", Short: "l", Usage: "User login name", Required: true}, + {Name: "login", Short: "l", Usage: tr.T("flag.user.login"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { login, err := ctx.RequireArg("login") @@ -39,3 +41,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go index 091200e..7d0574a 100644 --- a/shortcuts/webhook/webhook.go +++ b/shortcuts/webhook/webhook.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -22,11 +23,12 @@ var allowedWebhookEvents = map[string]bool{ } // Shortcuts returns webhook management shortcuts. -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List repository webhooks", + Description: tr.T("cmd.webhook.list.short"), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -40,9 +42,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "view", - Description: "View webhook details", + Description: tr.T("cmd.webhook.view.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -61,40 +63,40 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a repository webhook", + Description: tr.T("cmd.webhook.create.short"), Flags: []common.Flag{ - {Name: "url", Short: "u", Usage: "Webhook target URL", Required: true}, - {Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only", Required: true}, - {Name: "type", Short: "t", Usage: "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", Default: "gitea"}, - {Name: "content-type", Usage: "Payload content type: json or form", Default: "json"}, - {Name: "http-method", Usage: "HTTP method: POST or GET", Default: "POST"}, - {Name: "secret", Short: "s", Usage: "Webhook secret"}, - {Name: "branch-filter", Usage: "Branch glob filter for push/create/delete events", Default: "*"}, - {Name: "active", Usage: "Whether the webhook is active: true or false", Default: "true"}, + {Name: "url", Short: "u", Usage: tr.T("flag.webhook.url"), Required: true}, + {Name: "events", Short: "e", Usage: tr.T("flag.webhook.events"), Required: true}, + {Name: "type", Short: "t", Usage: tr.T("flag.webhook.type"), Default: "gitea"}, + {Name: "content-type", Usage: tr.T("flag.webhook.content_type"), Default: "json"}, + {Name: "http-method", Usage: tr.T("flag.webhook.http_method"), Default: "POST"}, + {Name: "secret", Short: "s", Usage: tr.T("flag.webhook.secret")}, + {Name: "branch-filter", Usage: tr.T("flag.webhook.branch_filter"), Default: "*"}, + {Name: "active", Usage: tr.T("flag.webhook.active"), Default: "true"}, }, Run: runCreate, }, { Name: "update", - Description: "Update a repository webhook while preserving unspecified fields when available", + Description: tr.T("cmd.webhook.update.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, - {Name: "url", Short: "u", Usage: "Webhook target URL"}, - {Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only"}, - {Name: "type", Short: "t", Usage: "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot"}, - {Name: "content-type", Usage: "Payload content type: json or form"}, - {Name: "http-method", Usage: "HTTP method: POST or GET"}, - {Name: "secret", Short: "s", Usage: "Webhook secret. Pass it again if the server does not return existing secrets."}, - {Name: "branch-filter", Usage: "Branch glob filter for push/create/delete events"}, - {Name: "active", Usage: "Whether the webhook is active: true or false"}, + {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, + {Name: "url", Short: "u", Usage: tr.T("flag.webhook.url")}, + {Name: "events", Short: "e", Usage: tr.T("flag.webhook.events")}, + {Name: "type", Short: "t", Usage: tr.T("flag.webhook.type")}, + {Name: "content-type", Usage: tr.T("flag.webhook.content_type")}, + {Name: "http-method", Usage: tr.T("flag.webhook.http_method")}, + {Name: "secret", Short: "s", Usage: tr.T("flag.webhook.secret_update")}, + {Name: "branch-filter", Usage: tr.T("flag.webhook.branch_filter")}, + {Name: "active", Usage: tr.T("flag.webhook.active")}, }, Run: runUpdate, }, { Name: "delete", - Description: "Delete a repository webhook", + Description: tr.T("cmd.webhook.delete.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -113,9 +115,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "test", - Description: "Trigger a test delivery for a webhook", + Description: tr.T("cmd.webhook.test.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -134,9 +136,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "tasks", - Description: "List webhook delivery tasks", + Description: tr.T("cmd.webhook.tasks.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -156,6 +158,13 @@ func Shortcuts() []*common.Shortcut { } } +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} + func runCreate(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index 0928362..5688853 100644 --- a/skills/gitlink-issue/SKILL.md +++ b/skills/gitlink-issue/SKILL.md @@ -29,6 +29,9 @@ metadata: | `issue +comment` | 添加评论 | 是 | | `issue +assigners` | 查询 Issue 负责人列表 | 否(公开项目) | | `issue +authors` | 查询 Issue 发布人列表 | 否(公开项目) | +| `issue +statuses` | 查询 Issue 状态列表 | 否(公开项目) | +| `issue +tags` | 查询 Issue 标签列表 | 否(公开项目) | +| `issue +priorities` | 查询 Issue 优先级列表 | 否(公开项目) | ## 使用示例 @@ -79,15 +82,23 @@ gitlink-cli api POST /:owner/:repo/issues/series_update --body '{"ids":[1,2,3]," | gitlink-cli 参数 | GitLink API 字段 | 说明 | |------------------|-----------------|------| | `--number` / `-n` | `project_issues_index` | Issue 编号(网页 URL 中的序号) | +| `--id` / `-i` | `project_issues_index` | `--number` 的兼容别名,不是数据库内部 ID | | `--title` | `subject` | Issue 标题 | | `--body` | `description` | Issue 描述 | | `--assignee` | `assigned_to_id` | 指派人 ID | | `--milestone` | `fixed_version_id` | 里程碑 ID | | `--state` | `status_id` | 状态(open=1,closed=5,也可直接传数字 ID) | +| `--priority-id` | `priority_id` | 优先级 ID | +| `--tag-ids` / `--label` | `issue_tag_ids` | Issue 标签 ID 数组 | +| `--assigner-ids` | `assigner_ids` | 负责人 ID 数组 | +| `--branch` | `branch_name` | 关联分支 | +| `--start-date` | `start_date` | 开始日期 | +| `--due-date` | `due_date` | 截止日期 | ## API 注意事项 - **Issue 编号(`--number`)是网页 URL 中看到的序号**(如 `issues/4` 中的 `4`),不是数据库内部 ID +- `--id` / `-i` 仅作为 `--number` / `-n` 的兼容别名,传入的仍然是网页 URL 中的 Issue 编号 - **批量关闭使用 `--numbers`,同样传网页 URL 中的 Issue 编号**,不是数据库内部 ID - Issue 操作使用 v1 API(`/api/v1/`),支持按 Issue 编号查询和操作 - **创建 Issue 时 CLI 会自动设置 `status_id: 1`(新增)和 `priority_id: 2`(正常)** diff --git a/skills/gitlink-issue/references/gitlink-issue-priorities.md b/skills/gitlink-issue/references/gitlink-issue-priorities.md new file mode 100644 index 0000000..5a71a4e --- /dev/null +++ b/skills/gitlink-issue/references/gitlink-issue-priorities.md @@ -0,0 +1,25 @@ +# Issue Priorities + +Use `issue +priorities` to list the available Issue priority values for a repository. +This is useful before creating or updating an Issue that needs a specific `priority_id`. + +## Usage + +```bash +gitlink-cli issue +priorities --owner Gitlink --repo forgeplus +gitlink-cli issue +priorities --owner Gitlink --repo forgeplus --keyword normal +``` + +## Options + +| Option | Description | +|--------|-------------| +| `--owner` | Repository owner | +| `--repo` | Repository name | +| `--keyword`, `-k` | Optional search keyword | + +## API + +```http +GET /api/v1/{owner}/{repo}/issue_priorities.json +``` diff --git a/skills/gitlink-issue/references/gitlink-issue-statuses.md b/skills/gitlink-issue/references/gitlink-issue-statuses.md new file mode 100644 index 0000000..5f85237 --- /dev/null +++ b/skills/gitlink-issue/references/gitlink-issue-statuses.md @@ -0,0 +1,28 @@ +# Issue Statuses + +Use `issue +statuses` to list available Issue status values for a repository. +This helps users find `status_id` values before creating reports or updating Issues. + +## Usage + +```bash +gitlink-cli issue +statuses --owner Gitlink --repo forgeplus +gitlink-cli issue +statuses --owner Gitlink --repo forgeplus --page 1 --limit 50 +``` + +## Options + +| Option | Description | +|--------|-------------| +| `--owner` | Repository owner | +| `--repo` | Repository name | +| `--page`, `-p` | Page number | +| `--limit`, `-l` | Items per page | + +## API + +```http +GET /api/v1/{owner}/{repo}/issue_statues.json +``` + +The API path and response field use `statues` as documented by GitLink OpenAPI. diff --git a/skills/gitlink-issue/references/gitlink-issue-tags.md b/skills/gitlink-issue/references/gitlink-issue-tags.md new file mode 100644 index 0000000..2ed581e --- /dev/null +++ b/skills/gitlink-issue/references/gitlink-issue-tags.md @@ -0,0 +1,31 @@ +# Issue Tags + +Use `issue +tags` to list the Issue tags available in a repository. +This helps users find tag IDs before creating or updating Issues. + +## Usage + +```bash +gitlink-cli issue +tags --owner Gitlink --repo forgeplus +gitlink-cli issue +tags --owner Gitlink --repo forgeplus --only-name --keyword bug +gitlink-cli issue +tags --owner Gitlink --repo forgeplus --order-by issues_count --order-direction desc +``` + +## Options + +| Option | Description | +|--------|-------------| +| `--owner` | Repository owner | +| `--repo` | Repository name | +| `--keyword`, `-k` | Optional search keyword | +| `--only-name` | Return only tag names and IDs | +| `--order-by` | Sort field: `updated_on`, `created_on`, or `issues_count` | +| `--order-direction` | Sort direction: `asc` or `desc` | + +## API + +```http +GET /api/v1/{owner}/{repo}/issue_tags.json +``` + +Query parameters are mapped as `keyword`, `only_name`, `order_by`, and `order_direction`. diff --git a/skills/gitlink-license-compliance/SKILL.md b/skills/gitlink-license-compliance/SKILL.md new file mode 100644 index 0000000..d8ad68e --- /dev/null +++ b/skills/gitlink-license-compliance/SKILL.md @@ -0,0 +1,348 @@ +--- +name: gitlink-license-compliance +version: 1.0.0 +description: "许可证合规检查:扫描仓库的许可证兼容性、依赖项合规性与敏感信息泄露风险,生成结构化合规评估报告。当用户需要检查项目许可证合规性、排查敏感信息泄露、评估开源风险时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli repo --help" +--- + +# gitlink-license-compliance(许可证合规检查) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +## 功能概述 + +本技能为科研项目、开源项目提供全面的合规性扫描,包括: + +1. **许可证识别** — 检测仓库根目录及子目录的许可证声明 +2. **许可证兼容性分析** — 评估项目许可证与依赖项许可证的兼容性 +3. **敏感信息扫描** — 检测代码中硬编码的密钥、密码、Token 等敏感数据 +4. **合规报告生成** — 输出结构化的合规评估报告 + +--- + +## 一、许可证识别 + +### 获取仓库根目录文件列表 + +```bash +# 先克隆仓库到本地(如果尚未克隆) +git clone https://www.gitlink.org.cn//.git +cd + +# 获取根目录文件列表,查找许可证文件 +git ls-tree --name-only HEAD +``` + +### 常见许可证文件名 + +``` +LICENSE +LICENSE.md +LICENSE.txt +LICENSE-MIT +LICENSE-APACHE +COPYING +COPYING.md +``` + +### 读取许可证文件内容 + +```bash +# 读取 LICENSE 文件内容 +git show HEAD:LICENSE +# 或查看其他常见许可证文件 +git show HEAD:LICENSE.md +git show HEAD:LICENSE.txt +``` + +### 获取 GitLink 平台支持的许可证列表 + +```bash +# 获取仓库中的许可证文件 +git ls-tree --name-only HEAD | grep -iE "(license|copying)" +# 如果仓库未声明许可证,结合下方的关键词识别规则 AI 直接判断 +``` + +### 常见许可证识别关键词 + +| 许可证 | 识别关键词 | +|--------|-----------| +| MIT | `MIT License`, `Permission is hereby granted, free of charge` | +| Apache-2.0 | `Apache License, Version 2.0` | +| GPL-2.0 | `GNU GENERAL PUBLIC LICENSE`, `Version 2` | +| GPL-3.0 | `GNU GENERAL PUBLIC LICENSE`, `Version 3` | +| LGPL-2.1 | `GNU LESSER GENERAL PUBLIC LICENSE`, `Version 2.1` | +| BSD-2-Clause | `Redistribution and use in source and binary forms` | +| BSD-3-Clause | `Neither the name of`, `nor the names of its contributors` | +| MPL-2.0 | `Mozilla Public License, v. 2.0` | +| MulanPSL-2.0 | `木兰宽松许可证`, `Mulan Permissive Software License` | +| CC0-1.0 | `Creative Commons Legal Code`, `CC0` | + +--- + +## 二、许可证兼容性分析 + +### 许可证分类(按限制程度) + +``` +宽松型(Permissive)—— 限制最少,可自由商用和闭源分发: + MIT、Apache-2.0、BSD-2-Clause、BSD-3-Clause、ISC、MulanPSL-2.0 + +弱传染型(Weak Copyleft)—— 修改的库文件需开源,但不传染整体: + LGPL-2.1、LGPL-3.0、MPL-2.0、EPL-2.0 + +强传染型(Strong Copyleft)—— 整个项目需以相同许可证开源: + GPL-2.0、GPL-3.0、AGPL-3.0 + +网络服务传染型(Network Copyleft)—— 通过网络提供服务也需开源: + AGPL-3.0 +``` + +### 许可证兼容性矩阵 + +下表描述项目许可证(行)与依赖许可证(列)的兼容性: + +| 项目 \ 依赖 | MIT | Apache-2.0 | GPL-2.0 | GPL-3.0 | LGPL-2.1 | AGPL-3.0 | MulanPSL-2.0 | +|-------------|-----|-----------|---------|---------|----------|----------|--------------| +| MIT | ✅ | ✅ | ❌ | ❌ | ⚠️ | ❌ | ✅ | +| Apache-2.0 | ✅ | ✅ | ❌ | ⚠️ | ⚠️ | ❌ | ✅ | +| GPL-2.0 | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ | ✅ | +| GPL-3.0 | ✅ | ✅ | ⚠️ | ✅ | ✅ | ⚠️ | ✅ | +| MulanPSL-2.0 | ✅ | ✅ | ❌ | ❌ | ⚠️ | ❌ | ✅ | + +> ✅ 兼容 | ⚠️ 需谨慎(存在条件兼容场景)| ❌ 不兼容(引入此依赖可能违反许可证) + +### 依赖项许可证扫描 + +```bash +# 获取仓库文件列表,定位依赖声明文件 +git ls-tree --name-only HEAD + +# 常见依赖文件: +# Go: go.mod +# Python: requirements.txt, setup.py, pyproject.toml +# Node: package.json +# Java: pom.xml, build.gradle +# Rust: Cargo.toml + +# 读取依赖文件内容 +git show HEAD:go.mod +``` + +### AI分析依赖许可证的步骤 + +1. 读取依赖声明文件,提取所有依赖包名和版本 +2. 对每个依赖包,根据已知知识库判断其许可证类型 +3. 对照兼容性矩阵,评估与项目主许可证的兼容性 +4. 对不确定的依赖,标记为"需人工确认" + +--- + +## 三、敏感信息扫描 + +### 扫描目标文件类型 + +```bash +# 获取仓库文件树,确定扫描范围 +git ls-tree --name-only HEAD + +# 递归列出子目录文件 +git ls-tree -r --name-only HEAD +``` + +**高风险文件(优先扫描):** + +``` +配置文件:.env, .env.local, .env.production, config.yaml, config.json, settings.py +证书文件:*.pem, *.key, *.p12, *.pfx, *.crt, *.cer +SSH 密钥:id_rsa, id_dsa, id_ecdsa, id_ed25519 +数据库配置:database.yml, db.conf, datasource.properties +CI 配置:.travis.yml, .github/workflows/*.yml, .gitlink-ci.yml +``` + +### 敏感信息特征模式 + +AI扫描文件内容时,重点识别以下模式: + +``` +密钥/Token 特征(正则): + AWS: AKIA[0-9A-Z]{16} + GitHub: ghp_[A-Za-z0-9]{36} + GitLink: glpat-[A-Za-z0-9\-_]{20} + 私钥头: -----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY----- + 通用密钥: (password|passwd|pwd|secret|token|apikey|api_key)\s*[=:]\s*['"]?[^\s'"]{8,} + 数据库连接: (mysql|postgres|mongodb)://[^@]+:[^@]+@ + +IP/内部地址: + 内网 IP: (192\.168\.|10\.\d+\.|172\.(1[6-9]|2[0-9]|3[0-1])\.) + localhost: localhost:\d{4,5}(需结合上下文判断是否为敏感配置) +``` + +### 读取特定文件内容 + +```bash +# 读取配置文件内容(用于敏感信息检测) +git show HEAD:.env +# 文件不存在时会报错,可用以下方式判断文件是否存在 +git ls-tree --name-only HEAD | grep "^\.env$" +``` + +### 敏感信息风险等级 + +| 风险等级 | 描述 | 处理建议 | +|---------|------|---------| +| 🔴 严重 | 真实密钥/Token 暴露在代码中 | 立即撤销密钥,从 Git 历史清除 | +| 🟠 高 | 密码/数据库连接串硬编码 | 替换为环境变量,清理历史提交 | +| 🟡 中 | 内网地址/测试账号泄露 | 评估影响范围,按需处理 | +| 🟢 低 | 疑似敏感但可能是示例数据 | 人工确认后决定是否处理 | + +--- + +## 四、合规报告生成 + +### 完整合规报告模板 + +```markdown +# 📋 仓库合规检查报告 + +**仓库:** owner/repo +**检查时间:** 2026-05-07 +**检查人:** AI Agent(gitlink-license-compliance v1.0.0) + +--- + +## 一、许可证概览 + +| 项目 | 结果 | +|------|------| +| 主许可证 | ✅ MIT License | +| 许可证文件位置 | `LICENSE` | +| 商业使用 | ✅ 允许 | +| 闭源分发 | ✅ 允许 | +| 专利授权 | ⚠️ 无明确专利授权(建议升级为 Apache-2.0) | + +--- + +## 二、依赖项许可证分析 + +| 依赖包 | 版本 | 许可证 | 兼容性 | +|--------|------|--------|--------| +| github.com/spf13/cobra | v1.8.0 | Apache-2.0 | ✅ 兼容 | +| github.com/stretchr/testify | v1.9.0 | MIT | ✅ 兼容 | +| gopkg.in/yaml.v3 | v3.0.1 | MIT / Apache-2.0 | ✅ 兼容 | +| golang.org/x/sys | v0.21.0 | BSD-3-Clause | ✅ 兼容 | +| example.com/gpl-lib | v1.0.0 | GPL-3.0 | ❌ 不兼容 | + +### 不兼容项详情 + +**[1] example.com/gpl-lib(GPL-3.0)** +- 问题:GPL-3.0 要求整个项目以 GPL-3.0 发布,与 MIT 主许可证冲突 +- 影响:若分发包含此依赖的产品,需将整个项目改为 GPL-3.0 +- 建议:寻找 MIT/Apache-2.0 许可证的替代库,或与法务确认使用场景 + +--- + +## 三、敏感信息扫描结果 + +### 风险汇总 + +| 风险等级 | 数量 | +|---------|------| +| 🔴 严重 | 0 | +| 🟠 高 | 1 | +| 🟡 中 | 0 | +| 🟢 低 | 2 | + +### 详细发现 + +**[高风险] config/database.yml** +- 位置:第 12 行 +- 内容特征:`password: prod_db_pass_123`(疑似真实密码硬编码) +- 建议:替换为环境变量 `${DB_PASSWORD}`,并检查该密码是否已在生产环境使用 + +**[低风险] examples/demo.go** +- 位置:第 45 行 +- 内容特征:`token: "example_token_for_demo"`(疑似示例 Token) +- 建议:添加注释说明这是示例数据,如 `// TODO: replace with real token` + +**[低风险] README.md** +- 位置:第 78 行 +- 内容特征:内网地址 `192.168.1.100:8080` +- 建议:确认是否为文档示例,若是则无需处理 + +--- + +## 四、合规评分 + +| 评分维度 | 得分 | 满分 | +|---------|------|------| +| 许可证完整性 | 10 | 10 | +| 许可证兼容性 | 7 | 10 | +| 敏感信息管控 | 7 | 10 | +| **综合评分** | **24** | **30** | + +**评级:B — 基本合规,存在需整改项** + +--- + +## 五、整改建议(按优先级) + +### 必须处理(P0) +1. **移除 config/database.yml 中的硬编码密码**(高风险) + - 操作:将密码替换为环境变量 + - 检查 Git 历史:`git log --all --follow -p config/database.yml | grep password` + - 如已有历史提交包含真实密码,需联系 GitLink 平台管理员清理 + +### 建议处理(P1) +2. **替换 GPL-3.0 依赖库** example.com/gpl-lib + - 评估是否有功能等价的宽松许可证替代库 + - 若无替代,需在项目文档中说明 GPL-3.0 传染影响 + +### 可选优化(P2) +3. 将许可证从 MIT 升级为 Apache-2.0(获得专利保护) +4. 为 examples/ 目录添加 .gitignore 排除示例密钥文件 +``` + +--- + +## 五、执行步骤总览 + +```bash +# Step 1:获取仓库基本信息 +gitlink-cli repo +info --owner --repo --format json + +# Step 2:克隆仓库到本地并获取根目录文件列表,定位许可证和依赖文件 +git clone https://www.gitlink.org.cn//.git +cd +git ls-tree --name-only HEAD + +# Step 3:读取许可证文件内容 +git show HEAD:LICENSE + +# Step 4:读取依赖声明文件 +git show HEAD:go.mod +# 根据项目类型选择对应文件(如 requirements.txt / package.json / pom.xml) + +# Step 5:递归列出所有文件,读取高风险配置文件(.env, config.yaml 等) +git ls-tree -r --name-only HEAD +git show HEAD:.env + +# Step 6:AI 综合分析所有数据,生成合规报告 +``` + +--- + +## 注意事项 + +- ✅ **文件内容直接读取**:通过 `git show` 读取文件内容为明文,可直接分析 +- ✅ **历史提交也需检查**:敏感信息即使已被删除,仍可能存在于 Git 历史中 +- ⚠️ **依赖许可证以官方声明为准**:AI推断可能不准确,对不确定的依赖标注"需人工确认" +- ✅ **报告仅供参考**:许可证合规最终应由法务或专业人员确认 +- ⚠️ **不自动修改代码**:本技能只做扫描和报告,不自动修改任何文件 +- ⚠️ **不删除任何文件**:本技能不允许执行任何删除命令