feat(i18n): add localization infrastructure

This commit is contained in:
whzy 2026-05-27 21:43:04 +08:00
parent 8ad6cd65c2
commit 29401c83e5
39 changed files with 1797 additions and 292 deletions

27
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,27 @@
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: Run Go tests
run: go test ./...

View File

@ -10,14 +10,18 @@ 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(tr *i18n.Translator) *cobra.Command {
if tr == nil {
tr = i18n.Default()
}
apiCmd := &cobra.Command{
Use: "api <METHOD> <PATH>",
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":"..."}'`,
@ -25,9 +29,9 @@ func NewAPICmd() *cobra.Command {
RunE: runAPI,
}
apiCmd.Flags().String("body", "", "Request body (JSON string)")
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("query", "", tr.T("flag.api.query"))
apiCmd.Flags().StringSlice("header", nil, tr.T("flag.api.header"))
return apiCmd
}

View File

@ -11,27 +11,31 @@ import (
"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 {
func NewAuthCmd(tr *i18n.Translator) *cobra.Command {
if tr == nil {
tr = i18n.Default()
}
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()
@ -39,7 +43,7 @@ func newLoginCmd() *cobra.Command {
return loginWithPassword()
},
}
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
}
@ -85,10 +89,10 @@ func loginWithToken() error {
return nil
}
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)
@ -99,10 +103,10 @@ func newLogoutCmd() *cobra.Command {
}
}
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 {
// Check env var token first
if envToken := os.Getenv(envTokenVar); envToken != "" {

View File

@ -6,4 +6,5 @@ var (
Repo string
Format string
Debug bool
Lang string
)

View File

@ -6,24 +6,28 @@ import (
"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(tr *i18n.Translator) *cobra.Command {
if tr == nil {
tr = i18n.Default()
}
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(tr *i18n.Translator) *cobra.Command {
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 {
@ -35,10 +39,10 @@ func newInitCmd() *cobra.Command {
}
}
func newSetCmd() *cobra.Command {
func newSetCmd(tr *i18n.Translator) *cobra.Command {
return &cobra.Command{
Use: "set <key> <value>",
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 {
@ -50,10 +54,10 @@ func newSetCmd() *cobra.Command {
}
}
func newGetCmd() *cobra.Command {
func newGetCmd(tr *i18n.Translator) *cobra.Command {
return &cobra.Command{
Use: "get <key>",
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])
@ -70,10 +74,10 @@ func newGetCmd() *cobra.Command {
}
}
func newListCmd() *cobra.Command {
func newListCmd(tr *i18n.Translator) *cobra.Command {
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 {
@ -83,6 +87,7 @@ func newListCmd() *cobra.Command {
fmt.Printf("default_format: %s\n", cfg.Format)
fmt.Printf("editor: %s\n", cfg.Editor)
fmt.Printf("pager: %s\n", cfg.Pager)
fmt.Printf("lang: %s\n", cfg.Lang)
fmt.Printf("\nConfig file: %s\n", internalConfig.ConfigPath())
return nil
},

View File

@ -6,49 +6,111 @@ import (
"github.com/spf13/cobra"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
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
}
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 = i18n.New(i18n.Options{Locale: "en-US"})
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"),
Run: func(cmd *cobra.Command, args []string) {
fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.version", i18n.Args{"version": version}))
},
}
}
func Execute() error {
args := os.Args[1:]
tr, err := newTranslator(args)
if err != nil {
return err
}
rootCmd, err := NewRootCmd(RootOptions{
Version: Version,
Args: args,
}, tr)
if err != nil {
return err
}
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
return err
}
return nil
}
func newTranslator(args []string) (*i18n.Translator, error) {
available, err := i18n.AvailableLocales()
if err != nil {
return nil, err
}
locale := i18n.ResolveLocale(i18n.ResolveOptions{
ExplicitLang: i18n.PreScanLang(args),
Env: i18n.EnvMap(),
ConfigLang: loadConfigLangBestEffort(),
}, available)
return i18n.New(i18n.Options{Locale: locale})
}
func loadConfigLangBestEffort() string {
cfg, err := internalConfig.Load()
if err != nil {
return ""
}
return cfg.Lang
}

205
cmd/root_test.go Normal file
View File

@ -0,0 +1,205 @@
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 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{"issue", "+batch-close", "--help"},
want: []string{"批量关闭筛选后的议题", "--older-than-days", "必需的安全筛选条件", "--yes"},
},
{
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)
}
}

View File

@ -0,0 +1,72 @@
# GitLink CLI i18n Infrastructure Summary
## Project Scope
This work introduces a maintainable localization infrastructure for GitLink CLI. It turns user-facing command help and flag descriptions into validated locale resources while keeping machine-readable outputs stable.
## Completed Capabilities
- Added `internal/i18n` as a dedicated localization package.
- Embedded locale JSON files with `go:embed`.
- Added `Translator` with `T` and `Tf` APIs.
- Added fallback behavior from selected locale to `en-US`, then to the message key.
- Added locale resolution from `--lang`, `GITLINK_LANG`, `config.lang`, `LC_ALL`, and `LANG`.
- Added locale normalization and matching for common inputs such as `zh_CN`, `zh-Hans-CN`, `zh`, and `en`.
- Added simple `{name}` template rendering for parameterized messages.
- Added `go run ./internal/i18n/cmd/check` to validate key completeness, empty messages, key naming, and template argument consistency.
- Added GitHub Actions test workflow for i18n validation and Go tests.
## CLI Integration
- Reworked root command construction from package-level static initialization to `NewRootCmd(opts, tr)`.
- Added global `--lang` flag.
- Added `lang` support to the config model.
- Localized root command help, global flags, version help/output, and shortcut group descriptions.
- Localized command and flag help for:
- `api`
- `auth`
- `config`
- `repo`
- `issue`
- `pr`
- `branch`
- `release`
- `org`
- `user`
- `search`
- `ci`
- `webhook`
## Deliberate Non-Goals
The migration intentionally does not localize:
- JSON field names
- API response bodies
- raw API errors returned by the server
- debug diagnostics
- internal developer-only error context
This keeps scripted usage stable while improving human-facing CLI experience.
## Validation
The implementation has been validated with:
```powershell
go run ./internal/i18n/cmd/check
go test ./...
git diff --check
```
The local Go toolchain used for validation is:
```text
E:\tools\go-sdk\go1.26.1\go\bin\go.exe
```
## Follow-Up Work
- Migrate selected runtime success/error messages where they are clearly user-facing.
- Consider a stricter scanner for new hardcoded help text.
- Add contributor documentation for adding a new locale.

View File

@ -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)
}

4
internal/i18n/args.go Normal file
View File

@ -0,0 +1,4 @@
package i18n
// Args contains named values used by parameterized messages.
type Args map[string]any

View File

@ -0,0 +1,23 @@
package main
import (
"fmt"
"os"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
)
func main() {
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)
}
fmt.Println("i18n messages are valid")
}

5
internal/i18n/doc.go Normal file
View File

@ -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

66
internal/i18n/loader.go Normal file
View File

@ -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()
}

98
internal/i18n/locale.go Normal file
View File

@ -0,0 +1,98 @@
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, normalized,
// language-primary, then fallback matching.
func MatchLocale(requested string, available []string, fallback string) string {
fallback = NormalizeLocale(fallback)
if fallback == "" {
fallback = defaultFallbackLocale
}
if len(available) == 0 {
return fallback
}
byLocale := make(map[string]string, len(available))
byPrimary := make(map[string]string, len(available))
for _, locale := range available {
normalized := NormalizeLocale(locale)
byLocale[normalized] = normalized
if primary := primaryLanguage(normalized); primary != "" {
if _, exists := byPrimary[primary]; !exists {
byPrimary[primary] = normalized
}
}
}
candidate := NormalizeLocale(requested)
if candidate != "" {
if matched, ok := byLocale[candidate]; ok {
return matched
}
if alias := localeAlias(candidate); alias != "" {
if matched, ok := byLocale[alias]; ok {
return matched
}
}
if matched, ok := byPrimary[primaryLanguage(candidate)]; ok {
return matched
}
}
if matched, ok := byLocale[fallback]; ok {
return matched
}
return NormalizeLocale(available[0])
}
func primaryLanguage(locale string) string {
if idx := strings.IndexByte(locale, '-'); idx >= 0 {
return locale[:idx]
}
return locale
}
func localeAlias(locale string) string {
switch primaryLanguage(locale) {
case "zh":
return "zh-CN"
case "en":
return "en-US"
default:
return ""
}
}

View File

@ -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)
}
}
}

View File

@ -0,0 +1,164 @@
{
"cmd.root.short": "GitLink CLI - command-line tool for GitLink",
"cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.",
"cmd.api.short": "Make raw API requests to GitLink",
"cmd.api.long": "Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.",
"cmd.auth.short": "Authentication commands",
"cmd.auth.login.short": "Login to GitLink",
"cmd.auth.logout.short": "Logout from GitLink",
"cmd.auth.status.short": "Show authentication status",
"cmd.config.short": "Manage gitlink-cli configuration",
"cmd.config.init.short": "Initialize configuration file",
"cmd.config.set.short": "Set a configuration value",
"cmd.config.get.short": "Get a configuration value",
"cmd.config.list.short": "List all configuration values",
"cmd.version.short": "Print version information",
"cmd.repo.short": "Repository operations",
"cmd.repo.list.short": "List repositories for a user or organization",
"cmd.repo.info.short": "Show repository details",
"cmd.repo.create.short": "Create a new repository",
"cmd.repo.fork.short": "Fork a repository",
"cmd.repo.delete.short": "Delete a repository",
"cmd.issue.short": "Issue operations",
"cmd.issue.list.short": "List issues",
"cmd.issue.create.short": "Create a new issue",
"cmd.issue.view.short": "View issue details",
"cmd.issue.close.short": "Close an issue",
"cmd.issue.update.short": "Update an issue",
"cmd.issue.comment.short": "Add a comment to an issue",
"cmd.issue.batch_list.short": "List issue batch maintenance candidates without changing remote data",
"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_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.",
"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_label.short": "Add a label to 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.pr.short": "Pull request operations",
"cmd.pr.list.short": "List pull requests",
"cmd.pr.create.short": "Create a pull request",
"cmd.pr.view.short": "View pull request details",
"cmd.pr.merge.short": "Merge a pull request",
"cmd.pr.close.short": "Close a pull request",
"cmd.pr.files.short": "List changed files in a pull request",
"cmd.pr.diff.short": "Show diff for a pull request",
"cmd.pr.versions.short": "List pull request patchset versions",
"cmd.pr.version_diff.short": "Show diff for a pull request patchset version",
"cmd.pr.reviews.short": "List pull request reviews",
"cmd.pr.review.short": "Create a pull request review",
"cmd.pr.comment.short": "Add a comment to a pull request",
"cmd.release.short": "Release operations",
"cmd.release.list.short": "List releases",
"cmd.release.create.short": "Create a release",
"cmd.release.view.short": "View release details",
"cmd.release.delete.short": "Delete a release",
"cmd.branch.short": "Branch operations",
"cmd.branch.list.short": "List branches",
"cmd.branch.create.short": "Create a branch",
"cmd.branch.delete.short": "Delete a branch",
"cmd.branch.protect.short": "Set branch protection",
"cmd.branch.unprotect.short": "Remove branch protection",
"cmd.org.short": "Organization operations",
"cmd.org.list.short": "List organizations",
"cmd.org.info.short": "Show organization details",
"cmd.org.members.short": "List organization members",
"cmd.org.create.short": "Create an organization",
"cmd.user.short": "User operations",
"cmd.user.me.short": "Show current authenticated user",
"cmd.user.info.short": "Show user profile",
"cmd.search.short": "Search operations",
"cmd.search.repos.short": "Search repositories",
"cmd.search.users.short": "Search users",
"cmd.ci.short": "CI/CD operations",
"cmd.ci.builds.short": "List CI builds",
"cmd.ci.logs.short": "View build logs",
"cmd.ci.restart.short": "Restart a build",
"cmd.ci.stop.short": "Stop a build",
"cmd.webhook.short": "Webhook operations",
"cmd.webhook.list.short": "List repository webhooks",
"cmd.webhook.view.short": "View webhook details",
"cmd.webhook.create.short": "Create a repository webhook",
"cmd.webhook.update.short": "Update a repository webhook while preserving unspecified fields when available",
"cmd.webhook.delete.short": "Delete a repository webhook",
"cmd.webhook.test.short": "Trigger a test delivery for a webhook",
"cmd.webhook.tasks.short": "List webhook delivery tasks",
"flag.owner": "Repository owner (auto-detected from git remote)",
"flag.repo": "Repository name (auto-detected from git remote)",
"flag.format": "Output format: json, table, yaml (default: table)",
"flag.debug": "Enable debug output",
"flag.lang": "Display language",
"flag.api.body": "Request body (JSON string)",
"flag.api.query": "Query parameters (key=val&key2=val2)",
"flag.api.header": "Additional headers (key:value)",
"flag.auth.token": "Login by pasting an existing token",
"flag.user": "User login (default: current user)",
"flag.user.login": "User login name",
"flag.page": "Page number",
"flag.limit": "Items per page",
"flag.description": "Description",
"flag.dry_run": "Preview the request without creating it",
"flag.comment.body": "Comment body",
"flag.repo.category": "Filter: manage/mirror/sync/fork/all (default: manage)",
"flag.repo.name": "Repository name",
"flag.repo.description": "Repository description",
"flag.repo.private": "Make repository private (true/false)",
"flag.issue.state": "Filter by state: open, closed, all",
"flag.issue.title": "Issue title",
"flag.issue.body": "Issue description",
"flag.issue.assignee": "Assignee login",
"flag.issue.milestone": "Milestone ID",
"flag.issue.label": "Label ID",
"flag.issue.number": "Issue number (as shown in the web URL)",
"flag.issue.new_title": "New title",
"flag.issue.new_body": "New description",
"flag.issue.new_state": "New state: open, closed, or numeric status_id",
"flag.issue.label_filter": "Filter by existing label",
"flag.issue.older_than_days": "Only include issues inactive for at least this many days",
"flag.issue.batch_list.limit": "Maximum issues to return, capped at 100",
"flag.issue.batch_close.state": "Filter by issue state before closing",
"flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7",
"flag.issue.batch_process.limit": "Maximum issues to process, capped at 100",
"flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.",
"flag.issue.batch.reason": "Optional reason shown in the batch result",
"flag.issue.batch_label.state": "Filter by issue state",
"flag.issue.add_label": "Label to add to each matching issue",
"flag.pr.state": "Filter: open, merged, closed",
"flag.pr.title": "PR title",
"flag.pr.body": "PR description",
"flag.pr.head": "Source branch",
"flag.pr.base": "Target branch",
"flag.pr.id": "PR number",
"flag.pr.merge_method": "Merge method: merge, rebase, squash",
"flag.pr.version_id": "Patchset version ID",
"flag.pr.file": "Filter diff by file path",
"flag.pr.review_status_filter": "Filter review status: common, approved, rejected",
"flag.pr.review_status": "Review status: common, approved, rejected",
"flag.pr.review_content": "Review content",
"flag.pr.review_commit": "Commit SHA to attach the review to",
"flag.branch.name": "Branch name",
"flag.branch.from": "Source branch or commit",
"flag.release.tag": "Tag name",
"flag.release.name": "Release name",
"flag.release.body": "Release notes",
"flag.release.target": "Target branch",
"flag.release.prerelease": "Mark as prerelease (true/false)",
"flag.release.id_or_tag": "Release ID or tag",
"flag.release.id": "Release ID",
"flag.org.id_or_login": "Organization ID or login",
"flag.org.id": "Organization ID",
"flag.org.name": "Organization name",
"flag.search.keyword": "Search keyword",
"flag.ci.build": "Build number",
"flag.ci.stage": "Stage number",
"flag.ci.step": "Step number",
"flag.webhook.id": "Webhook ID",
"flag.webhook.url": "Webhook target URL",
"flag.webhook.events": "Comma-separated events, for example: push,issues_only",
"flag.webhook.type": "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot",
"flag.webhook.content_type": "Payload content type: json or form",
"flag.webhook.http_method": "HTTP method: POST or GET",
"flag.webhook.secret": "Webhook secret",
"flag.webhook.secret_update": "Webhook secret. Pass it again if the server does not return existing secrets.",
"flag.webhook.branch_filter": "Branch glob filter for push/create/delete events",
"flag.webhook.active": "Whether the webhook is active: true or false",
"error.unsupported_language": "unsupported language: {lang}",
"output.version": "gitlink-cli {version}"
}

View File

@ -0,0 +1,164 @@
{
"cmd.root.short": "GitLink CLI - GitLink 命令行工具",
"cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。",
"cmd.api.short": "向 GitLink 发起原始 API 请求",
"cmd.api.long": "向 GitLink API 发送任意 HTTP 请求。认证信息会自动注入。",
"cmd.auth.short": "认证命令",
"cmd.auth.login.short": "登录 GitLink",
"cmd.auth.logout.short": "退出 GitLink 登录",
"cmd.auth.status.short": "显示认证状态",
"cmd.config.short": "管理 gitlink-cli 配置",
"cmd.config.init.short": "初始化配置文件",
"cmd.config.set.short": "设置配置项",
"cmd.config.get.short": "获取配置项",
"cmd.config.list.short": "列出所有配置项",
"cmd.version.short": "打印版本信息",
"cmd.repo.short": "仓库操作",
"cmd.repo.list.short": "列出用户或组织的仓库",
"cmd.repo.info.short": "显示仓库详情",
"cmd.repo.create.short": "创建新仓库",
"cmd.repo.fork.short": "Fork 仓库",
"cmd.repo.delete.short": "删除仓库",
"cmd.issue.short": "议题操作",
"cmd.issue.list.short": "列出议题",
"cmd.issue.create.short": "创建新议题",
"cmd.issue.view.short": "查看议题详情",
"cmd.issue.close.short": "关闭议题",
"cmd.issue.update.short": "更新议题",
"cmd.issue.comment.short": "给议题添加评论",
"cmd.issue.batch_list.short": "列出议题批量维护候选项,不修改远端数据",
"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_close.short": "批量关闭筛选后的议题。默认 dry-run传入 --yes 后执行。",
"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_label.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.pr.short": "拉取请求操作",
"cmd.pr.list.short": "列出拉取请求",
"cmd.pr.create.short": "创建拉取请求",
"cmd.pr.view.short": "查看拉取请求详情",
"cmd.pr.merge.short": "合并拉取请求",
"cmd.pr.close.short": "关闭拉取请求",
"cmd.pr.files.short": "列出拉取请求中的变更文件",
"cmd.pr.diff.short": "显示拉取请求 diff",
"cmd.pr.versions.short": "列出拉取请求补丁集版本",
"cmd.pr.version_diff.short": "显示拉取请求补丁集版本 diff",
"cmd.pr.reviews.short": "列出拉取请求评审",
"cmd.pr.review.short": "创建拉取请求评审",
"cmd.pr.comment.short": "给拉取请求添加评论",
"cmd.release.short": "发布操作",
"cmd.release.list.short": "列出发布",
"cmd.release.create.short": "创建发布",
"cmd.release.view.short": "查看发布详情",
"cmd.release.delete.short": "删除发布",
"cmd.branch.short": "分支操作",
"cmd.branch.list.short": "列出分支",
"cmd.branch.create.short": "创建分支",
"cmd.branch.delete.short": "删除分支",
"cmd.branch.protect.short": "设置分支保护",
"cmd.branch.unprotect.short": "移除分支保护",
"cmd.org.short": "组织操作",
"cmd.org.list.short": "列出组织",
"cmd.org.info.short": "显示组织详情",
"cmd.org.members.short": "列出组织成员",
"cmd.org.create.short": "创建组织",
"cmd.user.short": "用户操作",
"cmd.user.me.short": "显示当前认证用户",
"cmd.user.info.short": "显示用户资料",
"cmd.search.short": "搜索操作",
"cmd.search.repos.short": "搜索仓库",
"cmd.search.users.short": "搜索用户",
"cmd.ci.short": "CI/CD 操作",
"cmd.ci.builds.short": "列出 CI 构建",
"cmd.ci.logs.short": "查看构建日志",
"cmd.ci.restart.short": "重启构建",
"cmd.ci.stop.short": "停止构建",
"cmd.webhook.short": "Webhook 操作",
"cmd.webhook.list.short": "列出仓库 Webhook",
"cmd.webhook.view.short": "查看 Webhook 详情",
"cmd.webhook.create.short": "创建仓库 Webhook",
"cmd.webhook.update.short": "更新仓库 Webhook并在可用时保留未指定字段",
"cmd.webhook.delete.short": "删除仓库 Webhook",
"cmd.webhook.test.short": "触发 Webhook 测试投递",
"cmd.webhook.tasks.short": "列出 Webhook 投递任务",
"flag.owner": "仓库所有者(自动从 git remote 检测)",
"flag.repo": "仓库名称(自动从 git remote 检测)",
"flag.format": "输出格式json、table、yaml默认table",
"flag.debug": "启用调试输出",
"flag.lang": "显示语言",
"flag.api.body": "请求体JSON 字符串)",
"flag.api.query": "查询参数key=val&key2=val2",
"flag.api.header": "附加请求头key:value",
"flag.auth.token": "通过粘贴已有 Token 登录",
"flag.user": "用户登录名(默认:当前用户)",
"flag.user.login": "用户登录名",
"flag.page": "页码",
"flag.limit": "每页条目数",
"flag.description": "描述",
"flag.dry_run": "预览请求,不实际创建",
"flag.comment.body": "评论内容",
"flag.repo.category": "筛选manage/mirror/sync/fork/all默认manage",
"flag.repo.name": "仓库名称",
"flag.repo.description": "仓库描述",
"flag.repo.private": "设为私有仓库true/false",
"flag.issue.state": "按状态筛选open、closed、all",
"flag.issue.title": "议题标题",
"flag.issue.body": "议题描述",
"flag.issue.assignee": "负责人登录名",
"flag.issue.milestone": "里程碑 ID",
"flag.issue.label": "标签 ID",
"flag.issue.number": "议题编号(网页 URL 中显示的编号)",
"flag.issue.new_title": "新标题",
"flag.issue.new_body": "新描述",
"flag.issue.new_state": "新状态open、closed 或数字 status_id",
"flag.issue.label_filter": "按已有标签筛选",
"flag.issue.older_than_days": "只包含至少这么多天未活动的议题",
"flag.issue.batch_list.limit": "最多返回的议题数,上限 100",
"flag.issue.batch_close.state": "关闭前按议题状态筛选",
"flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7",
"flag.issue.batch_process.limit": "最多处理的议题数,上限 100",
"flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。",
"flag.issue.batch.reason": "批量结果中显示的可选原因",
"flag.issue.batch_label.state": "按议题状态筛选",
"flag.issue.add_label": "要添加到每个匹配议题的标签",
"flag.pr.state": "筛选open、merged、closed",
"flag.pr.title": "PR 标题",
"flag.pr.body": "PR 描述",
"flag.pr.head": "源分支",
"flag.pr.base": "目标分支",
"flag.pr.id": "PR 编号",
"flag.pr.merge_method": "合并方式merge、rebase、squash",
"flag.pr.version_id": "补丁集版本 ID",
"flag.pr.file": "按文件路径筛选 diff",
"flag.pr.review_status_filter": "按评审状态筛选common、approved、rejected",
"flag.pr.review_status": "评审状态common、approved、rejected",
"flag.pr.review_content": "评审内容",
"flag.pr.review_commit": "关联评审的 Commit SHA",
"flag.branch.name": "分支名称",
"flag.branch.from": "源分支或 Commit",
"flag.release.tag": "标签名称",
"flag.release.name": "发布名称",
"flag.release.body": "发布说明",
"flag.release.target": "目标分支",
"flag.release.prerelease": "标记为预发布true/false",
"flag.release.id_or_tag": "发布 ID 或标签",
"flag.release.id": "发布 ID",
"flag.org.id_or_login": "组织 ID 或登录名",
"flag.org.id": "组织 ID",
"flag.org.name": "组织名称",
"flag.search.keyword": "搜索关键词",
"flag.ci.build": "构建编号",
"flag.ci.stage": "阶段编号",
"flag.ci.step": "步骤编号",
"flag.webhook.id": "Webhook ID",
"flag.webhook.url": "Webhook 目标 URL",
"flag.webhook.events": "逗号分隔的事件例如push,issues_only",
"flag.webhook.type": "Webhook 类型gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot",
"flag.webhook.content_type": "Payload 内容类型json 或 form",
"flag.webhook.http_method": "HTTP 方法POST 或 GET",
"flag.webhook.secret": "Webhook 密钥",
"flag.webhook.secret_update": "Webhook 密钥。如果服务端不返回已有密钥,请再次传入。",
"flag.webhook.branch_filter": "用于 push/create/delete 事件的分支 glob 筛选",
"flag.webhook.active": "Webhook 是否启用true 或 false",
"error.unsupported_language": "不支持的语言:{lang}",
"output.version": "gitlink-cli {version}"
}

10
internal/i18n/options.go Normal file
View File

@ -0,0 +1,10 @@
package i18n
const defaultFallbackLocale = "en-US"
// Options controls Translator construction.
type Options struct {
Locale string
FallbackLocale string
Loader Loader
}

66
internal/i18n/resolver.go Normal file
View File

@ -0,0 +1,66 @@
package i18n
import (
"os"
"strings"
)
// ResolveOptions contains locale inputs ordered by caller intent.
type ResolveOptions struct {
ExplicitLang string
Env map[string]string
ConfigLang string
}
// ResolveLocale resolves a locale using CLI flag, env, config, system env, fallback.
func ResolveLocale(opts ResolveOptions, available []string) string {
candidates := []string{
opts.ExplicitLang,
envValue(opts.Env, "GITLINK_LANG"),
opts.ConfigLang,
envValue(opts.Env, "LC_ALL"),
envValue(opts.Env, "LANG"),
}
for _, candidate := range candidates {
if strings.TrimSpace(candidate) == "" {
continue
}
return MatchLocale(candidate, available, defaultFallbackLocale)
}
return MatchLocale(defaultFallbackLocale, available, defaultFallbackLocale)
}
// 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]
}

View File

@ -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)
}
}

View File

@ -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|table)\\.[a-z0-9_.-]+$"
}
}

41
internal/i18n/template.go Normal file
View File

@ -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
}

View File

@ -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])
}
}
}

View File

@ -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 call sites that have not been
// wired for dependency injection yet.
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)
}

View File

@ -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)
}
}

104
internal/i18n/validate.go Normal file
View File

@ -0,0 +1,104 @@
package i18n
import (
"fmt"
"reflect"
"regexp"
"sort"
"strings"
)
var keyPattern = regexp.MustCompile(`^(cmd|flag|error|output|prompt|table)\.[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
}

View File

@ -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)
}
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -14,6 +14,7 @@ import (
"time"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
@ -60,68 +61,50 @@ type batchIssueOptions struct {
Action string
}
func newBatchListShortcut() *common.Shortcut {
func newBatchListShortcut(tr *i18n.Translator) *common.Shortcut {
return &common.Shortcut{
Name: "batch-list",
Description: "List issue batch maintenance candidates without changing remote data",
Long: `List issue batch maintenance candidates without changing remote data.
Examples:
gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table
gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json`,
Description: tr.T("cmd.issue.batch_list.short"),
Long: tr.T("cmd.issue.batch_list.long"),
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by issue state: open, closed, all", Default: "open"},
{Name: "label", Usage: "Filter by existing label"},
{Name: "older-than-days", Usage: "Only include issues inactive for at least this many days"},
{Name: "limit", Short: "l", Usage: "Maximum issues to return, capped at 100", Default: "50"},
{Name: "state", Short: "s", Usage: tr.T("flag.issue.state"), Default: "open"},
{Name: "label", Usage: tr.T("flag.issue.label_filter")},
{Name: "older-than-days", Usage: tr.T("flag.issue.older_than_days")},
{Name: "limit", Short: "l", Usage: tr.T("flag.issue.batch_list.limit"), Default: "50"},
},
Run: runBatchList,
}
}
func newBatchCloseShortcut() *common.Shortcut {
func newBatchCloseShortcut(tr *i18n.Translator) *common.Shortcut {
return &common.Shortcut{
Name: "batch-close",
Description: "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.",
Long: `Close filtered issues in bulk.
This command defaults to dry-run mode and only prints matching issues.
Pass --yes to execute remote close operations. Use restrictive filters and a small limit.
Examples:
gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20
gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes`,
Description: tr.T("cmd.issue.batch_close.short"),
Long: tr.T("cmd.issue.batch_close.long"),
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by issue state before closing", Default: "open"},
{Name: "label", Usage: "Filter by existing label"},
{Name: "older-than-days", Usage: "Required safety filter; must be at least 7"},
{Name: "limit", Short: "l", Usage: "Maximum issues to process, capped at 100", Default: "20"},
{Name: "yes", Usage: "Execute remote close operations. Without this flag the command is dry-run only.", Bool: true, Default: "false"},
{Name: "reason", Usage: "Optional reason shown in the batch result"},
{Name: "state", Short: "s", Usage: tr.T("flag.issue.batch_close.state"), Default: "open"},
{Name: "label", Usage: tr.T("flag.issue.label_filter")},
{Name: "older-than-days", Usage: tr.T("flag.issue.batch_close.older_than_days")},
{Name: "limit", Short: "l", Usage: tr.T("flag.issue.batch_process.limit"), Default: "20"},
{Name: "yes", Usage: tr.T("flag.issue.batch.yes"), Bool: true, Default: "false"},
{Name: "reason", Usage: tr.T("flag.issue.batch.reason")},
},
Run: runBatchClose,
}
}
func newBatchLabelShortcut() *common.Shortcut {
func newBatchLabelShortcut(tr *i18n.Translator) *common.Shortcut {
return &common.Shortcut{
Name: "batch-label",
Description: "Add a label to filtered issues in bulk. Defaults to dry-run; pass --yes to execute.",
Long: `Add a label to filtered issues in bulk.
This command defaults to dry-run mode and only prints matching issues.
Pass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.
Examples:
gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50
gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes`,
Description: tr.T("cmd.issue.batch_label.short"),
Long: tr.T("cmd.issue.batch_label.long"),
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by issue state", Default: "open"},
{Name: "label", Usage: "Filter by existing label"},
{Name: "add-label", Usage: "Label to add to each matching issue"},
{Name: "older-than-days", Usage: "Only include issues inactive for at least this many days"},
{Name: "limit", Short: "l", Usage: "Maximum issues to process, capped at 100", Default: "50"},
{Name: "yes", Usage: "Execute remote label operations. Without this flag the command is dry-run only.", Bool: true, Default: "false"},
{Name: "state", Short: "s", Usage: tr.T("flag.issue.batch_label.state"), Default: "open"},
{Name: "label", Usage: tr.T("flag.issue.label_filter")},
{Name: "add-label", Usage: tr.T("flag.issue.add_label")},
{Name: "older-than-days", Usage: tr.T("flag.issue.older_than_days")},
{Name: "limit", Short: "l", Usage: tr.T("flag.issue.batch_process.limit"), Default: "50"},
{Name: "yes", Usage: tr.T("flag.issue.batch.yes"), Bool: true, Default: "false"},
},
Run: runBatchLabel,
}

View File

@ -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"
)
@ -20,18 +21,19 @@ type existingIssue struct {
Description string
}
func Shortcuts() []*common.Shortcut {
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
return []*common.Shortcut{
newBatchListShortcut(),
newBatchCloseShortcut(),
newBatchLabelShortcut(),
newBatchListShortcut(tr),
newBatchCloseShortcut(tr),
newBatchLabelShortcut(tr),
{
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 {
@ -53,13 +55,13 @@ 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")},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -93,9 +95,9 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "view",
Description: "View issue details",
Description: tr.T("cmd.issue.view.short"),
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -114,9 +116,9 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "close",
Description: "Close an issue",
Description: tr.T("cmd.issue.close.short"),
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -145,12 +147,12 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "update",
Description: "Update an issue",
Description: tr.T("cmd.issue.update.short"),
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"},
{Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), Required: true},
{Name: "title", Short: "t", Usage: tr.T("flag.issue.new_title")},
{Name: "body", Short: "b", Usage: tr.T("flag.issue.new_body")},
{Name: "state", Short: "s", Usage: tr.T("flag.issue.new_state")},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -198,10 +200,10 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "comment",
Description: "Add a comment to an issue",
Description: tr.T("cmd.issue.comment.short"),
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},
{Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), 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 {
@ -228,6 +230,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()
}
// 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.

View File

@ -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()
}

View File

@ -4,19 +4,21 @@ 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 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 {
@ -37,12 +39,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 {
@ -71,9 +73,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 {
@ -89,10 +91,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 {
@ -115,9 +117,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 {
@ -133,9 +135,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 {
@ -151,9 +153,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 {
@ -169,9 +171,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 {
@ -190,11 +192,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 {
@ -227,10 +229,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 {
@ -256,13 +258,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 {
@ -308,10 +310,10 @@ func Shortcuts() []*common.Shortcut {
},
{
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 {
@ -342,6 +344,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)
}

View File

@ -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"
@ -17,31 +18,31 @@ import (
)
// RegisterAll mounts all shortcut groups onto the root command.
func RegisterAll(root *cobra.Command) {
func RegisterAll(root *cobra.Command, tr *i18n.Translator) {
groups := map[string][]*common.Shortcut{
"repo": repo.Shortcuts(),
"issue": issue.Shortcuts(),
"pr": pr.Shortcuts(),
"release": release.Shortcuts(),
"branch": branch.Shortcuts(),
"org": org.Shortcuts(),
"user": user.Shortcuts(),
"search": search.Shortcuts(),
"ci": ci.Shortcuts(),
"webhook": webhook.Shortcuts(),
"repo": repo.Shortcuts(tr),
"issue": issue.Shortcuts(tr),
"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),
"webhook": webhook.Shortcuts(tr),
}
descriptions := map[string]string{
"repo": "Repository operations",
"issue": "Issue operations",
"pr": "Pull request operations",
"release": "Release operations",
"branch": "Branch operations",
"org": "Organization operations",
"user": "User operations",
"search": "Search operations",
"ci": "CI/CD operations",
"webhook": "Webhook operations",
"repo": tr.T("cmd.repo.short"),
"issue": tr.T("cmd.issue.short"),
"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"),
"webhook": tr.T("cmd.webhook.short"),
}
for name, shortcuts := range groups {

View File

@ -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()
}

View File

@ -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
@ -54,11 +56,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")
@ -96,7 +98,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
@ -110,7 +112,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
@ -124,3 +126,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()
}

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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