Merge remote-tracking branch 'upstream/master'

This commit is contained in:
林迪文 2026-06-03 08:27:51 +08:00
commit 64ccd0466a
51 changed files with 3688 additions and 462 deletions

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

@ -0,0 +1,30 @@
name: Test
on:
pull_request:
push:
branches:
- main
- master
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Validate i18n messages
run: go run ./internal/i18n/cmd/check
- name: Scan i18n key references
run: go run ./internal/i18n/cmd/check --scan-code
- name: Run Go tests
run: go test ./...

View File

@ -247,9 +247,15 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus
# Create an issue
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..."
# Create an issue with metadata
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" --priority-id 3 --tag-ids 4,5 --assigner-ids 7
# View an issue
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
# Update issue metadata
gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15
# Close an issue
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
@ -267,8 +273,22 @@ gitlink-cli issue +assigners --owner Gitlink --repo forgeplus
# List issue authors
gitlink-cli issue +authors --owner Gitlink --repo forgeplus
# List issue priorities
gitlink-cli issue +priorities --owner Gitlink --repo forgeplus
# List issue tags
gitlink-cli issue +tags --owner Gitlink --repo forgeplus --only-name
# List issue statuses
gitlink-cli issue +statuses --owner Gitlink --repo forgeplus
```
`issue +view`, `issue +update`, `issue +close`, and `issue +comment` prefer
`--number` / `-n` for the issue number shown in the web URL. `--id` / `-i`
is accepted as a compatibility alias for the same web issue number, not the
global database ID.
### Label Management
```bash

View File

@ -258,9 +258,15 @@ gitlink-cli issue +list --owner Gitlink --repo forgeplus
# 创建 Issue
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..."
# 创建带元数据的 Issue
gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" --priority-id 3 --tag-ids 4,5 --assigner-ids 7
# 查看 Issue
gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
# 更新 Issue 元数据
gitlink-cli issue +update --owner Gitlink --repo forgeplus --number 123 --priority-id 4 --branch bugfix/login --due-date 2026-06-15
# 关闭 Issue
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
@ -278,8 +284,21 @@ gitlink-cli issue +assigners --owner Gitlink --repo forgeplus
# 列出 Issue 发布人
gitlink-cli issue +authors --owner Gitlink --repo forgeplus
# 列出 Issue 优先级
gitlink-cli issue +priorities --owner Gitlink --repo forgeplus
# 列出 Issue 标签
gitlink-cli issue +tags --owner Gitlink --repo forgeplus --only-name
# 列出 Issue 状态
gitlink-cli issue +statuses --owner Gitlink --repo forgeplus
```
`issue +view`、`issue +update`、`issue +close` 和 `issue +comment` 推荐使用
`--number` / `-n` 传网页 URL 中的 Issue 编号。`--id` / `-i` 是同一网页 Issue
编号的兼容别名,不是数据库内部 ID。
### 标签管理
```bash

View File

@ -13,14 +13,19 @@ import (
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
func NewAPICmd() *cobra.Command {
func NewAPICmd(translators ...*i18n.Translator) *cobra.Command {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
apiCmd := &cobra.Command{
Use: "api <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":"..."}'
@ -29,11 +34,11 @@ func NewAPICmd() *cobra.Command {
RunE: runAPI,
}
apiCmd.Flags().String("body", "", "Request body (JSON string)")
apiCmd.Flags().String("body-file", "", "Read request body JSON from a file")
apiCmd.Flags().Bool("body-stdin", false, "Read request body JSON from stdin")
apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)")
apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)")
apiCmd.Flags().String("body", "", tr.T("flag.api.body"))
apiCmd.Flags().String("body-file", "", tr.T("flag.api.body_file"))
apiCmd.Flags().Bool("body-stdin", false, tr.T("flag.api.body_stdin"))
apiCmd.Flags().String("query", "", tr.T("flag.api.query"))
apiCmd.Flags().StringSlice("header", nil, tr.T("flag.api.header"))
return apiCmd
}

View File

@ -2,141 +2,182 @@ package auth
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strings"
"syscall"
"github.com/spf13/cobra"
"golang.org/x/term"
internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
)
const envTokenVar = "GITLINK_TOKEN"
func NewAuthCmd() *cobra.Command {
var (
storeToken = internalAuth.StoreToken
loadToken = internalAuth.LoadToken
)
func NewAuthCmd(translators ...*i18n.Translator) *cobra.Command {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
cmd := &cobra.Command{
Use: "auth",
Short: "Authentication commands",
Short: tr.T("cmd.auth.short"),
}
cmd.AddCommand(newLoginCmd())
cmd.AddCommand(newLogoutCmd())
cmd.AddCommand(newStatusCmd())
cmd.AddCommand(newLoginCmd(tr))
cmd.AddCommand(newLogoutCmd(tr))
cmd.AddCommand(newStatusCmd(tr))
return cmd
}
func newLoginCmd() *cobra.Command {
func newLoginCmd(tr *i18n.Translator) *cobra.Command {
var tokenMode bool
cmd := &cobra.Command{
Use: "login",
Short: "Login to GitLink",
Short: tr.T("cmd.auth.login.short"),
RunE: func(cmd *cobra.Command, args []string) error {
if tokenMode {
return loginWithToken()
return loginWithToken(cmd.InOrStdin(), cmd.OutOrStdout(), tr)
}
return loginWithPassword()
return loginWithPassword(cmd.InOrStdin(), cmd.OutOrStdout(), tr)
},
}
cmd.Flags().BoolVar(&tokenMode, "token", false, "Login by pasting an existing token")
cmd.Flags().BoolVar(&tokenMode, "token", false, tr.T("flag.auth.token"))
return cmd
}
func loginWithPassword() error {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Username/Email/Phone: ")
func loginWithPassword(in io.Reader, out io.Writer, tr *i18n.Translator) error {
reader := bufio.NewReader(in)
if _, err := fmt.Fprint(out, tr.T("prompt.auth.username")); err != nil {
return err
}
username, _ := reader.ReadString('\n')
username = strings.TrimSpace(username)
fmt.Print("Password: ")
passwordBytes, err := term.ReadPassword(syscall.Stdin)
if _, err := fmt.Fprint(out, tr.T("prompt.auth.password")); err != nil {
return err
}
passwordBytes, err := readPassword(in, reader)
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
}
fmt.Println()
if _, err := fmt.Fprintln(out); err != nil {
return err
}
password := string(passwordBytes)
result, err := internalAuth.Login(username, password)
if err != nil {
return fmt.Errorf("login failed: %w", err)
return errors.New(tr.Tf("error.auth.login_failed", i18n.Args{"message": err.Error()}))
}
fmt.Printf("✓ Logged in as %s\n", result.Login)
return nil
_, err = fmt.Fprintln(out, tr.Tf("success.auth.logged_in_as", i18n.Args{"login": result.Login}))
return err
}
func loginWithToken() error {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Paste your token: ")
func readPassword(in io.Reader, reader *bufio.Reader) ([]byte, error) {
if file, ok := in.(*os.File); ok {
fd := int(file.Fd())
if term.IsTerminal(fd) {
return term.ReadPassword(fd)
}
}
password, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
return nil, err
}
return []byte(strings.TrimRight(password, "\r\n")), nil
}
func loginWithToken(in io.Reader, out io.Writer, tr *i18n.Translator) error {
reader := bufio.NewReader(in)
if _, err := fmt.Fprint(out, tr.T("prompt.auth.token")); err != nil {
return err
}
token, _ := reader.ReadString('\n')
token = strings.TrimSpace(token)
if token == "" {
return fmt.Errorf("token cannot be empty")
return errors.New(tr.T("error.auth.token_empty"))
}
if err := internalAuth.StoreToken(token); err != nil {
return fmt.Errorf("failed to store token: %w", err)
if err := storeToken(token); err != nil {
return errors.New(tr.Tf("error.auth.store_token_failed", i18n.Args{"message": err.Error()}))
}
fmt.Println("✓ Token saved")
return nil
_, err := fmt.Fprintln(out, tr.T("success.auth.token_saved"))
return err
}
func newLogoutCmd() *cobra.Command {
func newLogoutCmd(tr *i18n.Translator) *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "Logout from GitLink",
Short: tr.T("cmd.auth.logout.short"),
RunE: func(cmd *cobra.Command, args []string) error {
if err := internalAuth.DeleteToken(); err != nil {
return fmt.Errorf("failed to delete token: %w", err)
return errors.New(tr.Tf("error.auth.delete_token_failed", i18n.Args{"message": err.Error()}))
}
fmt.Println("✓ Logged out")
return nil
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.T("success.auth.logged_out"))
return err
},
}
}
func newStatusCmd() *cobra.Command {
func newStatusCmd(tr *i18n.Translator) *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show authentication status",
Short: tr.T("cmd.auth.status.short"),
RunE: func(cmd *cobra.Command, args []string) error {
out := cmd.OutOrStdout()
// Check env var token first
if envToken := os.Getenv(envTokenVar); envToken != "" {
fmt.Printf("✓ Logged in via %s environment variable\n", envTokenVar)
if _, err := fmt.Fprintln(out, tr.Tf("success.auth.logged_in_via_env", i18n.Args{"env": envTokenVar})); err != nil {
return err
}
}
token, err := internalAuth.LoadToken()
token, err := loadToken()
if err != nil || token == "" {
if os.Getenv(envTokenVar) == "" {
fmt.Println("✗ Not logged in")
fmt.Println(" Run: gitlink-cli auth login")
fmt.Printf(" Or set %s environment variable\n", envTokenVar)
if _, err := fmt.Fprintln(out, tr.T("warning.auth.not_logged_in")); err != nil {
return err
}
if _, err := fmt.Fprintln(out, tr.T("output.auth.login_hint")); err != nil {
return err
}
if _, err := fmt.Fprintln(out, tr.Tf("output.auth.env_hint", i18n.Args{"env": envTokenVar})); err != nil {
return err
}
}
return nil
}
user, err := internalAuth.GetCurrentUser()
if err != nil {
fmt.Printf("✓ Token stored (but cannot verify: %v)\n", err)
return nil
_, err := fmt.Fprintln(out, tr.Tf("warning.auth.token_unverified", i18n.Args{"message": err.Error()}))
return err
}
login, _ := user["login"].(string)
name, _ := user["name"].(string)
if login != "" {
fmt.Printf("✓ Logged in as %s", login)
text := tr.Tf("success.auth.logged_in_as", i18n.Args{"login": login})
if name != "" {
fmt.Printf(" (%s)", name)
text = fmt.Sprintf("%s (%s)", text, name)
}
fmt.Println()
} else {
fmt.Println("✓ Token stored (user info unavailable)")
_, err := fmt.Fprintln(out, text)
return err
}
return nil
_, err = fmt.Fprintln(out, tr.T("warning.auth.user_unavailable"))
return err
},
}
}

View File

@ -1,46 +1,68 @@
package cmd
import (
"bytes"
"strings"
"testing"
)
func TestInit(t *testing.T) {
// init() runs automatically when package is imported.
// Verify rootCmd has the expected settings.
if rootCmd.Use != "gitlink-cli" {
t.Fatalf("Use = %q", rootCmd.Use)
func TestNewRootCmdDefaults(t *testing.T) {
root, err := NewRootCmd(RootOptions{Version: "test"}, nil)
if err != nil {
t.Fatal(err)
}
if rootCmd.SilenceUsage != true {
if root.Use != "gitlink-cli" {
t.Fatalf("Use = %q", root.Use)
}
if !root.SilenceUsage {
t.Fatal("expected SilenceUsage=true")
}
}
func TestExecute(t *testing.T) {
rootCmd.SetArgs([]string{"--help"})
if err := Execute(); err != nil {
t.Fatalf("Execute error: %v", err)
func TestRootHelp(t *testing.T) {
root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--help"}}, nil)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatalf("help command error: %v", err)
}
}
func TestVersionCmd(t *testing.T) {
rootCmd.SetArgs([]string{"version"})
if err := rootCmd.Execute(); err != nil {
root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"version"}}, nil)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatalf("version command error: %v", err)
}
if got := strings.TrimSpace(out.String()); got != "gitlink-cli test" {
t.Fatalf("version output = %q", got)
}
}
func TestRootCmdHasSubcommands(t *testing.T) {
root, err := NewRootCmd(RootOptions{Version: "test"}, nil)
if err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, sub := range rootCmd.Commands() {
for _, sub := range root.Commands() {
names[sub.Use] = true
}
// At minimum, these core commands should exist
for _, want := range []string{"auth", "config", "version"} {
if !names[want] {
t.Fatalf("missing subcommand: %s", want)
}
}
if len(rootCmd.Commands()) < 4 {
t.Fatalf("expected at least 4 subcommands, got %d", len(rootCmd.Commands()))
if len(root.Commands()) < 4 {
t.Fatalf("expected at least 4 subcommands, got %d", len(root.Commands()))
}
}

View File

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

View File

@ -1,59 +1,80 @@
package config
import (
"errors"
"fmt"
"github.com/spf13/cobra"
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
)
func NewConfigCmd() *cobra.Command {
func NewConfigCmd(translators ...*i18n.Translator) *cobra.Command {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
cmd := &cobra.Command{
Use: "config",
Short: "Manage gitlink-cli configuration",
Short: tr.T("cmd.config.short"),
}
cmd.AddCommand(newInitCmd())
cmd.AddCommand(newSetCmd())
cmd.AddCommand(newGetCmd())
cmd.AddCommand(newListCmd())
cmd.AddCommand(newInitCmd(tr))
cmd.AddCommand(newSetCmd(tr))
cmd.AddCommand(newGetCmd(tr))
cmd.AddCommand(newListCmd(tr))
return cmd
}
func newInitCmd() *cobra.Command {
func newInitCmd(translators ...*i18n.Translator) *cobra.Command {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
return &cobra.Command{
Use: "init",
Short: "Initialize configuration file",
Short: tr.T("cmd.config.init.short"),
RunE: func(cmd *cobra.Command, args []string) error {
cfg := internalConfig.DefaultConfig()
if err := internalConfig.Save(cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
return errors.New(tr.Tf("error.config.save_failed", i18n.Args{"message": err.Error()}))
}
fmt.Printf("✓ Config initialized at %s\n", internalConfig.ConfigPath())
return nil
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.initialized", i18n.Args{"path": internalConfig.ConfigPath()}))
return err
},
}
}
func newSetCmd() *cobra.Command {
func newSetCmd(translators ...*i18n.Translator) *cobra.Command {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
return &cobra.Command{
Use: "set <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 {
return err
}
fmt.Printf("✓ %s = %s\n", args[0], args[1])
return nil
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.set", i18n.Args{
"key": args[0],
"value": args[1],
}))
return err
},
}
}
func newGetCmd() *cobra.Command {
func newGetCmd(translators ...*i18n.Translator) *cobra.Command {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
return &cobra.Command{
Use: "get <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])
@ -61,30 +82,49 @@ func newGetCmd() *cobra.Command {
return err
}
if val == "" {
fmt.Printf("%s: (not set)\n", args[0])
} else {
fmt.Printf("%s: %s\n", args[0], val)
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], tr.T("output.config.not_set"))
return err
}
return nil
_, err = fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], val)
return err
},
}
}
func newListCmd() *cobra.Command {
func newListCmd(translators ...*i18n.Translator) *cobra.Command {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
return &cobra.Command{
Use: "list",
Short: "List all configuration values",
Short: tr.T("cmd.config.list.short"),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := internalConfig.Load()
if err != nil {
return err
}
fmt.Printf("base_url: %s\n", cfg.BaseURL)
fmt.Printf("default_format: %s\n", cfg.Format)
fmt.Printf("editor: %s\n", cfg.Editor)
fmt.Printf("pager: %s\n", cfg.Pager)
fmt.Printf("\nConfig file: %s\n", internalConfig.ConfigPath())
return nil
out := cmd.OutOrStdout()
if _, err := fmt.Fprintf(out, "base_url: %s\n", cfg.BaseURL); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "default_format: %s\n", cfg.Format); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "editor: %s\n", cfg.Editor); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "pager: %s\n", cfg.Pager); err != nil {
return err
}
if _, err := fmt.Fprintf(out, "lang: %s\n", cfg.Lang); err != nil {
return err
}
if _, err := fmt.Fprintln(out); err != nil {
return err
}
_, err = fmt.Fprintln(out, tr.Tf("output.config.file", i18n.Args{"path": internalConfig.ConfigPath()}))
return err
},
}
}

View File

@ -1,6 +1,7 @@
package cmd
import (
"errors"
"fmt"
"os"
@ -10,45 +11,117 @@ import (
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
configCmd "github.com/gitlink-org/gitlink-cli/cmd/config"
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts"
)
var Version = "dev"
var rootCmd = &cobra.Command{
Use: "gitlink-cli",
Short: "GitLink CLI — command-line tool for gitlink.org.cn",
Long: `gitlink-cli is a command-line interface for the GitLink (确实开源) platform, providing repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows.`,
SilenceUsage: true,
SilenceErrors: true,
type RootOptions struct {
Version string
Args []string
Env map[string]string
ConfigLang string
}
func init() {
rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "Repository owner (auto-detected from git remote)")
rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "Repository name (auto-detected from git remote)")
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "Output format: json, table, yaml (default: table)")
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output")
func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) {
if tr == nil {
var err error
tr, err = newTranslator(opts.Args, opts.Env, opts.ConfigLang)
if err != nil {
return nil, err
}
}
rootCmd.AddCommand(authCmd.NewAuthCmd())
rootCmd.AddCommand(apiCmd.NewAPICmd())
rootCmd.AddCommand(configCmd.NewConfigCmd())
rootCmd.AddCommand(versionCmd)
version := opts.Version
if version == "" {
version = Version
}
shortcuts.RegisterAll(rootCmd)
rootCmd := &cobra.Command{
Use: "gitlink-cli",
Short: tr.T("cmd.root.short"),
Long: tr.T("cmd.root.long"),
SilenceUsage: true,
SilenceErrors: true,
}
rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", tr.T("flag.owner"))
rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", tr.T("flag.repo"))
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", tr.T("flag.format"))
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, tr.T("flag.debug"))
rootCmd.PersistentFlags().StringVar(&cmdutil.Lang, "lang", "", tr.T("flag.lang"))
rootCmd.AddCommand(authCmd.NewAuthCmd(tr))
rootCmd.AddCommand(apiCmd.NewAPICmd(tr))
rootCmd.AddCommand(configCmd.NewConfigCmd(tr))
rootCmd.AddCommand(newVersionCmd(version, tr))
shortcuts.RegisterAll(rootCmd, tr)
if opts.Args != nil {
rootCmd.SetArgs(opts.Args)
}
return rootCmd, nil
}
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("gitlink-cli %s\n", Version)
},
func newVersionCmd(version string, tr *i18n.Translator) *cobra.Command {
return &cobra.Command{
Use: "version",
Short: tr.T("cmd.version.short"),
RunE: func(cmd *cobra.Command, args []string) error {
_, err := fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.version", i18n.Args{"version": version}))
return err
},
}
}
func Execute() error {
args := os.Args[1:]
rootCmd, err := NewRootCmd(RootOptions{
Version: Version,
Args: args,
}, nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return err
}
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
return err
}
return nil
}
func newTranslator(args []string, env map[string]string, configLang string) (*i18n.Translator, error) {
available, err := i18n.AvailableLocales()
if err != nil {
return nil, err
}
if env == nil {
env = i18n.EnvMap()
}
if configLang == "" {
configLang = loadConfigLangBestEffort()
}
resolved := i18n.ResolveLocaleDetailed(i18n.ResolveOptions{
ExplicitLang: i18n.PreScanLang(args),
Env: env,
ConfigLang: configLang,
}, available)
if !resolved.Supported && (resolved.Source == "flag" || resolved.Source == "env") {
tr := i18n.Default()
return nil, errors.New(tr.Tf("error.unsupported_language", i18n.Args{"lang": resolved.Requested}))
}
return i18n.New(i18n.Options{Locale: resolved.Locale})
}
func loadConfigLangBestEffort() string {
cfg, err := internalConfig.Load()
if err != nil {
return ""
}
return cfg.Lang
}

310
cmd/root_test.go Normal file
View File

@ -0,0 +1,310 @@
package cmd
import (
"bytes"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
)
func TestRootHelpUsesSelectedLocale(t *testing.T) {
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
if err != nil {
t.Fatal(err)
}
root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--help"}}, tr)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
help := out.String()
if !strings.Contains(help, "用于管理 GitLink 上的仓库") {
t.Fatalf("expected Chinese root long help, got:\n%s", help)
}
if !strings.Contains(help, "仓库操作") {
t.Fatalf("expected Chinese shortcut group help, got:\n%s", help)
}
if !strings.Contains(help, "认证命令") || !strings.Contains(help, "管理 gitlink-cli 配置") {
t.Fatalf("expected Chinese core command help, got:\n%s", help)
}
if !strings.Contains(help, "--lang") || !strings.Contains(help, "显示语言") {
t.Fatalf("expected localized lang flag help, got:\n%s", help)
}
}
func TestRootHelpUsesExplicitLang(t *testing.T) {
root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--lang", "zh-CN", "--help"}, Env: map[string]string{}}, nil)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
help := out.String()
for _, want := range []string{"用于管理 GitLink", "显示语言", "仓库"} {
if !strings.Contains(help, want) {
t.Fatalf("expected %q in help, got:\n%s", want, help)
}
}
}
func TestRootHelpUsesEnvLang(t *testing.T) {
root, err := NewRootCmd(RootOptions{
Version: "test",
Args: []string{"repo", "--help"},
Env: map[string]string{"GITLINK_LANG": "zh-CN"},
}, nil)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
help := out.String()
for _, want := range []string{"仓库操作", "仓库所有者", "仓库名称"} {
if !strings.Contains(help, want) {
t.Fatalf("expected %q in help, got:\n%s", want, help)
}
}
}
func TestExplicitLangOverridesConfigLang(t *testing.T) {
root, err := NewRootCmd(RootOptions{
Version: "test",
Args: []string{"--lang", "en-US", "--help"},
Env: map[string]string{},
ConfigLang: "zh-CN",
}, nil)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
help := out.String()
if !strings.Contains(help, "Repository operations") {
t.Fatalf("expected English help, got:\n%s", help)
}
if strings.Contains(help, "仓库操作") {
t.Fatalf("expected explicit en-US to override config zh-CN, got:\n%s", help)
}
}
func TestUnsupportedExplicitLangReturnsError(t *testing.T) {
_, err := NewRootCmd(RootOptions{
Version: "test",
Args: []string{"--lang", "fr-FR", "--help"},
Env: map[string]string{},
}, nil)
if err == nil {
t.Fatal("expected unsupported language error")
}
if !strings.Contains(err.Error(), "unsupported language") {
t.Fatalf("expected unsupported language error, got %q", err.Error())
}
}
func TestRequireArgUsesLocalizedError(t *testing.T) {
root, err := NewRootCmd(RootOptions{
Version: "test",
Args: []string{"--lang", "zh-CN", "repo", "+create"},
Env: map[string]string{},
}, nil)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
err = root.Execute()
if err == nil {
t.Fatal("expected missing required flag error")
}
if !strings.Contains(err.Error(), "缺少必需参数") {
t.Fatalf("expected localized missing flag error, got %q", err.Error())
}
}
func TestCoreCommandHelpUsesSelectedLocale(t *testing.T) {
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
if err != nil {
t.Fatal(err)
}
cases := []struct {
args []string
want []string
}{
{
args: []string{"api", "--help"},
want: []string{"向 GitLink API 发送任意 HTTP 请求", "--body", "请求体JSON 字符串)"},
},
{
args: []string{"auth", "login", "--help"},
want: []string{"登录 GitLink", "--token", "通过粘贴已有 Token 登录"},
},
{
args: []string{"config", "--help"},
want: []string{"管理 gitlink-cli 配置", "初始化配置文件", "列出所有配置项"},
},
}
for _, tc := range cases {
root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatalf("%v: %v", tc.args, err)
}
help := out.String()
for _, want := range tc.want {
if !strings.Contains(help, want) {
t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help)
}
}
}
}
func TestShortcutHelpUsesSelectedLocale(t *testing.T) {
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
if err != nil {
t.Fatal(err)
}
cases := []struct {
args []string
want []string
}{
{
args: []string{"repo", "+create", "--help"},
want: []string{"创建新仓库", "--name", "仓库名称", "--private", "设为私有仓库"},
},
{
args: []string{"pr", "+review", "--help"},
want: []string{"创建拉取请求评审", "--content", "评审内容", "--dry-run"},
},
}
for _, tc := range cases {
root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatalf("%v: %v", tc.args, err)
}
help := out.String()
for _, want := range tc.want {
if !strings.Contains(help, want) {
t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help)
}
}
}
}
func TestRemainingShortcutHelpUsesSelectedLocale(t *testing.T) {
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
if err != nil {
t.Fatal(err)
}
cases := []struct {
args []string
want []string
}{
{
args: []string{"branch", "+create", "--help"},
want: []string{"创建分支", "--from", "源分支或 Commit"},
},
{
args: []string{"release", "+create", "--help"},
want: []string{"创建发布", "--prerelease", "标记为预发布"},
},
{
args: []string{"webhook", "+create", "--help"},
want: []string{"创建仓库 Webhook", "--events", "逗号分隔的事件"},
},
{
args: []string{"ci", "+logs", "--help"},
want: []string{"查看构建日志", "--build", "构建编号"},
},
}
for _, tc := range cases {
root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatalf("%v: %v", tc.args, err)
}
help := out.String()
for _, want := range tc.want {
if !strings.Contains(help, want) {
t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help)
}
}
}
}
func TestVersionUsesInjectedVersion(t *testing.T) {
tr, err := i18n.New(i18n.Options{Locale: "en-US"})
if err != nil {
t.Fatal(err)
}
root, err := NewRootCmd(RootOptions{Version: "1.2.3", Args: []string{"version"}}, tr)
if err != nil {
t.Fatal(err)
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if got := strings.TrimSpace(out.String()); got != "gitlink-cli 1.2.3" {
t.Fatalf("version output = %q", got)
}
}

View File

@ -0,0 +1,24 @@
# Issue ID Alias
## Summary
`issue +view`, `issue +close`, `issue +update`, and `issue +comment` now accept
`--id` / `-i` as a compatibility alias for `--number` / `-n`.
The alias uses the same project-level issue number shown in the web URL, for
example `issues/123`. It is not the global database ID.
`--number` remains the preferred flag and takes precedence when both flags are
provided.
## Examples
```bash
gitlink-cli issue +view --owner Gitlink --repo forgeplus --id 123
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 --body "Fixed"
```
## Submitter
Wang Yue

View File

@ -0,0 +1,36 @@
# Issue Metadata Fields
## Summary
`issue +create` and `issue +update` now support common GitLink Issue metadata fields.
When updating or closing an Issue, the shortcut also carries the current metadata
back to the API so unrelated fields are not reset by partial updates.
## Added flags
| Flag | API field |
|------|-----------|
| `--priority-id` | `priority_id` |
| `--tag-ids` | `issue_tag_ids` |
| `--assigner-ids` | `assigner_ids` |
| `--branch` | `branch_name` |
| `--start-date` | `start_date` |
| `--due-date` | `due_date` |
`issue +create --label` is also mapped as a single tag ID for backward compatibility.
## Examples
```bash
gitlink-cli issue +create --owner Gitlink --repo forgeplus \
--title "Bug: login failed" \
--priority-id 3 \
--tag-ids 4,5 \
--assigner-ids 7
gitlink-cli issue +update --owner Gitlink --repo forgeplus \
--number 123 \
--priority-id 4 \
--branch bugfix/login \
--due-date 2026-06-15
```

77
docs/i18n.md Normal file
View File

@ -0,0 +1,77 @@
# GitLink CLI i18n Guide
## Goals
GitLink CLI localizes human-facing command-line text while keeping machine-readable output stable. The i18n layer is infrastructure, not a place to store every string in the project.
## Translate
- Cobra command `Short`, `Long`, and human-facing examples.
- Flag usage text.
- User-facing errors.
- Interactive prompts.
- Success messages.
- Warnings.
- Confirmation messages.
- Table column labels when the output is meant for humans.
## Do Not Translate
- JSON field names.
- Raw API response bodies.
- Debug logs and developer diagnostics.
- Machine-readable status enum values.
- HTTP methods, paths, query keys, and payload field names.
- Long-form README documentation.
- Test assertion descriptions.
## Key Names
Use stable, descriptive keys:
- `cmd.*` for command help.
- `flag.*` for flag usage.
- `error.*` for user-facing errors.
- `prompt.*` for interactive input prompts.
- `success.*` for successful user-facing operations.
- `warning.*` for warnings.
- `confirm.*` for confirmation prompts.
- `table.*` for human table headers.
Do not invent numbered keys such as `msg001`. Prefer names that describe ownership and intent, for example `error.missing_required_flag`.
## Adding Text
1. Add the key to `internal/i18n/locales/en-US.json`.
2. Add the same key to every other locale, including `zh-CN.json`.
3. Keep placeholders identical across locales, for example `{name}`.
4. Use `tr.T("key")` or `tr.Tf("key", i18n.Args{...})`.
5. Run:
```powershell
go run ./internal/i18n/cmd/check
go test ./...
```
Use `go run ./internal/i18n/cmd/check --fix` to format locale JSON.
Use `go run ./internal/i18n/cmd/check --scan-code` before opening a PR. The scanner is intentionally lightweight:
- Name command-construction translators `tr` when calling `tr.T(...)` or `tr.Tf(...)`.
- Use `ctx.Tr.T(...)` or `ctx.Tr.Tf(...)` in runtime shortcut code.
- Avoid calling translator methods through other variable names such as `translator.T(...)`; the current scan may not detect them.
- Do not add new `i18n.Default().T(...)` or `i18n.Default().Tf(...)` usages.
## Runtime Access
Command construction receives `*i18n.Translator` from `NewRootCmd`. Shortcut execution receives the same translator through `RuntimeContext.Tr`.
New command code should receive a translator explicitly. `i18n.Default()` exists only as a legacy migration fallback and should not be used for new command paths.
## Review Checklist
- Locale JSON is sorted and formatted with two spaces.
- Every locale has the same keys as `en-US`.
- Template placeholders match across locales.
- New command/runtime text uses i18n only when it is human-facing.
- JSON output, API raw responses, debug logs, and machine-readable values are unchanged.

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,163 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
)
func main() {
fix := flag.Bool("fix", false, "format locale JSON files")
scanCode := flag.Bool("scan-code", false, "scan Go source for referenced i18n keys")
flag.Parse()
problems, err := i18n.Validate(i18n.NewEmbedLoader(), "en-US")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if len(problems) > 0 {
for _, problem := range problems {
fmt.Fprintln(os.Stderr, problem.String())
}
os.Exit(1)
}
if err := checkLocaleFormat(*fix); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if *scanCode {
if err := checkCodeReferences(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
fmt.Println("i18n messages are valid")
}
func checkLocaleFormat(fix bool) error {
files, err := filepath.Glob(filepath.Join("internal", "i18n", "locales", "*.json"))
if err != nil {
return err
}
for _, path := range files {
data, err := os.ReadFile(path)
if err != nil {
return err
}
formatted, err := formatJSON(data)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
if string(data) == string(formatted) {
continue
}
if fix {
if err := os.WriteFile(path, formatted, 0600); err != nil {
return err
}
continue
}
return fmt.Errorf("%s: locale JSON is not formatted; run go run ./internal/i18n/cmd/check --fix", path)
}
return nil
}
func formatJSON(data []byte) ([]byte, error) {
var messages map[string]string
if err := json.Unmarshal(data, &messages); err != nil {
return nil, err
}
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(messages); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func checkCodeReferences() error {
loader := i18n.NewEmbedLoader()
base, err := loader.Load("en-US")
if err != nil {
return err
}
used, defaultUses, err := scanCodeKeys([]string{"cmd", "shortcuts", "internal"})
if err != nil {
return err
}
var missing []string
for key := range used {
if _, ok := base[key]; !ok {
missing = append(missing, key)
}
}
sort.Strings(missing)
if len(missing) > 0 {
return fmt.Errorf("missing i18n key references: %s", strings.Join(missing, ", "))
}
for _, item := range defaultUses {
fmt.Fprintf(os.Stderr, "warning: avoid new i18n.Default() usage at %s\n", item)
}
return nil
}
func scanCodeKeys(roots []string) (map[string]struct{}, []string, error) {
keyPattern := regexp.MustCompile(`(?:tr|ctx\.Tr|i18n\.Default\(\))\.T(?:f)?\("([^"]+)"`)
defaultPattern := regexp.MustCompile(`i18n\.Default\(\)\.T(?:f)?\("([^"]+)"`)
used := map[string]struct{}{}
var defaultUses []string
for _, root := range roots {
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
if strings.Contains(filepath.ToSlash(path), "internal/i18n/locales") {
return filepath.SkipDir
}
return nil
}
if entry.Type()&os.ModeSymlink != 0 {
return nil
}
if filepath.Ext(path) != ".go" {
return nil
}
if strings.HasSuffix(path, "_test.go") {
return nil
}
if !entry.Type().IsRegular() {
return nil
}
data, err := os.ReadFile(path) // #nosec G122 -- dev-only scan over repo roots; symlinks are skipped above.
if err != nil {
return err
}
text := string(data)
for _, match := range keyPattern.FindAllStringSubmatch(text, -1) {
used[match[1]] = struct{}{}
}
for _, match := range defaultPattern.FindAllStringSubmatchIndex(text, -1) {
line := 1 + strings.Count(text[:match[0]], "\n")
defaultUses = append(defaultUses, fmt.Sprintf("%s:%d", filepath.ToSlash(path), line))
}
return nil
})
if err != nil {
return nil, nil, err
}
}
sort.Strings(defaultUses)
return used, defaultUses, nil
}

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

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

@ -0,0 +1,100 @@
package i18n
import (
"strings"
)
// NormalizeLocale converts common locale spellings to a stable BCP-47-like form.
func NormalizeLocale(locale string) string {
locale = strings.TrimSpace(locale)
if locale == "" {
return ""
}
if idx := strings.IndexByte(locale, '.'); idx >= 0 {
locale = locale[:idx]
}
locale = strings.ReplaceAll(locale, "_", "-")
parts := strings.Split(locale, "-")
normalized := make([]string, 0, len(parts))
for i, part := range parts {
if part == "" {
continue
}
switch {
case i == 0:
normalized = append(normalized, strings.ToLower(part))
case len(part) == 2:
normalized = append(normalized, strings.ToUpper(part))
case len(part) == 4:
normalized = append(normalized, strings.ToUpper(part[:1])+strings.ToLower(part[1:]))
default:
normalized = append(normalized, part)
}
}
return strings.Join(normalized, "-")
}
// MatchLocale resolves requested to one of available using exact, safe alias,
// then fallback matching.
func MatchLocale(requested string, available []string, fallback string) string {
return matchLocale(requested, available, fallback).Locale
}
type localeMatch struct {
Locale string
Requested string
Fallbacked bool
Supported bool
}
func matchLocale(requested string, available []string, fallback string) localeMatch {
fallback = NormalizeLocale(fallback)
if fallback == "" {
fallback = defaultFallbackLocale
}
if len(available) == 0 {
return localeMatch{Locale: fallback, Requested: NormalizeLocale(requested), Fallbacked: true}
}
byLocale := make(map[string]string, len(available))
for _, locale := range available {
normalized := NormalizeLocale(locale)
byLocale[normalized] = normalized
}
candidate := NormalizeLocale(requested)
if candidate != "" {
if matched, ok := byLocale[candidate]; ok {
return localeMatch{Locale: matched, Requested: candidate, Supported: true}
}
if alias := localeAlias(candidate); alias != "" {
if matched, ok := byLocale[alias]; ok {
return localeMatch{Locale: matched, Requested: candidate, Supported: true}
}
}
}
if matched, ok := byLocale[fallback]; ok {
return localeMatch{Locale: matched, Requested: candidate, Fallbacked: candidate != "", Supported: false}
}
return localeMatch{Locale: NormalizeLocale(available[0]), Requested: candidate, Fallbacked: candidate != "", Supported: false}
}
func primaryLanguage(locale string) string {
if idx := strings.IndexByte(locale, '-'); idx >= 0 {
return locale[:idx]
}
return locale
}
func localeAlias(locale string) string {
switch {
case locale == "zh" || locale == "zh-CN" || locale == "zh-Hans" || locale == "zh-Hans-CN":
return "zh-CN"
case primaryLanguage(locale) == "en":
return "en-US"
default:
return ""
}
}

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

View File

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

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
}

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

@ -0,0 +1,95 @@
package i18n
import (
"os"
"strings"
)
// ResolveOptions contains locale inputs ordered by caller intent.
type ResolveOptions struct {
ExplicitLang string
Env map[string]string
ConfigLang string
}
type ResolvedLocale struct {
Locale string
Source string
Requested string
Fallbacked bool
Supported bool
}
// ResolveLocale resolves a locale using CLI flag, env, config, system env, fallback.
func ResolveLocale(opts ResolveOptions, available []string) string {
return ResolveLocaleDetailed(opts, available).Locale
}
func ResolveLocaleDetailed(opts ResolveOptions, available []string) ResolvedLocale {
candidates := []struct {
source string
value string
}{
{source: "flag", value: opts.ExplicitLang},
{source: "env", value: envValue(opts.Env, "GITLINK_LANG")},
{source: "config", value: opts.ConfigLang},
{source: "lc_all", value: envValue(opts.Env, "LC_ALL")},
{source: "lang", value: envValue(opts.Env, "LANG")},
}
for _, candidate := range candidates {
if strings.TrimSpace(candidate.value) == "" {
continue
}
match := matchLocale(candidate.value, available, defaultFallbackLocale)
return ResolvedLocale{
Locale: match.Locale,
Source: candidate.source,
Requested: match.Requested,
Fallbacked: match.Fallbacked,
Supported: match.Supported,
}
}
match := matchLocale(defaultFallbackLocale, available, defaultFallbackLocale)
return ResolvedLocale{
Locale: match.Locale,
Source: "default",
Requested: match.Requested,
Fallbacked: match.Fallbacked,
Supported: match.Supported,
}
}
// PreScanLang reads --lang before Cobra constructs localized help text.
func PreScanLang(args []string) string {
for i, arg := range args {
if arg == "--lang" {
if i+1 < len(args) {
return args[i+1]
}
return ""
}
if strings.HasPrefix(arg, "--lang=") {
return strings.TrimPrefix(arg, "--lang=")
}
}
return ""
}
// EnvMap returns process environment as a string map.
func EnvMap() map[string]string {
env := make(map[string]string)
for _, item := range os.Environ() {
key, value, ok := strings.Cut(item, "=")
if ok {
env[key] = value
}
}
return env
}
func envValue(env map[string]string, key string) string {
if env == nil {
return os.Getenv(key)
}
return env[key]
}

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|success|warning|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 legacy migration only.
// New command code should receive *Translator explicitly.
func Default() *Translator {
tr, err := New(Options{Locale: defaultFallbackLocale})
if err != nil {
return &Translator{
locale: defaultFallbackLocale,
fallbackLocale: defaultFallbackLocale,
messages: map[string]string{},
fallback: map[string]string{},
}
}
return tr
}
// New constructs a Translator. Missing messages fall back to FallbackLocale.
func New(opts Options) (*Translator, error) {
loader := opts.Loader
if loader == nil {
loader = NewEmbedLoader()
}
available, err := loader.AvailableLocales()
if err != nil {
return nil, err
}
fallbackLocale := opts.FallbackLocale
if fallbackLocale == "" {
fallbackLocale = defaultFallbackLocale
}
fallbackLocale = MatchLocale(fallbackLocale, available, defaultFallbackLocale)
locale := MatchLocale(opts.Locale, available, fallbackLocale)
fallback, err := loader.Load(fallbackLocale)
if err != nil {
return nil, fmt.Errorf("load fallback locale: %w", err)
}
messages := fallback
if locale != fallbackLocale {
messages, err = loader.Load(locale)
if err != nil {
return nil, fmt.Errorf("load locale: %w", err)
}
}
return &Translator{
locale: locale,
fallbackLocale: fallbackLocale,
messages: messages,
fallback: fallback,
}, nil
}
func (t *Translator) Locale() string {
return t.locale
}
// T returns a localized message, falling back to en-US and then the key itself.
func (t *Translator) T(key string) string {
if t == nil {
return key
}
if value, ok := t.messages[key]; ok {
return value
}
if value, ok := t.fallback[key]; ok {
return value
}
return key
}
// Tf returns a localized message with {name} placeholders rendered from args.
func (t *Translator) Tf(key string, args Args) string {
return renderTemplate(t.T(key), args)
}

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|prompt|success|warning|confirm|table|output)\.[a-z0-9_.-]+$`)
// Problem describes a locale validation issue.
type Problem struct {
Locale string
Key string
Message string
}
func (p Problem) String() string {
if p.Key == "" {
return fmt.Sprintf("%s: %s", p.Locale, p.Message)
}
return fmt.Sprintf("%s:%s: %s", p.Locale, p.Key, p.Message)
}
// Validate checks all locales against baseLocale.
func Validate(loader Loader, baseLocale string) ([]Problem, error) {
if loader == nil {
loader = NewEmbedLoader()
}
baseLocale = NormalizeLocale(baseLocale)
if baseLocale == "" {
baseLocale = defaultFallbackLocale
}
locales, err := loader.AvailableLocales()
if err != nil {
return nil, err
}
sort.Strings(locales)
allMessages := make(map[string]map[string]string, len(locales))
for _, locale := range locales {
normalized := NormalizeLocale(locale)
if normalized != locale {
return []Problem{{Locale: locale, Message: "locale filename is not normalized"}}, nil
}
messages, err := loader.Load(locale)
if err != nil {
return nil, err
}
allMessages[locale] = messages
}
base, ok := allMessages[baseLocale]
if !ok {
return []Problem{{Locale: baseLocale, Message: "base locale is missing"}}, nil
}
var problems []Problem
for key, value := range base {
problems = append(problems, validateMessage(baseLocale, key, value)...)
}
for _, locale := range locales {
messages := allMessages[locale]
for key, value := range messages {
problems = append(problems, validateMessage(locale, key, value)...)
if _, ok := base[key]; !ok {
problems = append(problems, Problem{Locale: locale, Key: key, Message: "key is not present in base locale"})
}
}
for key, baseValue := range base {
value, ok := messages[key]
if !ok {
problems = append(problems, Problem{Locale: locale, Key: key, Message: "missing key"})
continue
}
baseArgs := extractTemplateArgs(baseValue)
args := extractTemplateArgs(value)
if !reflect.DeepEqual(baseArgs, args) {
problems = append(problems, Problem{
Locale: locale,
Key: key,
Message: fmt.Sprintf("template args mismatch: expected {%s}, got {%s}", strings.Join(baseArgs, ","), strings.Join(args, ",")),
})
}
}
}
return problems, nil
}
func validateMessage(locale, key, value string) []Problem {
var problems []Problem
if !keyPattern.MatchString(key) {
problems = append(problems, Problem{Locale: locale, Key: key, Message: "key does not match naming rules"})
}
if strings.TrimSpace(value) == "" {
problems = append(problems, Problem{Locale: locale, Key: key, Message: "message is empty"})
}
return problems
}

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

@ -1,17 +1,22 @@
package common
import (
"fmt"
"strconv"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/spf13/cobra"
)
// MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand.
func MountShortcut(parent *cobra.Command, s *Shortcut) {
func MountShortcut(parent *cobra.Command, s *Shortcut, translators ...*i18n.Translator) {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
cmd := &cobra.Command{
Use: "+" + s.Name,
Short: s.Description,
Long: s.Long,
RunE: func(cmd *cobra.Command, args []string) error {
// Collect flag values
flagValues := make(map[string]string)
@ -27,10 +32,16 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
}
}
ctx, err := NewRuntimeContext(flagValues)
ctx, err := NewRuntimeContext(flagValues, tr)
if err != nil {
return err
}
for _, f := range s.Flags {
if f.Required && flagValues[f.Name] == "" {
_, err := ctx.RequireArg(f.Name)
return err
}
}
return s.Run(ctx)
},
@ -49,19 +60,18 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
} else {
cmd.Flags().String(f.Name, f.Default, f.Usage)
}
if f.Required {
if err := cmd.MarkFlagRequired(f.Name); err != nil {
panic(fmt.Sprintf("failed to mark flag %s as required: %v", f.Name, err))
}
}
}
parent.AddCommand(cmd)
}
// MountShortcuts mounts multiple shortcuts under a parent command.
func MountShortcuts(parent *cobra.Command, shortcuts []*Shortcut) {
func MountShortcuts(parent *cobra.Command, shortcuts []*Shortcut, translators ...*i18n.Translator) {
var tr *i18n.Translator
if len(translators) > 0 {
tr = translators[0]
}
for _, s := range shortcuts {
MountShortcut(parent, s)
MountShortcut(parent, s, tr)
}
}

View File

@ -2,12 +2,14 @@ package common
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/internal/context"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/internal/output"
)
@ -15,6 +17,7 @@ import (
type Shortcut struct {
Name string
Description string
Long string
Flags []Flag
Run func(ctx *RuntimeContext) error
}
@ -36,10 +39,15 @@ type RuntimeContext struct {
Repo string
Format string
Args map[string]string
Tr *i18n.Translator
}
// NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo.
func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) {
func NewRuntimeContext(args map[string]string, translators ...*i18n.Translator) (*RuntimeContext, error) {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
cli, err := client.New()
if err != nil {
return nil, err
@ -57,6 +65,7 @@ func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) {
Repo: cmdutil.Repo,
Format: format,
Args: args,
Tr: tr,
}, nil
}
@ -113,7 +122,7 @@ func (ctx *RuntimeContext) Arg(name string) string {
func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
v := ctx.Arg(name)
if v == "" {
return "", fmt.Errorf("required flag --%s is missing", name)
return "", errors.New(ctx.Tr.Tf("error.missing_required_flag", i18n.Args{"name": name}))
}
return v, nil
}

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"
)
@ -18,18 +19,26 @@ func v1RepoPath(ctx *common.RuntimeContext) string {
type existingIssue struct {
Subject string
Description string
StatusID interface{}
PriorityID interface{}
TagIDs []interface{}
AssignerIDs []interface{}
BranchName string
StartDate string
DueDate string
}
func Shortcuts() []*common.Shortcut {
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
return []*common.Shortcut{
newBatchCloseShortcut(),
{
Name: "list",
Description: "List issues",
Description: tr.T("cmd.issue.list.short"),
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "state", Short: "s", Usage: tr.T("flag.issue.state"), Default: "open"},
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -51,13 +60,19 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "create",
Description: "Create a new issue",
Description: tr.T("cmd.issue.create.short"),
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "Issue title", Required: true},
{Name: "body", Short: "b", Usage: "Issue description"},
{Name: "assignee", Short: "a", Usage: "Assignee login"},
{Name: "milestone", Short: "m", Usage: "Milestone ID"},
{Name: "label", Usage: "Label ID"},
{Name: "title", Short: "t", Usage: tr.T("flag.issue.title"), Required: true},
{Name: "body", Short: "b", Usage: tr.T("flag.issue.body")},
{Name: "assignee", Short: "a", Usage: tr.T("flag.issue.assignee")},
{Name: "milestone", Short: "m", Usage: tr.T("flag.issue.milestone")},
{Name: "label", Usage: tr.T("flag.issue.label")},
{Name: "priority-id", Usage: "Priority ID", Default: "2"},
{Name: "tag-ids", Usage: "Comma-separated issue tag IDs"},
{Name: "assigner-ids", Usage: "Comma-separated issue assigner IDs"},
{Name: "branch", Usage: "Linked branch name"},
{Name: "start-date", Usage: "Start date (YYYY-MM-DD)"},
{Name: "due-date", Usage: "Due date (YYYY-MM-DD)"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -82,6 +97,9 @@ func Shortcuts() []*common.Shortcut {
if m := ctx.Arg("milestone"); m != "" {
body["fixed_version_id"] = m
}
if err := applyIssueMetadataArgs(ctx, body); err != nil {
return err
}
env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body)
if err != nil {
return err
@ -91,11 +109,8 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "view",
Description: "View issue details",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)"},
{Name: "id", Usage: "Alias for --number; uses the issue number from the web URL"},
},
Description: tr.T("cmd.issue.view.short"),
Flags: issueNumberFlags(),
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
@ -113,15 +128,13 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "close",
Description: "Close an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
},
Description: tr.T("cmd.issue.close.short"),
Flags: issueNumberFlags(),
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
number, err := issueNumberArg(ctx)
if err != nil {
return err
}
@ -133,8 +146,9 @@ func Shortcuts() []*common.Shortcut {
body := map[string]interface{}{
"subject": current.Subject,
"description": current.Description,
"status_id": 5, // 5 = closed
}
preserveIssueMetadata(body, current)
body["status_id"] = 5 // 5 = closed
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return err
@ -144,26 +158,31 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "update",
Description: "Update an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "title", Short: "t", Usage: "New title"},
{Name: "body", Short: "b", Usage: "New description"},
{Name: "state", Short: "s", Usage: "New state: open, closed, or numeric status_id"},
},
Description: tr.T("cmd.issue.update.short"),
Flags: appendIssueNumberFlags(
common.Flag{Name: "title", Short: "t", Usage: tr.T("flag.issue.new_title")},
common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.issue.new_body")},
common.Flag{Name: "state", Short: "s", Usage: tr.T("flag.issue.new_state")},
common.Flag{Name: "priority-id", Usage: "New priority ID"},
common.Flag{Name: "tag-ids", Usage: "Comma-separated issue tag IDs"},
common.Flag{Name: "assigner-ids", Usage: "Comma-separated issue assigner IDs"},
common.Flag{Name: "branch", Usage: "Linked branch name"},
common.Flag{Name: "start-date", Usage: "Start date (YYYY-MM-DD)"},
common.Flag{Name: "due-date", Usage: "Due date (YYYY-MM-DD)"},
),
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
number, err := issueNumberArg(ctx)
if err != nil {
return err
}
title := ctx.Arg("title")
description := ctx.Arg("body")
state := ctx.Arg("state")
if title == "" && description == "" && state == "" {
return fmt.Errorf("at least one of --title, --body, or --state is required")
if title == "" && description == "" && state == "" && !hasIssueMetadataArgs(ctx) {
return fmt.Errorf("at least one update field is required")
}
current, err := fetchExistingIssue(ctx, number)
@ -175,6 +194,7 @@ func Shortcuts() []*common.Shortcut {
"subject": current.Subject,
"description": current.Description,
}
preserveIssueMetadata(body, current)
if t := ctx.Arg("title"); t != "" {
body["subject"] = t
}
@ -188,6 +208,9 @@ func Shortcuts() []*common.Shortcut {
}
body["status_id"] = statusID
}
if err := applyIssueMetadataArgs(ctx, body); err != nil {
return err
}
env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body)
if err != nil {
return err
@ -197,16 +220,15 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "comment",
Description: "Add a comment to an issue",
Flags: []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
},
Description: tr.T("cmd.issue.comment.short"),
Flags: appendIssueNumberFlags(
common.Flag{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true},
),
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
number, err := ctx.RequireArg("number")
number, err := issueNumberArg(ctx)
if err != nil {
return err
}
@ -266,9 +288,112 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "priorities",
Description: "List issue priorities",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if keyword := ctx.Arg("keyword"); keyword != "" {
q.Set("keyword", keyword)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_priorities", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "tags",
Description: "List issue tags",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword"},
{Name: "only-name", Usage: "Only return tag names and IDs", Bool: true, Default: "false"},
{Name: "order-by", Usage: "Order by: updated_on, created_on, issues_count"},
{Name: "order-direction", Usage: "Order direction: asc or desc"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if keyword := ctx.Arg("keyword"); keyword != "" {
q.Set("keyword", keyword)
}
if parseBool(ctx.Arg("only-name")) {
q.Set("only_name", "true")
}
if orderBy := ctx.Arg("order-by"); orderBy != "" {
q.Set("order_by", orderBy)
}
if orderDirection := ctx.Arg("order-direction"); orderDirection != "" {
q.Set("order_direction", orderDirection)
}
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_tags", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "statuses",
Description: "List issue statuses",
Flags: []common.Flag{
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_statues", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
}
return i18n.Default()
}
func issueNumberFlags() []common.Flag {
return []common.Flag{
{Name: "number", Short: "n", Usage: "Issue number from the web URL (preferred)"},
{Name: "id", Short: "i", Usage: "Compatibility alias for --number; this is not the database ID"},
}
}
func appendIssueNumberFlags(flags ...common.Flag) []common.Flag {
return append(issueNumberFlags(), flags...)
}
func issueNumberArg(ctx *common.RuntimeContext) (string, error) {
if number := strings.TrimSpace(ctx.Arg("number")); number != "" {
return number, nil
}
if id := strings.TrimSpace(ctx.Arg("id")); id != "" {
return id, nil
}
return "", fmt.Errorf("required flag --number is missing (or use --id as a compatibility alias)")
}
// normalizeIssueListIDs adds "number" (project_issues_index) and renames
// "id" to "database_id" so the user-facing output uses the project-level
// issue number, not the global database primary key.
@ -316,9 +441,76 @@ func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIss
return &existingIssue{
Subject: subject,
Description: description,
StatusID: nestedIssueID(issueData, "status"),
PriorityID: nestedIssueID(issueData, "priority"),
TagIDs: issueObjectIDs(issueData, "tags", "issue_tags"),
AssignerIDs: issueObjectIDs(issueData, "assigners"),
BranchName: stringField(issueData, "branch_name"),
StartDate: stringField(issueData, "start_date"),
DueDate: stringField(issueData, "due_date"),
}, nil
}
func preserveIssueMetadata(body map[string]interface{}, issue *existingIssue) {
if issue.StatusID != nil {
body["status_id"] = issue.StatusID
}
if issue.PriorityID != nil {
body["priority_id"] = issue.PriorityID
}
if len(issue.TagIDs) > 0 {
body["issue_tag_ids"] = issue.TagIDs
}
if len(issue.AssignerIDs) > 0 {
body["assigner_ids"] = issue.AssignerIDs
}
if issue.BranchName != "" {
body["branch_name"] = issue.BranchName
}
if issue.StartDate != "" {
body["start_date"] = issue.StartDate
}
if issue.DueDate != "" {
body["due_date"] = issue.DueDate
}
}
func nestedIssueID(data map[string]interface{}, key string) interface{} {
item, ok := data[key].(map[string]interface{})
if !ok {
return nil
}
return item["id"]
}
func issueObjectIDs(data map[string]interface{}, keys ...string) []interface{} {
for _, key := range keys {
items, ok := data[key].([]interface{})
if !ok {
continue
}
ids := make([]interface{}, 0, len(items))
for _, item := range items {
obj, ok := item.(map[string]interface{})
if !ok {
continue
}
if id, ok := obj["id"]; ok {
ids = append(ids, id)
}
}
if len(ids) > 0 {
return ids
}
}
return nil
}
func stringField(data map[string]interface{}, key string) string {
value, _ := data[key].(string)
return value
}
func normalizeIssueStatus(state string) (interface{}, error) {
switch strings.ToLower(strings.TrimSpace(state)) {
case "open":
@ -333,12 +525,77 @@ func normalizeIssueStatus(state string) (interface{}, error) {
}
}
func issueNumberArg(ctx *common.RuntimeContext) (string, error) {
if number := strings.TrimSpace(ctx.Arg("number")); number != "" {
return number, nil
func hasIssueMetadataArgs(ctx *common.RuntimeContext) bool {
for _, name := range []string{"priority-id", "tag-ids", "label", "assigner-ids", "branch", "start-date", "due-date"} {
if ctx.Arg(name) != "" {
return true
}
}
if id := strings.TrimSpace(ctx.Arg("id")); id != "" {
return id, nil
}
return "", fmt.Errorf("required flag --number (or --id alias) not set")
return false
}
func applyIssueMetadataArgs(ctx *common.RuntimeContext, body map[string]interface{}) error {
if priority := ctx.Arg("priority-id"); priority != "" {
priorityID, err := parseIssueID(priority, "priority-id")
if err != nil {
return err
}
body["priority_id"] = priorityID
}
tagIDs := ctx.Arg("tag-ids")
if label := ctx.Arg("label"); label != "" {
if tagIDs != "" {
return fmt.Errorf("--label cannot be used with --tag-ids")
}
tagIDs = label
}
if tagIDs != "" {
ids, err := parseIssueIDList(tagIDs, "tag-ids")
if err != nil {
return err
}
body["issue_tag_ids"] = ids
}
if assignerIDs := ctx.Arg("assigner-ids"); assignerIDs != "" {
ids, err := parseIssueIDList(assignerIDs, "assigner-ids")
if err != nil {
return err
}
body["assigner_ids"] = ids
}
if branch := ctx.Arg("branch"); branch != "" {
body["branch_name"] = branch
}
if startDate := ctx.Arg("start-date"); startDate != "" {
body["start_date"] = startDate
}
if dueDate := ctx.Arg("due-date"); dueDate != "" {
body["due_date"] = dueDate
}
return nil
}
func parseIssueIDList(value, flagName string) ([]int, error) {
parts := strings.Split(value, ",")
ids := make([]int, 0, len(parts))
for _, part := range parts {
id, err := parseIssueID(part, flagName)
if err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, nil
}
func parseIssueID(value, flagName string) (int, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return 0, fmt.Errorf("--%s contains an empty ID", flagName)
}
id, err := strconv.Atoi(trimmed)
if err != nil || id <= 0 {
return 0, fmt.Errorf("--%s must contain positive numeric IDs", flagName)
}
return id, nil
}

File diff suppressed because it is too large Load Diff

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

@ -5,19 +5,21 @@ import (
"net/url"
"strings"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/internal/output"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := shortcutTranslator(translators...)
return []*common.Shortcut{
{
Name: "list",
Description: "List pull requests",
Description: tr.T("cmd.pr.list.short"),
Flags: []common.Flag{
{Name: "state", Short: "s", Usage: "Filter: open, merged, closed", Default: "open"},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
{Name: "state", Short: "s", Usage: tr.T("flag.pr.state"), Default: "open"},
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -38,12 +40,12 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "create",
Description: "Create a pull request",
Description: tr.T("cmd.pr.create.short"),
Flags: []common.Flag{
{Name: "title", Short: "t", Usage: "PR title", Required: true},
{Name: "body", Short: "b", Usage: "PR description"},
{Name: "head", Usage: "Source branch", Required: true},
{Name: "base", Usage: "Target branch", Default: "master"},
{Name: "title", Short: "t", Usage: tr.T("flag.pr.title"), Required: true},
{Name: "body", Short: "b", Usage: tr.T("flag.pr.body")},
{Name: "head", Usage: tr.T("flag.pr.head"), Required: true},
{Name: "base", Usage: tr.T("flag.pr.base"), Default: "master"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -72,9 +74,9 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "view",
Description: "View pull request details",
Description: tr.T("cmd.pr.view.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -93,10 +95,10 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "merge",
Description: "Merge a pull request",
Description: tr.T("cmd.pr.merge.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
{Name: "method", Short: "m", Usage: tr.T("flag.pr.merge_method"), Default: "merge"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -119,9 +121,9 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "close",
Description: "Close a pull request",
Description: tr.T("cmd.pr.close.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -158,9 +160,9 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "files",
Description: "List changed files in a pull request",
Description: tr.T("cmd.pr.files.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -176,9 +178,9 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "diff",
Description: "Show diff for a pull request",
Description: tr.T("cmd.pr.diff.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -194,9 +196,9 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "versions",
Description: "List pull request patchset versions",
Description: tr.T("cmd.pr.versions.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -215,11 +217,11 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "version-diff",
Description: "Show diff for a pull request patchset version",
Description: tr.T("cmd.pr.version_diff.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "version-id", Short: "v", Usage: "Patchset version ID", Required: true},
{Name: "file", Short: "f", Usage: "Filter diff by file path"},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
{Name: "version-id", Short: "v", Usage: tr.T("flag.pr.version_id"), Required: true},
{Name: "file", Short: "f", Usage: tr.T("flag.pr.file")},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -252,10 +254,10 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "reviews",
Description: "List pull request reviews",
Description: tr.T("cmd.pr.reviews.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "status", Short: "s", Usage: "Filter review status: common, approved, rejected"},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
{Name: "status", Short: "s", Usage: tr.T("flag.pr.review_status_filter")},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -281,13 +283,13 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "review",
Description: "Create a pull request review",
Description: tr.T("cmd.pr.review.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "status", Short: "s", Usage: "Review status: common, approved, rejected", Default: "common"},
{Name: "content", Short: "c", Usage: "Review content", Required: true},
{Name: "commit", Short: "m", Usage: "Commit SHA to attach the review to"},
{Name: "dry-run", Usage: "Preview the review request without creating it", Bool: true, Default: "false"},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
{Name: "status", Short: "s", Usage: tr.T("flag.pr.review_status"), Default: "common"},
{Name: "content", Short: "c", Usage: tr.T("flag.pr.review_content"), Required: true},
{Name: "commit", Short: "m", Usage: tr.T("flag.pr.review_commit")},
{Name: "dry-run", Usage: tr.T("flag.dry_run"), Bool: true, Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -328,15 +330,29 @@ func Shortcuts() []*common.Shortcut {
if err != nil {
return err
}
// Also post a journal comment so the review is visible in the PR conversation.
prEnv, journalErr := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil)
if journalErr == nil {
if issueID, extractErr := extractIssueID(prEnv); extractErr == nil {
statusLabel := map[string]string{
"approved": "approved", "rejected": "rejected", "common": "commented",
}[status]
summary := fmt.Sprintf("## Review: %s\n\n%s", statusLabel, content)
ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID),
map[string]interface{}{"notes": summary})
}
}
return ctx.Output(env)
},
},
{
Name: "comment",
Description: "Add a comment to a pull request",
Description: tr.T("cmd.pr.comment.short"),
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
@ -367,6 +383,13 @@ func Shortcuts() []*common.Shortcut {
}
}
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
}
return i18n.Default()
}
func prV1Path(ctx *common.RuntimeContext, id string) string {
return fmt.Sprintf("/v1/%s/%s/pulls/%s", ctx.Owner, ctx.Repo, id)
}

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"
@ -23,42 +24,46 @@ import (
)
// RegisterAll mounts all shortcut groups onto the root command.
func RegisterAll(root *cobra.Command) {
func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
groups := map[string][]*common.Shortcut{
"repo": repo.Shortcuts(),
"issue": issue.Shortcuts(),
"repo": repo.Shortcuts(tr),
"issue": issue.Shortcuts(tr),
"label": label.Shortcuts(),
"member": member.Shortcuts(),
"milestone": milestone.Shortcuts(),
"pipeline": pipeline.Shortcuts(),
"pr": pr.Shortcuts(),
"release": release.Shortcuts(),
"branch": branch.Shortcuts(),
"org": org.Shortcuts(),
"user": user.Shortcuts(),
"search": search.Shortcuts(),
"ci": ci.Shortcuts(),
"pr": pr.Shortcuts(tr),
"release": release.Shortcuts(tr),
"branch": branch.Shortcuts(tr),
"org": org.Shortcuts(tr),
"user": user.Shortcuts(tr),
"search": search.Shortcuts(tr),
"ci": ci.Shortcuts(tr),
"compare": compare.Shortcuts(),
"webhook": webhook.Shortcuts(),
"webhook": webhook.Shortcuts(tr),
"workflow": workflow.Shortcuts(),
}
descriptions := map[string]string{
"repo": "Repository operations",
"issue": "Issue operations",
"repo": tr.T("cmd.repo.short"),
"issue": tr.T("cmd.issue.short"),
"label": "Issue label operations",
"member": "Repository member operations",
"milestone": "Milestone operations",
"pipeline": "Pipeline operations",
"pr": "Pull request operations",
"release": "Release operations",
"branch": "Branch operations",
"org": "Organization operations",
"user": "User operations",
"search": "Search operations",
"ci": "CI/CD operations",
"pr": tr.T("cmd.pr.short"),
"release": tr.T("cmd.release.short"),
"branch": tr.T("cmd.branch.short"),
"org": tr.T("cmd.org.short"),
"user": tr.T("cmd.user.short"),
"search": tr.T("cmd.search.short"),
"ci": tr.T("cmd.ci.short"),
"compare": "Compare branches, tags, or commits",
"webhook": "Webhook operations",
"webhook": tr.T("cmd.webhook.short"),
"workflow": "AI agent workflow analysis",
}
@ -67,7 +72,7 @@ func RegisterAll(root *cobra.Command) {
Use: name,
Short: descriptions[name],
}
common.MountShortcuts(groupCmd, shortcuts)
common.MountShortcuts(groupCmd, shortcuts, tr)
root.AddCommand(groupCmd)
}
}

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
@ -79,11 +81,11 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "create",
Description: "Create a new repository",
Description: tr.T("cmd.repo.create.short"),
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Repository name", Required: true},
{Name: "description", Short: "d", Usage: "Repository description"},
{Name: "private", Usage: "Make repository private (true/false)", Default: "false"},
{Name: "name", Short: "n", Usage: tr.T("flag.repo.name"), Required: true},
{Name: "description", Short: "d", Usage: tr.T("flag.repo.description")},
{Name: "private", Usage: tr.T("flag.repo.private"), Default: "false"},
},
Run: func(ctx *common.RuntimeContext) error {
name, err := ctx.RequireArg("name")
@ -121,7 +123,7 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "fork",
Description: "Fork a repository",
Description: tr.T("cmd.repo.fork.short"),
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
@ -135,7 +137,7 @@ func Shortcuts() []*common.Shortcut {
},
{
Name: "delete",
Description: "Delete a repository",
Description: tr.T("cmd.repo.delete.short"),
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
@ -149,3 +151,10 @@ func Shortcuts() []*common.Shortcut {
},
}
}
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
if len(translators) > 0 && translators[0] != nil {
return translators[0]
}
return i18n.Default()
}

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

View File

@ -29,6 +29,9 @@ metadata:
| `issue +comment` | 添加评论 | 是 |
| `issue +assigners` | 查询 Issue 负责人列表 | 否(公开项目) |
| `issue +authors` | 查询 Issue 发布人列表 | 否(公开项目) |
| `issue +statuses` | 查询 Issue 状态列表 | 否(公开项目) |
| `issue +tags` | 查询 Issue 标签列表 | 否(公开项目) |
| `issue +priorities` | 查询 Issue 优先级列表 | 否(公开项目) |
## 使用示例
@ -79,15 +82,23 @@ gitlink-cli api POST /:owner/:repo/issues/series_update --body '{"ids":[1,2,3],"
| gitlink-cli 参数 | GitLink API 字段 | 说明 |
|------------------|-----------------|------|
| `--number` / `-n` | `project_issues_index` | Issue 编号(网页 URL 中的序号) |
| `--id` / `-i` | `project_issues_index` | `--number` 的兼容别名,不是数据库内部 ID |
| `--title` | `subject` | Issue 标题 |
| `--body` | `description` | Issue 描述 |
| `--assignee` | `assigned_to_id` | 指派人 ID |
| `--milestone` | `fixed_version_id` | 里程碑 ID |
| `--state` | `status_id` | 状态open=1closed=5也可直接传数字 ID |
| `--priority-id` | `priority_id` | 优先级 ID |
| `--tag-ids` / `--label` | `issue_tag_ids` | Issue 标签 ID 数组 |
| `--assigner-ids` | `assigner_ids` | 负责人 ID 数组 |
| `--branch` | `branch_name` | 关联分支 |
| `--start-date` | `start_date` | 开始日期 |
| `--due-date` | `due_date` | 截止日期 |
## API 注意事项
- **Issue 编号(`--number`)是网页 URL 中看到的序号**(如 `issues/4` 中的 `4`),不是数据库内部 ID
- `--id` / `-i` 仅作为 `--number` / `-n` 的兼容别名,传入的仍然是网页 URL 中的 Issue 编号
- **批量关闭使用 `--numbers`,同样传网页 URL 中的 Issue 编号**,不是数据库内部 ID
- Issue 操作使用 v1 API`/api/v1/`),支持按 Issue 编号查询和操作
- **创建 Issue 时 CLI 会自动设置 `status_id: 1`(新增)和 `priority_id: 2`(正常)**

View File

@ -0,0 +1,25 @@
# Issue Priorities
Use `issue +priorities` to list the available Issue priority values for a repository.
This is useful before creating or updating an Issue that needs a specific `priority_id`.
## Usage
```bash
gitlink-cli issue +priorities --owner Gitlink --repo forgeplus
gitlink-cli issue +priorities --owner Gitlink --repo forgeplus --keyword normal
```
## Options
| Option | Description |
|--------|-------------|
| `--owner` | Repository owner |
| `--repo` | Repository name |
| `--keyword`, `-k` | Optional search keyword |
## API
```http
GET /api/v1/{owner}/{repo}/issue_priorities.json
```

View File

@ -0,0 +1,28 @@
# Issue Statuses
Use `issue +statuses` to list available Issue status values for a repository.
This helps users find `status_id` values before creating reports or updating Issues.
## Usage
```bash
gitlink-cli issue +statuses --owner Gitlink --repo forgeplus
gitlink-cli issue +statuses --owner Gitlink --repo forgeplus --page 1 --limit 50
```
## Options
| Option | Description |
|--------|-------------|
| `--owner` | Repository owner |
| `--repo` | Repository name |
| `--page`, `-p` | Page number |
| `--limit`, `-l` | Items per page |
## API
```http
GET /api/v1/{owner}/{repo}/issue_statues.json
```
The API path and response field use `statues` as documented by GitLink OpenAPI.

View File

@ -0,0 +1,31 @@
# Issue Tags
Use `issue +tags` to list the Issue tags available in a repository.
This helps users find tag IDs before creating or updating Issues.
## Usage
```bash
gitlink-cli issue +tags --owner Gitlink --repo forgeplus
gitlink-cli issue +tags --owner Gitlink --repo forgeplus --only-name --keyword bug
gitlink-cli issue +tags --owner Gitlink --repo forgeplus --order-by issues_count --order-direction desc
```
## Options
| Option | Description |
|--------|-------------|
| `--owner` | Repository owner |
| `--repo` | Repository name |
| `--keyword`, `-k` | Optional search keyword |
| `--only-name` | Return only tag names and IDs |
| `--order-by` | Sort field: `updated_on`, `created_on`, or `issues_count` |
| `--order-direction` | Sort direction: `asc` or `desc` |
## API
```http
GET /api/v1/{owner}/{repo}/issue_tags.json
```
Query parameters are mapped as `keyword`, `only_name`, `order_by`, and `order_direction`.

View File

@ -0,0 +1,348 @@
---
name: gitlink-license-compliance
version: 1.0.0
description: "许可证合规检查:扫描仓库的许可证兼容性、依赖项合规性与敏感信息泄露风险,生成结构化合规评估报告。当用户需要检查项目许可证合规性、排查敏感信息泄露、评估开源风险时触发。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli repo --help"
---
# gitlink-license-compliance许可证合规检查
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md)。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
## 功能概述
本技能为科研项目、开源项目提供全面的合规性扫描,包括:
1. **许可证识别** — 检测仓库根目录及子目录的许可证声明
2. **许可证兼容性分析** — 评估项目许可证与依赖项许可证的兼容性
3. **敏感信息扫描** — 检测代码中硬编码的密钥、密码、Token 等敏感数据
4. **合规报告生成** — 输出结构化的合规评估报告
---
## 一、许可证识别
### 获取仓库根目录文件列表
```bash
# 先克隆仓库到本地(如果尚未克隆)
git clone https://www.gitlink.org.cn/<owner>/<repo>.git
cd <repo>
# 获取根目录文件列表,查找许可证文件
git ls-tree --name-only HEAD
```
### 常见许可证文件名
```
LICENSE
LICENSE.md
LICENSE.txt
LICENSE-MIT
LICENSE-APACHE
COPYING
COPYING.md
```
### 读取许可证文件内容
```bash
# 读取 LICENSE 文件内容
git show HEAD:LICENSE
# 或查看其他常见许可证文件
git show HEAD:LICENSE.md
git show HEAD:LICENSE.txt
```
### 获取 GitLink 平台支持的许可证列表
```bash
# 获取仓库中的许可证文件
git ls-tree --name-only HEAD | grep -iE "(license|copying)"
# 如果仓库未声明许可证,结合下方的关键词识别规则 AI 直接判断
```
### 常见许可证识别关键词
| 许可证 | 识别关键词 |
|--------|-----------|
| MIT | `MIT License`, `Permission is hereby granted, free of charge` |
| Apache-2.0 | `Apache License, Version 2.0` |
| GPL-2.0 | `GNU GENERAL PUBLIC LICENSE`, `Version 2` |
| GPL-3.0 | `GNU GENERAL PUBLIC LICENSE`, `Version 3` |
| LGPL-2.1 | `GNU LESSER GENERAL PUBLIC LICENSE`, `Version 2.1` |
| BSD-2-Clause | `Redistribution and use in source and binary forms` |
| BSD-3-Clause | `Neither the name of`, `nor the names of its contributors` |
| MPL-2.0 | `Mozilla Public License, v. 2.0` |
| MulanPSL-2.0 | `木兰宽松许可证`, `Mulan Permissive Software License` |
| CC0-1.0 | `Creative Commons Legal Code`, `CC0` |
---
## 二、许可证兼容性分析
### 许可证分类(按限制程度)
```
宽松型Permissive—— 限制最少,可自由商用和闭源分发:
MIT、Apache-2.0、BSD-2-Clause、BSD-3-Clause、ISC、MulanPSL-2.0
弱传染型Weak Copyleft—— 修改的库文件需开源,但不传染整体:
LGPL-2.1、LGPL-3.0、MPL-2.0、EPL-2.0
强传染型Strong Copyleft—— 整个项目需以相同许可证开源:
GPL-2.0、GPL-3.0、AGPL-3.0
网络服务传染型Network Copyleft—— 通过网络提供服务也需开源:
AGPL-3.0
```
### 许可证兼容性矩阵
下表描述项目许可证(行)与依赖许可证(列)的兼容性:
| 项目 \ 依赖 | MIT | Apache-2.0 | GPL-2.0 | GPL-3.0 | LGPL-2.1 | AGPL-3.0 | MulanPSL-2.0 |
|-------------|-----|-----------|---------|---------|----------|----------|--------------|
| MIT | ✅ | ✅ | ❌ | ❌ | ⚠️ | ❌ | ✅ |
| Apache-2.0 | ✅ | ✅ | ❌ | ⚠️ | ⚠️ | ❌ | ✅ |
| GPL-2.0 | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ | ✅ |
| GPL-3.0 | ✅ | ✅ | ⚠️ | ✅ | ✅ | ⚠️ | ✅ |
| MulanPSL-2.0 | ✅ | ✅ | ❌ | ❌ | ⚠️ | ❌ | ✅ |
> ✅ 兼容 | ⚠️ 需谨慎(存在条件兼容场景)| ❌ 不兼容(引入此依赖可能违反许可证)
### 依赖项许可证扫描
```bash
# 获取仓库文件列表,定位依赖声明文件
git ls-tree --name-only HEAD
# 常见依赖文件:
# Go: go.mod
# Python: requirements.txt, setup.py, pyproject.toml
# Node: package.json
# Java: pom.xml, build.gradle
# Rust: Cargo.toml
# 读取依赖文件内容
git show HEAD:go.mod
```
### AI分析依赖许可证的步骤
1. 读取依赖声明文件,提取所有依赖包名和版本
2. 对每个依赖包,根据已知知识库判断其许可证类型
3. 对照兼容性矩阵,评估与项目主许可证的兼容性
4. 对不确定的依赖,标记为"需人工确认"
---
## 三、敏感信息扫描
### 扫描目标文件类型
```bash
# 获取仓库文件树,确定扫描范围
git ls-tree --name-only HEAD
# 递归列出子目录文件
git ls-tree -r --name-only HEAD
```
**高风险文件(优先扫描):**
```
配置文件:.env, .env.local, .env.production, config.yaml, config.json, settings.py
证书文件:*.pem, *.key, *.p12, *.pfx, *.crt, *.cer
SSH 密钥id_rsa, id_dsa, id_ecdsa, id_ed25519
数据库配置database.yml, db.conf, datasource.properties
CI 配置:.travis.yml, .github/workflows/*.yml, .gitlink-ci.yml
```
### 敏感信息特征模式
AI扫描文件内容时重点识别以下模式
```
密钥/Token 特征(正则):
AWS: AKIA[0-9A-Z]{16}
GitHub: ghp_[A-Za-z0-9]{36}
GitLink: glpat-[A-Za-z0-9\-_]{20}
私钥头: -----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----
通用密钥: (password|passwd|pwd|secret|token|apikey|api_key)\s*[=:]\s*['"]?[^\s'"]{8,}
数据库连接: (mysql|postgres|mongodb)://[^@]+:[^@]+@
IP/内部地址:
内网 IP: (192\.168\.|10\.\d+\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)
localhost: localhost:\d{4,5}(需结合上下文判断是否为敏感配置)
```
### 读取特定文件内容
```bash
# 读取配置文件内容(用于敏感信息检测)
git show HEAD:.env
# 文件不存在时会报错,可用以下方式判断文件是否存在
git ls-tree --name-only HEAD | grep "^\.env$"
```
### 敏感信息风险等级
| 风险等级 | 描述 | 处理建议 |
|---------|------|---------|
| 🔴 严重 | 真实密钥/Token 暴露在代码中 | 立即撤销密钥,从 Git 历史清除 |
| 🟠 高 | 密码/数据库连接串硬编码 | 替换为环境变量,清理历史提交 |
| 🟡 中 | 内网地址/测试账号泄露 | 评估影响范围,按需处理 |
| 🟢 低 | 疑似敏感但可能是示例数据 | 人工确认后决定是否处理 |
---
## 四、合规报告生成
### 完整合规报告模板
```markdown
# 📋 仓库合规检查报告
**仓库:** owner/repo
**检查时间:** 2026-05-07
**检查人:** AI Agentgitlink-license-compliance v1.0.0
---
## 一、许可证概览
| 项目 | 结果 |
|------|------|
| 主许可证 | ✅ MIT License |
| 许可证文件位置 | `LICENSE` |
| 商业使用 | ✅ 允许 |
| 闭源分发 | ✅ 允许 |
| 专利授权 | ⚠️ 无明确专利授权(建议升级为 Apache-2.0 |
---
## 二、依赖项许可证分析
| 依赖包 | 版本 | 许可证 | 兼容性 |
|--------|------|--------|--------|
| github.com/spf13/cobra | v1.8.0 | Apache-2.0 | ✅ 兼容 |
| github.com/stretchr/testify | v1.9.0 | MIT | ✅ 兼容 |
| gopkg.in/yaml.v3 | v3.0.1 | MIT / Apache-2.0 | ✅ 兼容 |
| golang.org/x/sys | v0.21.0 | BSD-3-Clause | ✅ 兼容 |
| example.com/gpl-lib | v1.0.0 | GPL-3.0 | ❌ 不兼容 |
### 不兼容项详情
**[1] example.com/gpl-libGPL-3.0**
- 问题GPL-3.0 要求整个项目以 GPL-3.0 发布,与 MIT 主许可证冲突
- 影响:若分发包含此依赖的产品,需将整个项目改为 GPL-3.0
- 建议:寻找 MIT/Apache-2.0 许可证的替代库,或与法务确认使用场景
---
## 三、敏感信息扫描结果
### 风险汇总
| 风险等级 | 数量 |
|---------|------|
| 🔴 严重 | 0 |
| 🟠 高 | 1 |
| 🟡 中 | 0 |
| 🟢 低 | 2 |
### 详细发现
**[高风险] config/database.yml**
- 位置:第 12 行
- 内容特征:`password: prod_db_pass_123`(疑似真实密码硬编码)
- 建议:替换为环境变量 `${DB_PASSWORD}`,并检查该密码是否已在生产环境使用
**[低风险] examples/demo.go**
- 位置:第 45 行
- 内容特征:`token: "example_token_for_demo"`(疑似示例 Token
- 建议:添加注释说明这是示例数据,如 `// TODO: replace with real token`
**[低风险] README.md**
- 位置:第 78 行
- 内容特征:内网地址 `192.168.1.100:8080`
- 建议:确认是否为文档示例,若是则无需处理
---
## 四、合规评分
| 评分维度 | 得分 | 满分 |
|---------|------|------|
| 许可证完整性 | 10 | 10 |
| 许可证兼容性 | 7 | 10 |
| 敏感信息管控 | 7 | 10 |
| **综合评分** | **24** | **30** |
**评级B — 基本合规,存在需整改项**
---
## 五、整改建议(按优先级)
### 必须处理P0
1. **移除 config/database.yml 中的硬编码密码**(高风险)
- 操作:将密码替换为环境变量
- 检查 Git 历史:`git log --all --follow -p config/database.yml | grep password`
- 如已有历史提交包含真实密码,需联系 GitLink 平台管理员清理
### 建议处理P1
2. **替换 GPL-3.0 依赖库** example.com/gpl-lib
- 评估是否有功能等价的宽松许可证替代库
- 若无替代,需在项目文档中说明 GPL-3.0 传染影响
### 可选优化P2
3. 将许可证从 MIT 升级为 Apache-2.0(获得专利保护)
4. 为 examples/ 目录添加 .gitignore 排除示例密钥文件
```
---
## 五、执行步骤总览
```bash
# Step 1获取仓库基本信息
gitlink-cli repo +info --owner <owner> --repo <repo> --format json
# Step 2克隆仓库到本地并获取根目录文件列表定位许可证和依赖文件
git clone https://www.gitlink.org.cn/<owner>/<repo>.git
cd <repo>
git ls-tree --name-only HEAD
# Step 3读取许可证文件内容
git show HEAD:LICENSE
# Step 4读取依赖声明文件
git show HEAD:go.mod
# 根据项目类型选择对应文件(如 requirements.txt / package.json / pom.xml
# Step 5递归列出所有文件读取高风险配置文件.env, config.yaml 等)
git ls-tree -r --name-only HEAD
git show HEAD:.env
# Step 6AI 综合分析所有数据,生成合规报告
```
---
## 注意事项
- ✅ **文件内容直接读取**:通过 `git show` 读取文件内容为明文,可直接分析
- ✅ **历史提交也需检查**:敏感信息即使已被删除,仍可能存在于 Git 历史中
- ⚠️ **依赖许可证以官方声明为准**AI推断可能不准确对不确定的依赖标注"需人工确认"
- ✅ **报告仅供参考**:许可证合规最终应由法务或专业人员确认
- ⚠️ **不自动修改代码**:本技能只做扫描和报告,不自动修改任何文件
- ⚠️ **不删除任何文件**:本技能不允许执行任何删除命令