Compare commits
7 Commits
master
...
feat/i18n-
| Author | SHA1 | Date |
|---|---|---|
|
|
85011dc659 | |
|
|
351b874045 | |
|
|
ceede4ea59 | |
|
|
9f0c63d2fb | |
|
|
29401c83e5 | |
|
|
8ad6cd65c2 | |
|
|
6b2d29e5f7 |
|
|
@ -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 ./...
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
gitlink-cli.exe
|
||||||
20
README.md
20
README.md
|
|
@ -41,7 +41,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
||||||
| Category | Capabilities |
|
| Category | Capabilities |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| 📦 Repo | List, create, fork, delete repositories, view repo info |
|
| 📦 Repo | List, create, fork, delete repositories, view repo info |
|
||||||
| 🐛 Issue | Create, update, close, batch close, comment on issues |
|
| 🐛 Issue | Create, update, close, batch maintenance, comment on issues |
|
||||||
| 🔀 PR | Create, merge, review pull requests, view changed files |
|
| 🔀 PR | Create, merge, review pull requests, view changed files |
|
||||||
| 🌿 Branch | Create, delete, list, protect, unprotect branches |
|
| 🌿 Branch | Create, delete, list, protect, unprotect branches |
|
||||||
| 🏷️ Release | Create, view, delete releases |
|
| 🏷️ Release | Create, view, delete releases |
|
||||||
|
|
@ -190,16 +190,26 @@ gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
|
||||||
# Close an issue
|
# Close an issue
|
||||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
||||||
|
|
||||||
# Preview batch close without changing data
|
# List batch maintenance candidates without changing data
|
||||||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run
|
gitlink-cli issue +batch-list --owner Gitlink --repo forgeplus --state open --older-than-days 30 --format table
|
||||||
|
|
||||||
# Batch close issues from a CSV file
|
# Preview batch close without changing data
|
||||||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
|
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --older-than-days 60 --limit 20
|
||||||
|
|
||||||
|
# Execute batch close after reviewing the dry-run output
|
||||||
|
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --older-than-days 60 --limit 20 --yes
|
||||||
|
|
||||||
|
# Preview adding a label to matching issues
|
||||||
|
gitlink-cli issue +batch-label --owner Gitlink --repo forgeplus --add-label stale --older-than-days 30 --limit 50
|
||||||
|
|
||||||
# Add a comment
|
# Add a comment
|
||||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
|
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Batch write commands default to dry-run mode. Pass `--yes` only after reviewing
|
||||||
|
the candidate list. `issue +batch-close` uses the existing issue close API;
|
||||||
|
`issue +batch-label` does not fake remote writes when a label endpoint is unavailable.
|
||||||
|
|
||||||
### Pull Requests
|
### Pull Requests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
| 分类 | 能力 |
|
| 分类 | 能力 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 |
|
| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 |
|
||||||
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
|
| 🐛 Issue | 创建、更新、关闭、批量维护、评论 Issue |
|
||||||
| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 |
|
| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 |
|
||||||
| 🌿 分支 | 创建、删除、保护分支 |
|
| 🌿 分支 | 创建、删除、保护分支 |
|
||||||
| 🏷️ 发布 | 创建、查看、删除 Release |
|
| 🏷️ 发布 | 创建、查看、删除 Release |
|
||||||
|
|
@ -201,16 +201,26 @@ gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123
|
||||||
# 关闭 Issue
|
# 关闭 Issue
|
||||||
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123
|
||||||
|
|
||||||
# 预览批量关闭,不修改数据
|
# 列出批量维护候选 Issue,不修改数据
|
||||||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run
|
gitlink-cli issue +batch-list --owner Gitlink --repo forgeplus --state open --older-than-days 30 --format table
|
||||||
|
|
||||||
# 从 CSV 文件批量关闭 Issue
|
# 预览批量关闭,不修改数据
|
||||||
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv
|
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --older-than-days 60 --limit 20
|
||||||
|
|
||||||
|
# 确认候选列表后执行批量关闭
|
||||||
|
gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --older-than-days 60 --limit 20 --yes
|
||||||
|
|
||||||
|
# 预览给匹配 Issue 添加标签
|
||||||
|
gitlink-cli issue +batch-label --owner Gitlink --repo forgeplus --add-label stale --older-than-days 30 --limit 50
|
||||||
|
|
||||||
# 添加评论
|
# 添加评论
|
||||||
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
|
gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
批量写操作默认 dry-run,仅输出候选列表。确认无误后再传入 `--yes`
|
||||||
|
执行远端写操作。`issue +batch-close` 复用已有关闭 Issue API;
|
||||||
|
`issue +batch-label` 在标签写入 API 不明确时不会伪造远端成功。
|
||||||
|
|
||||||
### Pull Request
|
### Pull Request
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,18 @@ import (
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewAPICmd() *cobra.Command {
|
func NewAPICmd(tr *i18n.Translator) *cobra.Command {
|
||||||
|
if tr == nil {
|
||||||
|
tr = i18n.Default()
|
||||||
|
}
|
||||||
apiCmd := &cobra.Command{
|
apiCmd := &cobra.Command{
|
||||||
Use: "api <METHOD> <PATH>",
|
Use: "api <METHOD> <PATH>",
|
||||||
Short: "Make raw API requests to GitLink",
|
Short: tr.T("cmd.api.short"),
|
||||||
Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`,
|
Long: tr.T("cmd.api.long"),
|
||||||
Example: ` gitlink-cli api GET /users/me
|
Example: ` gitlink-cli api GET /users/me
|
||||||
gitlink-cli api GET /projects --query 'page=1&limit=10'
|
gitlink-cli api GET /projects --query 'page=1&limit=10'
|
||||||
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`,
|
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`,
|
||||||
|
|
@ -25,9 +29,9 @@ func NewAPICmd() *cobra.Command {
|
||||||
RunE: runAPI,
|
RunE: runAPI,
|
||||||
}
|
}
|
||||||
|
|
||||||
apiCmd.Flags().String("body", "", "Request body (JSON string)")
|
apiCmd.Flags().String("body", "", tr.T("flag.api.body"))
|
||||||
apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)")
|
apiCmd.Flags().String("query", "", tr.T("flag.api.query"))
|
||||||
apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)")
|
apiCmd.Flags().StringSlice("header", nil, tr.T("flag.api.header"))
|
||||||
|
|
||||||
return apiCmd
|
return apiCmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
109
cmd/auth/auth.go
109
cmd/auth/auth.go
|
|
@ -2,139 +2,162 @@ package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
|
|
||||||
internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth"
|
internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
const envTokenVar = "GITLINK_TOKEN"
|
const envTokenVar = "GITLINK_TOKEN"
|
||||||
|
|
||||||
func NewAuthCmd() *cobra.Command {
|
var (
|
||||||
|
storeToken = internalAuth.StoreToken
|
||||||
|
loadToken = internalAuth.LoadToken
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewAuthCmd(tr *i18n.Translator) *cobra.Command {
|
||||||
|
if tr == nil {
|
||||||
|
tr = i18n.Default()
|
||||||
|
}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "auth",
|
Use: "auth",
|
||||||
Short: "Authentication commands",
|
Short: tr.T("cmd.auth.short"),
|
||||||
}
|
}
|
||||||
cmd.AddCommand(newLoginCmd())
|
cmd.AddCommand(newLoginCmd(tr))
|
||||||
cmd.AddCommand(newLogoutCmd())
|
cmd.AddCommand(newLogoutCmd(tr))
|
||||||
cmd.AddCommand(newStatusCmd())
|
cmd.AddCommand(newStatusCmd(tr))
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newLoginCmd() *cobra.Command {
|
func newLoginCmd(tr *i18n.Translator) *cobra.Command {
|
||||||
var tokenMode bool
|
var tokenMode bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "login",
|
Use: "login",
|
||||||
Short: "Login to GitLink",
|
Short: tr.T("cmd.auth.login.short"),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if tokenMode {
|
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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func loginWithPassword() error {
|
func loginWithPassword(in io.Reader, out io.Writer, tr *i18n.Translator) error {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(in)
|
||||||
|
fmt.Fprint(out, tr.T("prompt.auth.username"))
|
||||||
fmt.Print("Username/Email/Phone: ")
|
|
||||||
username, _ := reader.ReadString('\n')
|
username, _ := reader.ReadString('\n')
|
||||||
username = strings.TrimSpace(username)
|
username = strings.TrimSpace(username)
|
||||||
|
|
||||||
fmt.Print("Password: ")
|
fmt.Fprint(out, tr.T("prompt.auth.password"))
|
||||||
passwordBytes, err := term.ReadPassword(int(syscall.Stdin))
|
passwordBytes, err := readPassword(in, reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read password: %w", err)
|
return fmt.Errorf("failed to read password: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Fprintln(out)
|
||||||
password := string(passwordBytes)
|
password := string(passwordBytes)
|
||||||
|
|
||||||
result, err := internalAuth.Login(username, password)
|
result, err := internalAuth.Login(username, password)
|
||||||
if err != nil {
|
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)
|
fmt.Fprintln(out, tr.Tf("success.auth.logged_in_as", i18n.Args{"login": result.Login}))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loginWithToken() error {
|
func readPassword(in io.Reader, reader *bufio.Reader) ([]byte, error) {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
if file, ok := in.(*os.File); ok {
|
||||||
fmt.Print("Paste your token: ")
|
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)
|
||||||
|
fmt.Fprint(out, tr.T("prompt.auth.token"))
|
||||||
token, _ := reader.ReadString('\n')
|
token, _ := reader.ReadString('\n')
|
||||||
token = strings.TrimSpace(token)
|
token = strings.TrimSpace(token)
|
||||||
|
|
||||||
if 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 {
|
if err := storeToken(token); err != nil {
|
||||||
return fmt.Errorf("failed to store token: %w", err)
|
return errors.New(tr.Tf("error.auth.store_token_failed", i18n.Args{"message": err.Error()}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("✓ Token saved")
|
fmt.Fprintln(out, tr.T("success.auth.token_saved"))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newLogoutCmd() *cobra.Command {
|
func newLogoutCmd(tr *i18n.Translator) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "logout",
|
Use: "logout",
|
||||||
Short: "Logout from GitLink",
|
Short: tr.T("cmd.auth.logout.short"),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if err := internalAuth.DeleteToken(); err != nil {
|
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")
|
fmt.Fprintln(cmd.OutOrStdout(), tr.T("success.auth.logged_out"))
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStatusCmd() *cobra.Command {
|
func newStatusCmd(tr *i18n.Translator) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "status",
|
Use: "status",
|
||||||
Short: "Show authentication status",
|
Short: tr.T("cmd.auth.status.short"),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
// Check env var token first
|
// Check env var token first
|
||||||
if envToken := os.Getenv(envTokenVar); envToken != "" {
|
if envToken := os.Getenv(envTokenVar); envToken != "" {
|
||||||
fmt.Printf("✓ Logged in via %s environment variable\n", envTokenVar)
|
fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.auth.logged_in_via_env", i18n.Args{"env": envTokenVar}))
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := internalAuth.LoadToken()
|
token, err := loadToken()
|
||||||
if err != nil || token == "" {
|
if err != nil || token == "" {
|
||||||
if os.Getenv(envTokenVar) == "" {
|
if os.Getenv(envTokenVar) == "" {
|
||||||
fmt.Println("✗ Not logged in")
|
fmt.Fprintln(cmd.OutOrStdout(), tr.T("warning.auth.not_logged_in"))
|
||||||
fmt.Println(" Run: gitlink-cli auth login")
|
fmt.Fprintln(cmd.OutOrStdout(), tr.T("output.auth.login_hint"))
|
||||||
fmt.Printf(" Or set %s environment variable\n", envTokenVar)
|
fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.auth.env_hint", i18n.Args{"env": envTokenVar}))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := internalAuth.GetCurrentUser()
|
user, err := internalAuth.GetCurrentUser()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("✓ Token stored (but cannot verify: %v)\n", err)
|
fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("warning.auth.token_unverified", i18n.Args{"message": err.Error()}))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
login, _ := user["login"].(string)
|
login, _ := user["login"].(string)
|
||||||
name, _ := user["name"].(string)
|
name, _ := user["name"].(string)
|
||||||
if login != "" {
|
if login != "" {
|
||||||
fmt.Printf("✓ Logged in as %s", login)
|
text := tr.Tf("success.auth.logged_in_as", i18n.Args{"login": login})
|
||||||
if name != "" {
|
if name != "" {
|
||||||
fmt.Printf(" (%s)", name)
|
text = fmt.Sprintf("%s (%s)", text, name)
|
||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Fprintln(cmd.OutOrStdout(), text)
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("✓ Token stored (user info unavailable)")
|
fmt.Fprintln(cmd.OutOrStdout(), tr.T("warning.auth.user_unavailable"))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthTokenLoginUsesLocalizedPrompt(t *testing.T) {
|
||||||
|
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldStoreToken := storeToken
|
||||||
|
var storedToken string
|
||||||
|
storeToken = func(token string) error {
|
||||||
|
storedToken = token
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
storeToken = oldStoreToken
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewAuthCmd(tr)
|
||||||
|
cmd.SetArgs([]string{"login", "--token"})
|
||||||
|
cmd.SetIn(strings.NewReader("test-token\n"))
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.SetOut(&out)
|
||||||
|
cmd.SetErr(&out)
|
||||||
|
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if storedToken != "test-token" {
|
||||||
|
t.Fatalf("stored token = %q, want test-token", storedToken)
|
||||||
|
}
|
||||||
|
output := out.String()
|
||||||
|
if !strings.Contains(output, "粘贴你的访问 Token") {
|
||||||
|
t.Fatalf("expected localized token prompt, got:\n%s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "Token 已保存") {
|
||||||
|
t.Fatalf("expected localized token saved message, got:\n%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthStatusUsesLocalizedNotLoggedIn(t *testing.T) {
|
||||||
|
t.Setenv("GITLINK_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv(envTokenVar, "")
|
||||||
|
|
||||||
|
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldLoadToken := loadToken
|
||||||
|
loadToken = func() (string, error) {
|
||||||
|
return "", os.ErrNotExist
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
loadToken = oldLoadToken
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd := NewAuthCmd(tr)
|
||||||
|
cmd.SetArgs([]string{"status"})
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.SetOut(&out)
|
||||||
|
cmd.SetErr(&out)
|
||||||
|
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
output := out.String()
|
||||||
|
if !strings.Contains(output, "未登录") {
|
||||||
|
t.Fatalf("expected localized not logged in message, got:\n%s", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, "gitlink-cli auth login") {
|
||||||
|
t.Fatalf("expected login hint, got:\n%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,4 +6,5 @@ var (
|
||||||
Repo string
|
Repo string
|
||||||
Format string
|
Format string
|
||||||
Debug bool
|
Debug bool
|
||||||
|
Lang string
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,67 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
|
internalConfig "github.com/gitlink-org/gitlink-cli/internal/config"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewConfigCmd() *cobra.Command {
|
func NewConfigCmd(tr *i18n.Translator) *cobra.Command {
|
||||||
|
if tr == nil {
|
||||||
|
tr = i18n.Default()
|
||||||
|
}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "config",
|
Use: "config",
|
||||||
Short: "Manage gitlink-cli configuration",
|
Short: tr.T("cmd.config.short"),
|
||||||
}
|
}
|
||||||
cmd.AddCommand(newInitCmd())
|
cmd.AddCommand(newInitCmd(tr))
|
||||||
cmd.AddCommand(newSetCmd())
|
cmd.AddCommand(newSetCmd(tr))
|
||||||
cmd.AddCommand(newGetCmd())
|
cmd.AddCommand(newGetCmd(tr))
|
||||||
cmd.AddCommand(newListCmd())
|
cmd.AddCommand(newListCmd(tr))
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newInitCmd() *cobra.Command {
|
func newInitCmd(tr *i18n.Translator) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "init",
|
Use: "init",
|
||||||
Short: "Initialize configuration file",
|
Short: tr.T("cmd.config.init.short"),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cfg := internalConfig.DefaultConfig()
|
cfg := internalConfig.DefaultConfig()
|
||||||
if err := internalConfig.Save(cfg); err != nil {
|
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())
|
fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.initialized", i18n.Args{"path": internalConfig.ConfigPath()}))
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSetCmd() *cobra.Command {
|
func newSetCmd(tr *i18n.Translator) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "set <key> <value>",
|
Use: "set <key> <value>",
|
||||||
Short: "Set a configuration value",
|
Short: tr.T("cmd.config.set.short"),
|
||||||
Args: cobra.ExactArgs(2),
|
Args: cobra.ExactArgs(2),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if err := internalConfig.Set(args[0], args[1]); err != nil {
|
if err := internalConfig.Set(args[0], args[1]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("✓ %s = %s\n", args[0], args[1])
|
fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.set", i18n.Args{
|
||||||
|
"key": args[0],
|
||||||
|
"value": args[1],
|
||||||
|
}))
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newGetCmd() *cobra.Command {
|
func newGetCmd(tr *i18n.Translator) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "get <key>",
|
Use: "get <key>",
|
||||||
Short: "Get a configuration value",
|
Short: tr.T("cmd.config.get.short"),
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
val, err := internalConfig.Get(args[0])
|
val, err := internalConfig.Get(args[0])
|
||||||
|
|
@ -61,29 +69,31 @@ func newGetCmd() *cobra.Command {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if val == "" {
|
if val == "" {
|
||||||
fmt.Printf("%s: (not set)\n", args[0])
|
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], tr.T("output.config.not_set"))
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("%s: %s\n", args[0], val)
|
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], val)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newListCmd() *cobra.Command {
|
func newListCmd(tr *i18n.Translator) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List all configuration values",
|
Short: tr.T("cmd.config.list.short"),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
cfg, err := internalConfig.Load()
|
cfg, err := internalConfig.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Printf("base_url: %s\n", cfg.BaseURL)
|
fmt.Fprintf(cmd.OutOrStdout(), "base_url: %s\n", cfg.BaseURL)
|
||||||
fmt.Printf("default_format: %s\n", cfg.Format)
|
fmt.Fprintf(cmd.OutOrStdout(), "default_format: %s\n", cfg.Format)
|
||||||
fmt.Printf("editor: %s\n", cfg.Editor)
|
fmt.Fprintf(cmd.OutOrStdout(), "editor: %s\n", cfg.Editor)
|
||||||
fmt.Printf("pager: %s\n", cfg.Pager)
|
fmt.Fprintf(cmd.OutOrStdout(), "pager: %s\n", cfg.Pager)
|
||||||
fmt.Printf("\nConfig file: %s\n", internalConfig.ConfigPath())
|
fmt.Fprintf(cmd.OutOrStdout(), "lang: %s\n", cfg.Lang)
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout())
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.config.file", i18n.Args{"path": internalConfig.ConfigPath()}))
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigSetUsesCommandOutputAndI18n(t *testing.T) {
|
||||||
|
t.Setenv("GITLINK_CONFIG_DIR", t.TempDir())
|
||||||
|
|
||||||
|
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NewConfigCmd(tr)
|
||||||
|
cmd.SetArgs([]string{"set", "lang", "zh-CN"})
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.SetOut(&out)
|
||||||
|
cmd.SetErr(&out)
|
||||||
|
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
output := out.String()
|
||||||
|
if !strings.Contains(output, "已设置 lang = zh-CN") {
|
||||||
|
t.Fatalf("expected localized config set output, got:\n%s", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
120
cmd/root.go
120
cmd/root.go
|
|
@ -1,54 +1,126 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
|
||||||
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
|
|
||||||
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
|
apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api"
|
||||||
|
authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||||
configCmd "github.com/gitlink-org/gitlink-cli/cmd/config"
|
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"
|
"github.com/gitlink-org/gitlink-cli/shortcuts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version = "dev"
|
var Version = "dev"
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
type RootOptions struct {
|
||||||
Use: "gitlink-cli",
|
Version string
|
||||||
Short: "GitLink CLI — command-line tool for gitlink.org.cn",
|
Args []string
|
||||||
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.`,
|
Env map[string]string
|
||||||
SilenceUsage: true,
|
ConfigLang string
|
||||||
SilenceErrors: true,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) {
|
||||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "Repository owner (auto-detected from git remote)")
|
if tr == nil {
|
||||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "Repository name (auto-detected from git remote)")
|
var err error
|
||||||
rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "Output format: json, table, yaml (default: table)")
|
tr, err = newTranslator(opts.Args, opts.Env, opts.ConfigLang)
|
||||||
rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output")
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rootCmd.AddCommand(authCmd.NewAuthCmd())
|
version := opts.Version
|
||||||
rootCmd.AddCommand(apiCmd.NewAPICmd())
|
if version == "" {
|
||||||
rootCmd.AddCommand(configCmd.NewConfigCmd())
|
version = Version
|
||||||
rootCmd.AddCommand(versionCmd)
|
}
|
||||||
|
|
||||||
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{
|
func newVersionCmd(version string, tr *i18n.Translator) *cobra.Command {
|
||||||
Use: "version",
|
return &cobra.Command{
|
||||||
Short: "Print version information",
|
Use: "version",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Short: tr.T("cmd.version.short"),
|
||||||
fmt.Printf("gitlink-cli %s\n", Version)
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
},
|
fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.version", i18n.Args{"version": version}))
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Execute() error {
|
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 {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,314 @@
|
||||||
|
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{"issue", "+batch-close", "--help"},
|
||||||
|
want: []string{"批量关闭筛选后的议题", "--older-than-days", "必需的安全筛选条件", "--yes"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
args: []string{"pr", "+review", "--help"},
|
||||||
|
want: []string{"创建拉取请求评审", "--content", "评审内容", "--dry-run"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
root.SetOut(&out)
|
||||||
|
root.SetErr(&out)
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
t.Fatalf("%v: %v", tc.args, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
help := out.String()
|
||||||
|
for _, want := range tc.want {
|
||||||
|
if !strings.Contains(help, want) {
|
||||||
|
t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemainingShortcutHelpUsesSelectedLocale(t *testing.T) {
|
||||||
|
tr, err := i18n.New(i18n.Options{Locale: "zh-CN"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
args []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
args: []string{"branch", "+create", "--help"},
|
||||||
|
want: []string{"创建分支", "--from", "源分支或 Commit"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
args: []string{"release", "+create", "--help"},
|
||||||
|
want: []string{"创建发布", "--prerelease", "标记为预发布"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
args: []string{"webhook", "+create", "--help"},
|
||||||
|
want: []string{"创建仓库 Webhook", "--events", "逗号分隔的事件"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
args: []string{"ci", "+logs", "--help"},
|
||||||
|
want: []string{"查看构建日志", "--build", "构建编号"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
root.SetOut(&out)
|
||||||
|
root.SetErr(&out)
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
t.Fatalf("%v: %v", tc.args, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
help := out.String()
|
||||||
|
for _, want := range tc.want {
|
||||||
|
if !strings.Contains(help, want) {
|
||||||
|
t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionUsesInjectedVersion(t *testing.T) {
|
||||||
|
tr, err := i18n.New(i18n.Options{Locale: "en-US"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
root, err := NewRootCmd(RootOptions{Version: "1.2.3", Args: []string{"version"}}, tr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
root.SetOut(&out)
|
||||||
|
root.SetErr(&out)
|
||||||
|
if err := root.Execute(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := strings.TrimSpace(out.String()); got != "gitlink-cli 1.2.3" {
|
||||||
|
t.Fatalf("version output = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
# GitLink CLI i18n Infrastructure Summary
|
||||||
|
|
||||||
|
## Project Scope
|
||||||
|
|
||||||
|
This work introduces a maintainable localization infrastructure for GitLink CLI. It turns user-facing command help and flag descriptions into validated locale resources while keeping machine-readable outputs stable.
|
||||||
|
|
||||||
|
## Completed Capabilities
|
||||||
|
|
||||||
|
- Added `internal/i18n` as a dedicated localization package.
|
||||||
|
- Embedded locale JSON files with `go:embed`.
|
||||||
|
- Added `Translator` with `T` and `Tf` APIs.
|
||||||
|
- Added fallback behavior from selected locale to `en-US`, then to the message key.
|
||||||
|
- Added locale resolution from `--lang`, `GITLINK_LANG`, `config.lang`, `LC_ALL`, and `LANG`.
|
||||||
|
- Added locale normalization with exact matching and explicit alias matching for supported locales; unsupported locales fall back to the baseline language to avoid incorrect matches.
|
||||||
|
- Added simple `{name}` template rendering for parameterized messages.
|
||||||
|
- Added `go run ./internal/i18n/cmd/check` to validate key completeness, empty messages, key naming, and template argument consistency.
|
||||||
|
- Added GitHub Actions test workflow for i18n validation and Go tests.
|
||||||
|
|
||||||
|
## CLI Integration
|
||||||
|
|
||||||
|
- Reworked root command construction from package-level static initialization to `NewRootCmd(opts, tr)`.
|
||||||
|
- Added global `--lang` flag.
|
||||||
|
- Added `lang` support to the config model.
|
||||||
|
- Localized root command help, global flags, version help/output, and shortcut group descriptions.
|
||||||
|
- Localized command and flag help for:
|
||||||
|
- `api`
|
||||||
|
- `auth`
|
||||||
|
- `config`
|
||||||
|
- `repo`
|
||||||
|
- `issue`
|
||||||
|
- `pr`
|
||||||
|
- `branch`
|
||||||
|
- `release`
|
||||||
|
- `org`
|
||||||
|
- `user`
|
||||||
|
- `search`
|
||||||
|
- `ci`
|
||||||
|
- `webhook`
|
||||||
|
|
||||||
|
## Deliberate Non-Goals
|
||||||
|
|
||||||
|
The migration intentionally does not localize:
|
||||||
|
|
||||||
|
- JSON field names
|
||||||
|
- API response bodies
|
||||||
|
- raw API errors returned by the server
|
||||||
|
- debug diagnostics
|
||||||
|
- internal developer-only error context
|
||||||
|
|
||||||
|
This keeps scripted usage stable while improving human-facing CLI experience.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
The implementation has been validated with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go run ./internal/i18n/cmd/check
|
||||||
|
go run ./internal/i18n/cmd/check --scan-code
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Follow-Up Work
|
||||||
|
|
||||||
|
- Migrate selected runtime success/error messages where they are clearly user-facing.
|
||||||
|
- Consider a stricter scanner for new hardcoded help text.
|
||||||
|
- Add contributor documentation for adding a new locale.
|
||||||
|
|
@ -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` to validate referenced message keys. 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.
|
||||||
|
|
||||||
|
## Maintenance Checks
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Issue Batch Maintenance Design
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
This change adds focused Issue batch maintenance shortcuts for GitLink CLI. The
|
||||||
|
goal is to improve practical Issue management while keeping remote write
|
||||||
|
operations explicit and guarded.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### `issue +batch-list`
|
||||||
|
|
||||||
|
Lists candidate issues for batch maintenance. This command is read-only.
|
||||||
|
|
||||||
|
Supported filters:
|
||||||
|
|
||||||
|
- `--state`, default `open`
|
||||||
|
- `--label`
|
||||||
|
- `--older-than-days`
|
||||||
|
- `--limit`, capped at `100`
|
||||||
|
|
||||||
|
### `issue +batch-close`
|
||||||
|
|
||||||
|
Closes filtered issues in bulk. The command defaults to dry-run mode and only
|
||||||
|
executes remote close operations when `--yes` is passed.
|
||||||
|
|
||||||
|
Safety guards:
|
||||||
|
|
||||||
|
- requires `--older-than-days`
|
||||||
|
- rejects values lower than `7`
|
||||||
|
- caps `--limit` at `100`
|
||||||
|
- records per-issue failures without hiding partial errors
|
||||||
|
|
||||||
|
### `issue +batch-label`
|
||||||
|
|
||||||
|
Previews adding a label to filtered issues. It requires `--add-label` and
|
||||||
|
defaults to dry-run mode. The current implementation does not fake remote label
|
||||||
|
writes when a stable add-label API endpoint is unavailable.
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
`BatchIssueCandidate` is the stable item DTO for candidate issues:
|
||||||
|
|
||||||
|
- `number`
|
||||||
|
- `title`
|
||||||
|
- `state`
|
||||||
|
- `labels`
|
||||||
|
- `author`
|
||||||
|
- `updated_at`
|
||||||
|
- `days_inactive`
|
||||||
|
- `url`
|
||||||
|
|
||||||
|
`BatchIssueResult` wraps the command result with:
|
||||||
|
|
||||||
|
- `dry_run`
|
||||||
|
- `action`
|
||||||
|
- `total`
|
||||||
|
- `success`
|
||||||
|
- `failed`
|
||||||
|
- `candidates`
|
||||||
|
- `errors`
|
||||||
|
|
||||||
|
## API Strategy
|
||||||
|
|
||||||
|
The implementation reuses the existing shortcut runtime:
|
||||||
|
|
||||||
|
- `RuntimeContext.ResolveOwnerRepo`
|
||||||
|
- `RuntimeContext.CallAPIWithQuery`
|
||||||
|
- `RuntimeContext.CallAPI`
|
||||||
|
- existing `/v1/{owner}/{repo}/issues` paths
|
||||||
|
|
||||||
|
`issue +batch-close --yes` uses the existing issue close behavior: fetch the
|
||||||
|
current issue details first, then patch `status_id` to the closed status while
|
||||||
|
preserving title and description.
|
||||||
|
|
||||||
|
`issue +batch-label --yes` currently reports that the remote write endpoint is
|
||||||
|
unavailable instead of pretending to mutate labels.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
The batch shortcuts support:
|
||||||
|
|
||||||
|
- `table`
|
||||||
|
- `json`
|
||||||
|
|
||||||
|
Table output is optimized for terminal review. JSON output is stable enough for
|
||||||
|
scripts and agents.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This PR does not modify `internal/output`, does not introduce dependencies, and
|
||||||
|
does not add unrelated workflow-agent functionality.
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
# Issue Batch Maintenance Test Report
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The tests cover the Issue batch maintenance shortcuts:
|
||||||
|
|
||||||
|
- `issue +batch-list`
|
||||||
|
- `issue +batch-close`
|
||||||
|
- `issue +batch-label`
|
||||||
|
|
||||||
|
They focus on filtering, dry-run safety, output structure, and API behavior.
|
||||||
|
|
||||||
|
## Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gofmt -w shortcuts/issue/*.go shortcuts/common/*.go
|
||||||
|
go test ./shortcuts/issue
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Unit Coverage
|
||||||
|
|
||||||
|
Implemented coverage includes:
|
||||||
|
|
||||||
|
- batch-list filtering by state, label, and inactive days
|
||||||
|
- `older-than-days` filtering
|
||||||
|
- `limit` truncation behavior
|
||||||
|
- missing `updated_at` tolerance
|
||||||
|
- batch-close safety validation
|
||||||
|
- batch-close default dry-run behavior
|
||||||
|
- batch-close dry-run does not call `PATCH`
|
||||||
|
- batch-label requires `--add-label`
|
||||||
|
- JSON output can be unmarshaled
|
||||||
|
- table output is non-empty and includes dry-run or execution summary
|
||||||
|
- per-item write failures are recorded in `errors`
|
||||||
|
- httptest issue list response normalization
|
||||||
|
|
||||||
|
## Safety Verification
|
||||||
|
|
||||||
|
`issue +batch-close` refuses unsafe execution when:
|
||||||
|
|
||||||
|
- `--older-than-days` is missing
|
||||||
|
- `--older-than-days` is lower than `7`
|
||||||
|
- `--limit` is greater than `100`
|
||||||
|
|
||||||
|
`issue +batch-label` refuses missing `--add-label` and does not fake remote
|
||||||
|
label writes when the add-label endpoint is not implemented.
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
- `issue +batch-label --yes` reports an endpoint-unavailable error until a
|
||||||
|
stable label mutation endpoint is confirmed.
|
||||||
|
- The batch commands currently fetch the first page using the configured limit.
|
||||||
|
Larger multi-page workflows can be added later using the existing pagination
|
||||||
|
helper if maintainers want that behavior.
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
# feat(issue): add batch maintenance shortcuts
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This PR adds focused Issue batch maintenance shortcuts:
|
||||||
|
|
||||||
|
- `issue +batch-list`
|
||||||
|
- `issue +batch-close`
|
||||||
|
- `issue +batch-label`
|
||||||
|
|
||||||
|
The commands help maintainers inspect and act on groups of Issues with safe
|
||||||
|
defaults and structured output.
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
Maintainers often need to find stale or labeled Issues, preview a batch action,
|
||||||
|
and then execute it carefully. Existing single-Issue commands are useful, but
|
||||||
|
they do not provide a guarded batch maintenance workflow.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Add `issue +batch-list` for read-only candidate listing.
|
||||||
|
- Upgrade `issue +batch-close` to use filter-based selection, default dry-run,
|
||||||
|
and explicit `--yes` execution.
|
||||||
|
- Add `issue +batch-label` for dry-run label maintenance planning.
|
||||||
|
- Add batch DTOs and table/json rendering inside the issue shortcut package.
|
||||||
|
- Add long help text with examples and safety notes.
|
||||||
|
- Add tests and documentation for the batch workflow.
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
- `issue +batch-list` is read-only.
|
||||||
|
- `issue +batch-close` and `issue +batch-label` default to dry-run.
|
||||||
|
- `--yes` is required before any remote mutation is attempted.
|
||||||
|
- Batch close requires `--older-than-days`.
|
||||||
|
- Batch close rejects `--older-than-days` values lower than `7`.
|
||||||
|
- `--limit` is capped at `100`.
|
||||||
|
- Per-item failures are recorded in `errors` and do not hide partial failures.
|
||||||
|
- `issue +batch-label --yes` does not fake success when a label write endpoint
|
||||||
|
is unavailable.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gofmt -w shortcuts/issue/*.go shortcuts/common/*.go
|
||||||
|
go test ./shortcuts/issue
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Coverage includes filtering, dry-run behavior, JSON/table rendering, httptest
|
||||||
|
API list responses, and per-item write failure reporting.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- `README.md`
|
||||||
|
- `README.zh-CN.md`
|
||||||
|
- `docs/issue-batch-design.md`
|
||||||
|
- `docs/issue-batch-test-report.md`
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
- Label mutation is intentionally not executed until a stable GitLink add-label
|
||||||
|
endpoint is confirmed.
|
||||||
|
- The current candidate fetch uses one page with the requested limit.
|
||||||
|
|
@ -17,6 +17,7 @@ type Config struct {
|
||||||
Format string `yaml:"default_format"`
|
Format string `yaml:"default_format"`
|
||||||
Editor string `yaml:"editor,omitempty"`
|
Editor string `yaml:"editor,omitempty"`
|
||||||
Pager string `yaml:"pager,omitempty"`
|
Pager string `yaml:"pager,omitempty"`
|
||||||
|
Lang string `yaml:"lang,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfig() *Config {
|
func DefaultConfig() *Config {
|
||||||
|
|
@ -85,6 +86,8 @@ func Get(key string) (string, error) {
|
||||||
return cfg.Editor, nil
|
return cfg.Editor, nil
|
||||||
case "pager":
|
case "pager":
|
||||||
return cfg.Pager, nil
|
return cfg.Pager, nil
|
||||||
|
case "lang":
|
||||||
|
return cfg.Lang, nil
|
||||||
default:
|
default:
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +107,8 @@ func Set(key, value string) error {
|
||||||
cfg.Editor = value
|
cfg.Editor = value
|
||||||
case "pager":
|
case "pager":
|
||||||
cfg.Pager = value
|
cfg.Pager = value
|
||||||
|
case "lang":
|
||||||
|
cfg.Lang = value
|
||||||
}
|
}
|
||||||
return Save(cfg)
|
return Save(cfg)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
// Args contains named values used by parameterized messages.
|
||||||
|
type Args map[string]any
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
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, 0644); 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 filepath.Ext(path) != ".go" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(path, "_test.go") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
{
|
||||||
|
"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.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)"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
{
|
||||||
|
"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.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 已保存(用户信息不可用)"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package i18n
|
||||||
|
|
||||||
|
const defaultFallbackLocale = "en-US"
|
||||||
|
|
||||||
|
// Options controls Translator construction.
|
||||||
|
type Options struct {
|
||||||
|
Locale string
|
||||||
|
FallbackLocale string
|
||||||
|
Loader Loader
|
||||||
|
}
|
||||||
|
|
@ -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]
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "GitLink CLI locale messages",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"propertyNames": {
|
||||||
|
"pattern": "^(cmd|flag|error|output|prompt|table)\\.[a-z0-9_.-]+$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
PR Test 2026年 4月 7日 星期二 11时45分56秒 CST
|
|
||||||
|
|
@ -4,17 +4,19 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"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{
|
return []*common.Shortcut{
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List branches",
|
Description: tr.T("cmd.branch.list.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -32,10 +34,10 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "create",
|
Name: "create",
|
||||||
Description: "Create a branch",
|
Description: tr.T("cmd.branch.create.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "name", Short: "n", Usage: "Branch name", Required: true},
|
{Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true},
|
||||||
{Name: "from", Short: "f", Usage: "Source branch or commit", Default: "master"},
|
{Name: "from", Short: "f", Usage: tr.T("flag.branch.from"), Default: "master"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -59,9 +61,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "delete",
|
Name: "delete",
|
||||||
Description: "Delete a branch",
|
Description: tr.T("cmd.branch.delete.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -80,9 +82,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "protect",
|
Name: "protect",
|
||||||
Description: "Set branch protection",
|
Description: tr.T("cmd.branch.protect.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -101,9 +103,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "unprotect",
|
Name: "unprotect",
|
||||||
Description: "Remove branch protection",
|
Description: tr.T("cmd.branch.unprotect.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,19 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"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{
|
return []*common.Shortcut{
|
||||||
{
|
{
|
||||||
Name: "builds",
|
Name: "builds",
|
||||||
Description: "List CI builds",
|
Description: tr.T("cmd.ci.builds.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -32,11 +34,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "logs",
|
Name: "logs",
|
||||||
Description: "View build logs",
|
Description: tr.T("cmd.ci.logs.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "build", Short: "b", Usage: "Build number", Required: true},
|
{Name: "build", Short: "b", Usage: tr.T("flag.ci.build"), Required: true},
|
||||||
{Name: "stage", Short: "s", Usage: "Stage number", Default: "1"},
|
{Name: "stage", Short: "s", Usage: tr.T("flag.ci.stage"), Default: "1"},
|
||||||
{Name: "step", Usage: "Step number", Default: "1"},
|
{Name: "step", Usage: tr.T("flag.ci.step"), Default: "1"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -60,9 +62,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "restart",
|
Name: "restart",
|
||||||
Description: "Restart a build",
|
Description: tr.T("cmd.ci.restart.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -78,9 +80,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "stop",
|
Name: "stop",
|
||||||
Description: "Stop a build",
|
Description: tr.T("cmd.ci.stop.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,20 @@ package common
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand.
|
// 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{
|
cmd := &cobra.Command{
|
||||||
Use: "+" + s.Name,
|
Use: "+" + s.Name,
|
||||||
Short: s.Description,
|
Short: s.Description,
|
||||||
|
Long: s.Long,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
// Collect flag values
|
// Collect flag values
|
||||||
flagValues := make(map[string]string)
|
flagValues := make(map[string]string)
|
||||||
|
|
@ -26,10 +32,16 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, err := NewRuntimeContext(flagValues)
|
ctx, err := NewRuntimeContext(flagValues, tr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
for _, f := range s.Flags {
|
||||||
|
if f.Required && flagValues[f.Name] == "" {
|
||||||
|
_, err := ctx.RequireArg(f.Name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return s.Run(ctx)
|
return s.Run(ctx)
|
||||||
},
|
},
|
||||||
|
|
@ -48,17 +60,18 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) {
|
||||||
} else {
|
} else {
|
||||||
cmd.Flags().String(f.Name, f.Default, f.Usage)
|
cmd.Flags().String(f.Name, f.Default, f.Usage)
|
||||||
}
|
}
|
||||||
if f.Required {
|
|
||||||
cmd.MarkFlagRequired(f.Name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
parent.AddCommand(cmd)
|
parent.AddCommand(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MountShortcuts mounts multiple shortcuts under a parent command.
|
// 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 {
|
for _, s := range shortcuts {
|
||||||
MountShortcut(parent, s)
|
MountShortcut(parent, s, tr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/context"
|
"github.com/gitlink-org/gitlink-cli/internal/context"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -15,6 +17,7 @@ import (
|
||||||
type Shortcut struct {
|
type Shortcut struct {
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
|
Long string
|
||||||
Flags []Flag
|
Flags []Flag
|
||||||
Run func(ctx *RuntimeContext) error
|
Run func(ctx *RuntimeContext) error
|
||||||
}
|
}
|
||||||
|
|
@ -36,10 +39,14 @@ type RuntimeContext struct {
|
||||||
Repo string
|
Repo string
|
||||||
Format string
|
Format string
|
||||||
Args map[string]string
|
Args map[string]string
|
||||||
|
Tr *i18n.Translator
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo.
|
// NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo.
|
||||||
func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) {
|
func NewRuntimeContext(args map[string]string, tr *i18n.Translator) (*RuntimeContext, error) {
|
||||||
|
if tr == nil {
|
||||||
|
tr = i18n.Default()
|
||||||
|
}
|
||||||
cli, err := client.New()
|
cli, err := client.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -57,6 +64,7 @@ func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) {
|
||||||
Repo: cmdutil.Repo,
|
Repo: cmdutil.Repo,
|
||||||
Format: format,
|
Format: format,
|
||||||
Args: args,
|
Args: args,
|
||||||
|
Tr: tr,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,7 +121,7 @@ func (ctx *RuntimeContext) Arg(name string) string {
|
||||||
func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
|
func (ctx *RuntimeContext) RequireArg(name string) (string, error) {
|
||||||
v := ctx.Arg(name)
|
v := ctx.Arg(name)
|
||||||
if v == "" {
|
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
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,102 +1,449 @@
|
||||||
package issue
|
package issue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
const closedIssueStatusID = 5
|
const (
|
||||||
|
closedIssueStatusID = 5
|
||||||
|
maxBatchLimit = 100
|
||||||
|
)
|
||||||
|
|
||||||
type batchCloseResult struct {
|
type BatchIssueCandidate struct {
|
||||||
Number string `json:"number" yaml:"number"`
|
Number int `json:"number"`
|
||||||
Action string `json:"action" yaml:"action"`
|
Title string `json:"title"`
|
||||||
Status string `json:"status" yaml:"status"`
|
State string `json:"state"`
|
||||||
Error string `json:"error,omitempty" yaml:"error,omitempty"`
|
Labels []string `json:"labels,omitempty"`
|
||||||
|
Author string `json:"author,omitempty"`
|
||||||
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
|
DaysInactive int `json:"days_inactive,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type batchCloseSummary struct {
|
type BatchIssueResult struct {
|
||||||
Repository string `json:"repository" yaml:"repository"`
|
Owner string `json:"owner"`
|
||||||
DryRun bool `json:"dry_run" yaml:"dry_run"`
|
Repo string `json:"repo"`
|
||||||
Total int `json:"total" yaml:"total"`
|
DryRun bool `json:"dry_run"`
|
||||||
Succeeded int `json:"succeeded" yaml:"succeeded"`
|
Action string `json:"action"`
|
||||||
Failed int `json:"failed" yaml:"failed"`
|
Total int `json:"total"`
|
||||||
Results []batchCloseResult `json:"results" yaml:"results"`
|
Success int `json:"success"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Candidates []BatchIssueCandidate `json:"candidates"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBatchCloseShortcut() *common.Shortcut {
|
type batchIssueOptions struct {
|
||||||
|
Owner string
|
||||||
|
Repo string
|
||||||
|
State string
|
||||||
|
Label string
|
||||||
|
AddLabel string
|
||||||
|
OlderThanDays int
|
||||||
|
Limit int
|
||||||
|
Yes bool
|
||||||
|
Reason string
|
||||||
|
Action string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBatchListShortcut(tr *i18n.Translator) *common.Shortcut {
|
||||||
|
return &common.Shortcut{
|
||||||
|
Name: "batch-list",
|
||||||
|
Description: tr.T("cmd.issue.batch_list.short"),
|
||||||
|
Long: tr.T("cmd.issue.batch_list.long"),
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "state", Short: "s", Usage: tr.T("flag.issue.state"), Default: "open"},
|
||||||
|
{Name: "label", Usage: tr.T("flag.issue.label_filter")},
|
||||||
|
{Name: "older-than-days", Usage: tr.T("flag.issue.older_than_days")},
|
||||||
|
{Name: "limit", Short: "l", Usage: tr.T("flag.issue.batch_list.limit"), Default: "50"},
|
||||||
|
},
|
||||||
|
Run: runBatchList,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBatchCloseShortcut(tr *i18n.Translator) *common.Shortcut {
|
||||||
return &common.Shortcut{
|
return &common.Shortcut{
|
||||||
Name: "batch-close",
|
Name: "batch-close",
|
||||||
Description: "Close multiple issues by issue numbers or a CSV file",
|
Description: tr.T("cmd.issue.batch_close.short"),
|
||||||
|
Long: tr.T("cmd.issue.batch_close.long"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"},
|
{Name: "state", Short: "s", Usage: tr.T("flag.issue.batch_close.state"), Default: "open"},
|
||||||
{Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"},
|
{Name: "label", Usage: tr.T("flag.issue.label_filter")},
|
||||||
{Name: "dry-run", Usage: "Preview the issues that would be closed without changing them", Bool: true, Default: "false"},
|
{Name: "older-than-days", Usage: tr.T("flag.issue.batch_close.older_than_days")},
|
||||||
|
{Name: "limit", Short: "l", Usage: tr.T("flag.issue.batch_process.limit"), Default: "20"},
|
||||||
|
{Name: "yes", Usage: tr.T("flag.issue.batch.yes"), Bool: true, Default: "false"},
|
||||||
|
{Name: "reason", Usage: tr.T("flag.issue.batch.reason")},
|
||||||
},
|
},
|
||||||
Run: runBatchClose,
|
Run: runBatchClose,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runBatchClose(ctx *common.RuntimeContext) error {
|
func newBatchLabelShortcut(tr *i18n.Translator) *common.Shortcut {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
return &common.Shortcut{
|
||||||
return err
|
Name: "batch-label",
|
||||||
|
Description: tr.T("cmd.issue.batch_label.short"),
|
||||||
|
Long: tr.T("cmd.issue.batch_label.long"),
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "state", Short: "s", Usage: tr.T("flag.issue.batch_label.state"), Default: "open"},
|
||||||
|
{Name: "label", Usage: tr.T("flag.issue.label_filter")},
|
||||||
|
{Name: "add-label", Usage: tr.T("flag.issue.add_label")},
|
||||||
|
{Name: "older-than-days", Usage: tr.T("flag.issue.older_than_days")},
|
||||||
|
{Name: "limit", Short: "l", Usage: tr.T("flag.issue.batch_process.limit"), Default: "50"},
|
||||||
|
{Name: "yes", Usage: tr.T("flag.issue.batch.yes"), Bool: true, Default: "false"},
|
||||||
|
},
|
||||||
|
Run: runBatchLabel,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from"))
|
func runBatchList(ctx *common.RuntimeContext) error {
|
||||||
|
opts, err := batchOptionsFromContext(ctx, "list")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(numbers) == 0 {
|
candidates, err := fetchBatchIssueCandidates(ctx, opts)
|
||||||
return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv")
|
if err != nil {
|
||||||
}
|
|
||||||
|
|
||||||
dryRun := parseBool(ctx.Arg("dry-run"))
|
|
||||||
summary := batchCloseSummary{
|
|
||||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
|
||||||
DryRun: dryRun,
|
|
||||||
Total: len(numbers),
|
|
||||||
Results: make([]batchCloseResult, 0, len(numbers)),
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, number := range numbers {
|
|
||||||
result := batchCloseResult{Number: number, Action: "close"}
|
|
||||||
if dryRun {
|
|
||||||
result.Status = "planned"
|
|
||||||
summary.Succeeded++
|
|
||||||
summary.Results = append(summary.Results, result)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := closeIssue(ctx, number); err != nil {
|
|
||||||
result.Status = "failed"
|
|
||||||
result.Error = err.Error()
|
|
||||||
summary.Failed++
|
|
||||||
} else {
|
|
||||||
result.Status = "closed"
|
|
||||||
summary.Succeeded++
|
|
||||||
}
|
|
||||||
summary.Results = append(summary.Results, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ctx.OutputData(summary); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if summary.Failed > 0 {
|
result := BatchIssueResult{
|
||||||
return fmt.Errorf("%d of %d issue(s) failed to close", summary.Failed, summary.Total)
|
Owner: ctx.Owner,
|
||||||
|
Repo: ctx.Repo,
|
||||||
|
DryRun: true,
|
||||||
|
Action: "list",
|
||||||
|
Total: len(candidates),
|
||||||
|
Success: len(candidates),
|
||||||
|
Candidates: candidates,
|
||||||
|
}
|
||||||
|
return renderBatchIssueResult(os.Stdout, result, batchOutputFormat(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBatchClose(ctx *common.RuntimeContext) error {
|
||||||
|
opts, err := batchOptionsFromContext(ctx, "close")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateBatchCloseOptions(opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
candidates, err := fetchBatchIssueCandidates(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result := BatchIssueResult{
|
||||||
|
Owner: ctx.Owner,
|
||||||
|
Repo: ctx.Repo,
|
||||||
|
DryRun: !opts.Yes,
|
||||||
|
Action: "close",
|
||||||
|
Total: len(candidates),
|
||||||
|
Candidates: candidates,
|
||||||
|
Reason: opts.Reason,
|
||||||
|
}
|
||||||
|
if !opts.Yes {
|
||||||
|
result.Success = len(candidates)
|
||||||
|
return renderBatchIssueResult(os.Stdout, result, batchOutputFormat(ctx))
|
||||||
|
}
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
if err := closeIssue(ctx, strconv.Itoa(candidate.Number)); err != nil {
|
||||||
|
result.Failed++
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("#%d: %v", candidate.Number, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Success++
|
||||||
|
}
|
||||||
|
if err := renderBatchIssueResult(os.Stdout, result, batchOutputFormat(ctx)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.Failed > 0 {
|
||||||
|
return fmt.Errorf("%d of %d issue(s) failed to close", result.Failed, result.Total)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runBatchLabel(ctx *common.RuntimeContext) error {
|
||||||
|
opts, err := batchOptionsFromContext(ctx, "label")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateBatchLabelOptions(opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
candidates, err := fetchBatchIssueCandidates(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result := BatchIssueResult{
|
||||||
|
Owner: ctx.Owner,
|
||||||
|
Repo: ctx.Repo,
|
||||||
|
DryRun: !opts.Yes,
|
||||||
|
Action: "add-label:" + opts.AddLabel,
|
||||||
|
Total: len(candidates),
|
||||||
|
Candidates: candidates,
|
||||||
|
}
|
||||||
|
if !opts.Yes {
|
||||||
|
result.Success = len(candidates)
|
||||||
|
return renderBatchIssueResult(os.Stdout, result, batchOutputFormat(ctx))
|
||||||
|
}
|
||||||
|
result.Failed = len(candidates)
|
||||||
|
for _, candidate := range candidates {
|
||||||
|
result.Errors = append(result.Errors, fmt.Sprintf("#%d: write endpoint unavailable for add-label", candidate.Number))
|
||||||
|
}
|
||||||
|
if err := renderBatchIssueResult(os.Stdout, result, batchOutputFormat(ctx)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if result.Failed > 0 {
|
||||||
|
return fmt.Errorf("batch-label remote write is not implemented because the add-label endpoint is not defined")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchOptionsFromContext(ctx *common.RuntimeContext, action string) (batchIssueOptions, error) {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return batchIssueOptions{}, err
|
||||||
|
}
|
||||||
|
limit, err := parseBatchInt(ctx.Arg("limit"), defaultBatchLimit(action), "limit")
|
||||||
|
if err != nil {
|
||||||
|
return batchIssueOptions{}, err
|
||||||
|
}
|
||||||
|
if limit > maxBatchLimit {
|
||||||
|
return batchIssueOptions{}, fmt.Errorf("--limit must be <= %d", maxBatchLimit)
|
||||||
|
}
|
||||||
|
olderThanDays, err := parseBatchInt(ctx.Arg("older-than-days"), 0, "older-than-days")
|
||||||
|
if err != nil {
|
||||||
|
return batchIssueOptions{}, err
|
||||||
|
}
|
||||||
|
return batchIssueOptions{
|
||||||
|
Owner: ctx.Owner,
|
||||||
|
Repo: ctx.Repo,
|
||||||
|
State: defaultString(ctx.Arg("state"), "open"),
|
||||||
|
Label: strings.TrimSpace(ctx.Arg("label")),
|
||||||
|
AddLabel: strings.TrimSpace(ctx.Arg("add-label")),
|
||||||
|
OlderThanDays: olderThanDays,
|
||||||
|
Limit: limit,
|
||||||
|
Yes: parseBool(ctx.Arg("yes")),
|
||||||
|
Reason: strings.TrimSpace(ctx.Arg("reason")),
|
||||||
|
Action: action,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultBatchLimit(action string) int {
|
||||||
|
switch action {
|
||||||
|
case "close":
|
||||||
|
return 20
|
||||||
|
default:
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateBatchCloseOptions(opts batchIssueOptions) error {
|
||||||
|
if opts.OlderThanDays == 0 {
|
||||||
|
return fmt.Errorf("issue +batch-close requires --older-than-days as a safety filter")
|
||||||
|
}
|
||||||
|
if opts.OlderThanDays < 7 {
|
||||||
|
return fmt.Errorf("--older-than-days must be at least 7 for issue +batch-close")
|
||||||
|
}
|
||||||
|
return validateBatchHasFilter(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateBatchLabelOptions(opts batchIssueOptions) error {
|
||||||
|
if opts.AddLabel == "" {
|
||||||
|
return fmt.Errorf("issue +batch-label requires --add-label")
|
||||||
|
}
|
||||||
|
return validateBatchHasFilter(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateBatchHasFilter(opts batchIssueOptions) error {
|
||||||
|
if opts.Label == "" && opts.OlderThanDays == 0 {
|
||||||
|
return fmt.Errorf("refusing batch operation without a label or older-than-days filter")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchBatchIssueCandidates(ctx *common.RuntimeContext, opts batchIssueOptions) ([]BatchIssueCandidate, error) {
|
||||||
|
query := url.Values{}
|
||||||
|
query.Set("page", "1")
|
||||||
|
query.Set("limit", strconv.Itoa(opts.Limit))
|
||||||
|
if opts.State != "" && opts.State != "all" {
|
||||||
|
query.Set("state", opts.State)
|
||||||
|
}
|
||||||
|
if opts.Label != "" {
|
||||||
|
query.Set("label", opts.Label)
|
||||||
|
}
|
||||||
|
env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
candidates := normalizeBatchIssueCandidates(env, opts)
|
||||||
|
if len(candidates) > opts.Limit {
|
||||||
|
candidates = candidates[:opts.Limit]
|
||||||
|
}
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBatchIssueCandidates(env *output.Envelope, opts batchIssueOptions) []BatchIssueCandidate {
|
||||||
|
items := extractBatchIssueItems(env)
|
||||||
|
candidates := make([]BatchIssueCandidate, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
candidate := batchCandidateFromMap(item)
|
||||||
|
if candidate.Number == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !batchCandidateMatches(candidate, opts) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
candidates = append(candidates, candidate)
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractBatchIssueItems(env *output.Envelope) []map[string]interface{} {
|
||||||
|
if env == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, ok := env.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, key := range []string{"issues", "data"} {
|
||||||
|
raw, ok := data[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if items := mapsFromInterfaceSlice(raw); len(items) > 0 {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if items := mapsFromInterfaceSlice(env.Data); len(items) > 0 {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapsFromInterfaceSlice(value interface{}) []map[string]interface{} {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
out := make([]map[string]interface{}, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
if m, ok := item.(map[string]interface{}); ok {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchCandidateFromMap(item map[string]interface{}) BatchIssueCandidate {
|
||||||
|
updatedAt := firstBatchString(item, "updated_at", "updatedAt", "last_activity_at", "lastActivityAt")
|
||||||
|
return BatchIssueCandidate{
|
||||||
|
Number: firstBatchInt(item, "number", "project_issues_index", "iid", "id"),
|
||||||
|
Title: firstBatchString(item, "title", "subject", "name"),
|
||||||
|
State: firstBatchString(item, "state", "status", "status_name"),
|
||||||
|
Labels: batchLabels(item["labels"]),
|
||||||
|
Author: batchAuthor(item),
|
||||||
|
UpdatedAt: updatedAt,
|
||||||
|
DaysInactive: daysInactive(updatedAt),
|
||||||
|
URL: firstBatchString(item, "url", "html_url", "web_url"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchCandidateMatches(candidate BatchIssueCandidate, opts batchIssueOptions) bool {
|
||||||
|
if opts.State != "" && opts.State != "all" && candidate.State != "" && !strings.EqualFold(candidate.State, opts.State) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if opts.Label != "" && !candidateHasLabel(candidate, opts.Label) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if opts.OlderThanDays > 0 {
|
||||||
|
if candidate.UpdatedAt == "" || candidate.DaysInactive < opts.OlderThanDays {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func candidateHasLabel(candidate BatchIssueCandidate, label string) bool {
|
||||||
|
for _, existing := range candidate.Labels {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(existing), strings.TrimSpace(label)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBatchIssueResult(w io.Writer, result BatchIssueResult, format string) error {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||||
|
case "", "table":
|
||||||
|
return renderBatchIssueTable(w, result)
|
||||||
|
case "json":
|
||||||
|
data, err := json.MarshalIndent(result, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintln(w, string(data))
|
||||||
|
return err
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported format %q: use table or json", format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBatchIssueTable(w io.Writer, result BatchIssueResult) error {
|
||||||
|
mode := "DRY-RUN"
|
||||||
|
if !result.DryRun {
|
||||||
|
mode = "EXECUTED"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s ACTION=%s TOTAL=%d SUCCESS=%d FAILED=%d\n", mode, result.Action, result.Total, result.Success, result.Failed)
|
||||||
|
if result.Reason != "" {
|
||||||
|
fmt.Fprintf(w, "REASON: %s\n", result.Reason)
|
||||||
|
}
|
||||||
|
if len(result.Candidates) > 0 {
|
||||||
|
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "NUMBER\tSTATE\tDAYS_INACTIVE\tLABELS\tTITLE")
|
||||||
|
for _, candidate := range result.Candidates {
|
||||||
|
fmt.Fprintf(tw, "%d\t%s\t%d\t%s\t%s\n",
|
||||||
|
candidate.Number,
|
||||||
|
candidate.State,
|
||||||
|
candidate.DaysInactive,
|
||||||
|
strings.Join(candidate.Labels, ","),
|
||||||
|
candidate.Title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err := tw.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, errText := range result.Errors {
|
||||||
|
fmt.Fprintf(w, "ERROR: %s\n", errText)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchOutputFormat(ctx *common.RuntimeContext) string {
|
||||||
|
if strings.TrimSpace(cmdutil.Format) == "" {
|
||||||
|
return "table"
|
||||||
|
}
|
||||||
|
return ctx.Format
|
||||||
|
}
|
||||||
|
|
||||||
func closeIssue(ctx *common.RuntimeContext, number string) error {
|
func closeIssue(ctx *common.RuntimeContext, number string) error {
|
||||||
current, err := fetchExistingIssue(ctx, number)
|
current, err := fetchExistingIssue(ctx, number)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("fetch issue: %w", err)
|
return fmt.Errorf("fetch issue: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
"subject": current.Subject,
|
"subject": current.Subject,
|
||||||
"description": current.Description,
|
"description": current.Description,
|
||||||
|
|
@ -108,6 +455,147 @@ func closeIssue(ctx *common.RuntimeContext, number string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseBatchInt(value string, defaultValue int, name string) (int, error) {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return defaultValue, nil
|
||||||
|
}
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid --%s %q: must be an integer", name, value)
|
||||||
|
}
|
||||||
|
if parsed < 0 {
|
||||||
|
return 0, fmt.Errorf("invalid --%s %q: must be >= 0", name, value)
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func daysInactive(value string) int {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
updated, err := parseBatchTime(value)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
days := int(time.Since(updated).Hours() / 24)
|
||||||
|
if days < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBatchTime(value string) (time.Time, error) {
|
||||||
|
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
|
||||||
|
if parsed, err := time.Parse(layout, value); err == nil {
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Time{}, fmt.Errorf("unsupported time %q", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstBatchString(item map[string]interface{}, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value, ok := item[key]; ok {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(typed)
|
||||||
|
case map[string]interface{}:
|
||||||
|
if text := firstBatchString(typed, "name", "login", "title"); text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if value != nil {
|
||||||
|
return strings.TrimSpace(fmt.Sprint(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstBatchInt(item map[string]interface{}, keys ...string) int {
|
||||||
|
for _, key := range keys {
|
||||||
|
value, ok := item[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case int:
|
||||||
|
return typed
|
||||||
|
case int64:
|
||||||
|
return int(typed)
|
||||||
|
case float64:
|
||||||
|
return int(typed)
|
||||||
|
case json.Number:
|
||||||
|
parsed, _ := typed.Int64()
|
||||||
|
return int(parsed)
|
||||||
|
case string:
|
||||||
|
parsed, _ := strconv.Atoi(strings.TrimSpace(typed))
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchLabels(value interface{}) []string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case []interface{}:
|
||||||
|
out := make([]string, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
switch label := item.(type) {
|
||||||
|
case string:
|
||||||
|
out = append(out, label)
|
||||||
|
case map[string]interface{}:
|
||||||
|
if text := firstBatchString(label, "name", "title", "label"); text != "" {
|
||||||
|
out = append(out, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case []string:
|
||||||
|
return append([]string(nil), typed...)
|
||||||
|
case string:
|
||||||
|
if strings.TrimSpace(typed) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(typed, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
if text := strings.TrimSpace(part); text != "" {
|
||||||
|
out = append(out, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchAuthor(item map[string]interface{}) string {
|
||||||
|
for _, key := range []string{"author", "user", "creator"} {
|
||||||
|
value, ok := item[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
return typed
|
||||||
|
case map[string]interface{}:
|
||||||
|
if text := firstBatchString(typed, "login", "name", "username"); text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultString(value, fallback string) string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
|
||||||
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
|
func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
|
||||||
numbers, err := parseIssueNumbers(numbersValue)
|
numbers, err := parseIssueNumbers(numbersValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -116,7 +604,6 @@ func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) {
|
||||||
if csvPath == "" {
|
if csvPath == "" {
|
||||||
return numbers, nil
|
return numbers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
csvNumbers, err := readIssueNumbersFromCSV(csvPath)
|
csvNumbers, err := readIssueNumbersFromCSV(csvPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -147,7 +634,6 @@ func readIssueNumbersFromCSV(path string) ([]string, error) {
|
||||||
if len(records) == 0 {
|
if len(records) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
numberColumn := -1
|
numberColumn := -1
|
||||||
startRow := 0
|
startRow := 0
|
||||||
for i, cell := range records[0] {
|
for i, cell := range records[0] {
|
||||||
|
|
@ -160,7 +646,6 @@ func readIssueNumbersFromCSV(path string) ([]string, error) {
|
||||||
if numberColumn == -1 {
|
if numberColumn == -1 {
|
||||||
numberColumn = 0
|
numberColumn = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
values := make([]string, 0, len(records)-startRow)
|
values := make([]string, 0, len(records)-startRow)
|
||||||
for _, record := range records[startRow:] {
|
for _, record := range records[startRow:] {
|
||||||
if numberColumn >= len(record) {
|
if numberColumn >= len(record) {
|
||||||
|
|
@ -210,3 +695,9 @@ func parseBool(value string) bool {
|
||||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||||
return err == nil && parsed
|
return err == nil && parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderBatchIssueResultString(result BatchIssueResult, format string) (string, error) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := renderBatchIssueResult(&buf, result, format)
|
||||||
|
return buf.String(), err
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
package issue
|
package issue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseIssueNumbers(t *testing.T) {
|
func TestParseIssueNumbers(t *testing.T) {
|
||||||
|
|
@ -81,6 +86,169 @@ func TestParseBool(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIssueShortcutsExposeBatchCommands(t *testing.T) {
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, shortcut := range Shortcuts() {
|
||||||
|
names[shortcut.Name] = true
|
||||||
|
}
|
||||||
|
for _, want := range []string{"batch-list", "batch-close", "batch-label"} {
|
||||||
|
if !names[want] {
|
||||||
|
t.Fatalf("Shortcuts missing %s", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchListFiltersOlderThanDaysAndLabel(t *testing.T) {
|
||||||
|
old := time.Now().AddDate(0, 0, -45).Format(time.RFC3339)
|
||||||
|
fresh := time.Now().AddDate(0, 0, -3).Format(time.RFC3339)
|
||||||
|
env := batchIssueEnvelope([]map[string]interface{}{
|
||||||
|
{
|
||||||
|
"project_issues_index": 1,
|
||||||
|
"subject": "stale bug",
|
||||||
|
"status": "open",
|
||||||
|
"labels": []interface{}{"bug", "stale"},
|
||||||
|
"updated_at": old,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project_issues_index": 2,
|
||||||
|
"subject": "fresh bug",
|
||||||
|
"status": "open",
|
||||||
|
"labels": []interface{}{"bug"},
|
||||||
|
"updated_at": fresh,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"project_issues_index": 3,
|
||||||
|
"subject": "stale docs",
|
||||||
|
"status": "open",
|
||||||
|
"labels": []interface{}{"docs"},
|
||||||
|
"updated_at": old,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
got := normalizeBatchIssueCandidates(env, batchIssueOptions{
|
||||||
|
State: "open",
|
||||||
|
Label: "bug",
|
||||||
|
OlderThanDays: 30,
|
||||||
|
Limit: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(got) != 1 || got[0].Number != 1 {
|
||||||
|
t.Fatalf("filtered candidates = %#v, want only issue #1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchListLimitTruncates(t *testing.T) {
|
||||||
|
old := time.Now().AddDate(0, 0, -45).Format(time.RFC3339)
|
||||||
|
env := batchIssueEnvelope([]map[string]interface{}{
|
||||||
|
{"project_issues_index": 1, "subject": "one", "status": "open", "updated_at": old},
|
||||||
|
{"project_issues_index": 2, "subject": "two", "status": "open", "updated_at": old},
|
||||||
|
})
|
||||||
|
candidates := normalizeBatchIssueCandidates(env, batchIssueOptions{State: "open", OlderThanDays: 30, Limit: 1})
|
||||||
|
if len(candidates) > 1 {
|
||||||
|
candidates = candidates[:1]
|
||||||
|
}
|
||||||
|
if len(candidates) != 1 || candidates[0].Number != 1 {
|
||||||
|
t.Fatalf("limited candidates = %#v, want first issue only", candidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchCandidateMissingUpdatedAtDoesNotPanic(t *testing.T) {
|
||||||
|
env := batchIssueEnvelope([]map[string]interface{}{
|
||||||
|
{"project_issues_index": 1, "subject": "missing update", "status": "open"},
|
||||||
|
})
|
||||||
|
got := normalizeBatchIssueCandidates(env, batchIssueOptions{State: "open", Limit: 50})
|
||||||
|
if len(got) != 1 || got[0].DaysInactive != 0 {
|
||||||
|
t.Fatalf("candidate = %#v, want one candidate with zero inactive days", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchCloseSafetyValidation(t *testing.T) {
|
||||||
|
err := validateBatchCloseOptions(batchIssueOptions{Limit: 20})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("validateBatchCloseOptions returned nil without older-than-days")
|
||||||
|
}
|
||||||
|
err = validateBatchCloseOptions(batchIssueOptions{OlderThanDays: 3, Limit: 20})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("validateBatchCloseOptions returned nil for unsafe older-than-days")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchCloseDefaultDryRunResult(t *testing.T) {
|
||||||
|
result := BatchIssueResult{
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
DryRun: true,
|
||||||
|
Action: "close",
|
||||||
|
Total: 1,
|
||||||
|
Success: 1,
|
||||||
|
Candidates: []BatchIssueCandidate{{Number: 1, State: "open", Title: "old issue"}},
|
||||||
|
}
|
||||||
|
rendered, err := renderBatchIssueResultString(result, "table")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderBatchIssueResultString returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rendered, "DRY-RUN") || !strings.Contains(rendered, "old issue") {
|
||||||
|
t.Fatalf("table output = %q, want dry-run and issue title", rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchLabelRequiresAddLabel(t *testing.T) {
|
||||||
|
if err := validateBatchLabelOptions(batchIssueOptions{OlderThanDays: 30}); err == nil {
|
||||||
|
t.Fatal("validateBatchLabelOptions returned nil without --add-label")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchJSONRenderCanUnmarshal(t *testing.T) {
|
||||||
|
result := BatchIssueResult{
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
DryRun: true,
|
||||||
|
Action: "list",
|
||||||
|
Total: 1,
|
||||||
|
Success: 1,
|
||||||
|
Candidates: []BatchIssueCandidate{{Number: 1, State: "open", Title: "old issue"}},
|
||||||
|
}
|
||||||
|
rendered, err := renderBatchIssueResultString(result, "json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderBatchIssueResultString returned error: %v", err)
|
||||||
|
}
|
||||||
|
var decoded BatchIssueResult
|
||||||
|
if err := json.Unmarshal([]byte(rendered), &decoded); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, rendered)
|
||||||
|
}
|
||||||
|
if decoded.Total != 1 || decoded.Candidates[0].Number != 1 {
|
||||||
|
t.Fatalf("decoded result = %#v, want one candidate", decoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBatchWriteFailureRecordsError(t *testing.T) {
|
||||||
|
result := BatchIssueResult{
|
||||||
|
Owner: "owner",
|
||||||
|
Repo: "repo",
|
||||||
|
DryRun: false,
|
||||||
|
Action: "close",
|
||||||
|
Total: 1,
|
||||||
|
Failed: 1,
|
||||||
|
Candidates: []BatchIssueCandidate{{Number: 1, State: "open", Title: "old issue"}},
|
||||||
|
Errors: []string{"#1: close issue: HTTP 500"},
|
||||||
|
}
|
||||||
|
rendered, err := renderBatchIssueResultString(result, "table")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderBatchIssueResultString returned error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rendered, "FAILED=1") || !strings.Contains(rendered, "HTTP 500") {
|
||||||
|
t.Fatalf("table output = %q, want failure summary and error", rendered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func batchIssueEnvelope(issues []map[string]interface{}) *output.Envelope {
|
||||||
|
items := make([]interface{}, 0, len(issues))
|
||||||
|
for _, issue := range issues {
|
||||||
|
items = append(items, issue)
|
||||||
|
}
|
||||||
|
return output.SuccessEnvelope(map[string]interface{}{"issues": items}, nil)
|
||||||
|
}
|
||||||
|
|
||||||
func writeTempCSV(t *testing.T, content string) string {
|
func writeTempCSV(t *testing.T, content string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(t.TempDir(), "issues.csv")
|
path := filepath.Join(t.TempDir(), "issues.csv")
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
@ -20,16 +21,19 @@ type existingIssue struct {
|
||||||
Description string
|
Description string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Shortcuts() []*common.Shortcut {
|
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
||||||
|
tr := shortcutTranslator(translators...)
|
||||||
return []*common.Shortcut{
|
return []*common.Shortcut{
|
||||||
newBatchCloseShortcut(),
|
newBatchListShortcut(tr),
|
||||||
|
newBatchCloseShortcut(tr),
|
||||||
|
newBatchLabelShortcut(tr),
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List issues",
|
Description: tr.T("cmd.issue.list.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"},
|
{Name: "state", Short: "s", Usage: tr.T("flag.issue.state"), Default: "open"},
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -51,13 +55,13 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "create",
|
Name: "create",
|
||||||
Description: "Create a new issue",
|
Description: tr.T("cmd.issue.create.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "title", Short: "t", Usage: "Issue title", Required: true},
|
{Name: "title", Short: "t", Usage: tr.T("flag.issue.title"), Required: true},
|
||||||
{Name: "body", Short: "b", Usage: "Issue description"},
|
{Name: "body", Short: "b", Usage: tr.T("flag.issue.body")},
|
||||||
{Name: "assignee", Short: "a", Usage: "Assignee login"},
|
{Name: "assignee", Short: "a", Usage: tr.T("flag.issue.assignee")},
|
||||||
{Name: "milestone", Short: "m", Usage: "Milestone ID"},
|
{Name: "milestone", Short: "m", Usage: tr.T("flag.issue.milestone")},
|
||||||
{Name: "label", Usage: "Label ID"},
|
{Name: "label", Usage: tr.T("flag.issue.label")},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -91,9 +95,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "view",
|
Name: "view",
|
||||||
Description: "View issue details",
|
Description: tr.T("cmd.issue.view.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
{Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), Required: true},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -112,9 +116,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "close",
|
Name: "close",
|
||||||
Description: "Close an issue",
|
Description: tr.T("cmd.issue.close.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
{Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), Required: true},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -143,12 +147,12 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "update",
|
Name: "update",
|
||||||
Description: "Update an issue",
|
Description: tr.T("cmd.issue.update.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
{Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), Required: true},
|
||||||
{Name: "title", Short: "t", Usage: "New title"},
|
{Name: "title", Short: "t", Usage: tr.T("flag.issue.new_title")},
|
||||||
{Name: "body", Short: "b", Usage: "New description"},
|
{Name: "body", Short: "b", Usage: tr.T("flag.issue.new_body")},
|
||||||
{Name: "state", Short: "s", Usage: "New state: open, closed, or numeric status_id"},
|
{Name: "state", Short: "s", Usage: tr.T("flag.issue.new_state")},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -196,10 +200,10 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "comment",
|
Name: "comment",
|
||||||
Description: "Add a comment to an issue",
|
Description: tr.T("cmd.issue.comment.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true},
|
{Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), Required: true},
|
||||||
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
|
{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -226,6 +230,13 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
|
||||||
|
if len(translators) > 0 && translators[0] != nil {
|
||||||
|
return translators[0]
|
||||||
|
}
|
||||||
|
return i18n.Default()
|
||||||
|
}
|
||||||
|
|
||||||
// normalizeIssueListIDs adds "number" (project_issues_index) and renames
|
// normalizeIssueListIDs adds "number" (project_issues_index) and renames
|
||||||
// "id" to "database_id" so the user-facing output uses the project-level
|
// "id" to "database_id" so the user-facing output uses the project-level
|
||||||
// issue number, not the global database primary key.
|
// issue number, not the global database primary key.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
|
@ -12,8 +13,20 @@ import (
|
||||||
|
|
||||||
func TestIssueClosePreservesCurrentDescription(t *testing.T) {
|
func TestIssueClosePreservesCurrentDescription(t *testing.T) {
|
||||||
var updatePayload map[string]interface{}
|
var updatePayload map[string]interface{}
|
||||||
|
staleTime := time.Now().AddDate(0, 0, -70).Format(time.RFC3339)
|
||||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch {
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"issues": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"project_issues_index": 42,
|
||||||
|
"subject": "Existing title",
|
||||||
|
"status": "open",
|
||||||
|
"updated_at": staleTime,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
|
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
|
||||||
writeJSON(t, w, map[string]interface{}{
|
writeJSON(t, w, map[string]interface{}{
|
||||||
"subject": "Existing title",
|
"subject": "Existing title",
|
||||||
|
|
@ -102,8 +115,20 @@ func TestIssueUpdatePreservesCurrentSubjectWhenChangingDescription(t *testing.T)
|
||||||
|
|
||||||
func TestBatchClosePreservesCurrentDescription(t *testing.T) {
|
func TestBatchClosePreservesCurrentDescription(t *testing.T) {
|
||||||
var updatePayload map[string]interface{}
|
var updatePayload map[string]interface{}
|
||||||
|
staleTime := time.Now().AddDate(0, 0, -70).Format(time.RFC3339)
|
||||||
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
switch {
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"issues": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"project_issues_index": 42,
|
||||||
|
"subject": "Existing title",
|
||||||
|
"status": "open",
|
||||||
|
"updated_at": staleTime,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
|
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/42.json":
|
||||||
writeJSON(t, w, map[string]interface{}{
|
writeJSON(t, w, map[string]interface{}{
|
||||||
"subject": "Existing title",
|
"subject": "Existing title",
|
||||||
|
|
@ -119,8 +144,8 @@ func TestBatchClosePreservesCurrentDescription(t *testing.T) {
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
err := runIssueShortcut(t, server, "batch-close", map[string]string{
|
err := runIssueShortcut(t, server, "batch-close", map[string]string{
|
||||||
"numbers": "42",
|
"older-than-days": "60",
|
||||||
"dry-run": "false",
|
"yes": "true",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("batch-close shortcut failed: %v", err)
|
t.Fatalf("batch-close shortcut failed: %v", err)
|
||||||
|
|
@ -131,6 +156,42 @@ func TestBatchClosePreservesCurrentDescription(t *testing.T) {
|
||||||
assertEqual(t, updatePayload["status_id"], float64(5))
|
assertEqual(t, updatePayload["status_id"], float64(5))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBatchCloseWithoutYesDoesNotPatch(t *testing.T) {
|
||||||
|
staleTime := time.Now().AddDate(0, 0, -70).Format(time.RFC3339)
|
||||||
|
patchCalled := false
|
||||||
|
server := newIssueTestServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||||
|
writeJSON(t, w, map[string]interface{}{
|
||||||
|
"issues": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"project_issues_index": 42,
|
||||||
|
"subject": "Existing title",
|
||||||
|
"status": "open",
|
||||||
|
"updated_at": staleTime,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case r.Method == "PATCH":
|
||||||
|
patchCalled = true
|
||||||
|
t.Fatalf("dry-run batch-close must not PATCH: %s", r.URL.Path)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
err := runIssueShortcut(t, server, "batch-close", map[string]string{
|
||||||
|
"older-than-days": "60",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dry-run batch-close failed: %v", err)
|
||||||
|
}
|
||||||
|
if patchCalled {
|
||||||
|
t.Fatal("dry-run batch-close called PATCH")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func runIssueShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
func runIssueShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
shortcut := findIssueShortcut(t, name)
|
shortcut := findIssueShortcut(t, name)
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,19 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"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{
|
return []*common.Shortcut{
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List organizations",
|
Description: tr.T("cmd.org.list.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
|
|
@ -29,9 +31,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "info",
|
Name: "info",
|
||||||
Description: "Show organization details",
|
Description: tr.T("cmd.org.info.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
id, _ := ctx.RequireArg("id")
|
id, _ := ctx.RequireArg("id")
|
||||||
|
|
@ -44,11 +46,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "members",
|
Name: "members",
|
||||||
Description: "List organization members",
|
Description: tr.T("cmd.org.members.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "Organization ID", Required: true},
|
{Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true},
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
id, _ := ctx.RequireArg("id")
|
id, _ := ctx.RequireArg("id")
|
||||||
|
|
@ -64,10 +66,10 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "create",
|
Name: "create",
|
||||||
Description: "Create an organization",
|
Description: tr.T("cmd.org.create.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "name", Short: "n", Usage: "Organization name", Required: true},
|
{Name: "name", Short: "n", Usage: tr.T("flag.org.name"), Required: true},
|
||||||
{Name: "description", Short: "d", Usage: "Description"},
|
{Name: "description", Short: "d", Usage: tr.T("flag.description")},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
name, _ := ctx.RequireArg("name")
|
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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,21 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"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{
|
return []*common.Shortcut{
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List pull requests",
|
Description: tr.T("cmd.pr.list.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "state", Short: "s", Usage: "Filter: open, merged, closed", Default: "open"},
|
{Name: "state", Short: "s", Usage: tr.T("flag.pr.state"), Default: "open"},
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -37,12 +39,12 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "create",
|
Name: "create",
|
||||||
Description: "Create a pull request",
|
Description: tr.T("cmd.pr.create.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "title", Short: "t", Usage: "PR title", Required: true},
|
{Name: "title", Short: "t", Usage: tr.T("flag.pr.title"), Required: true},
|
||||||
{Name: "body", Short: "b", Usage: "PR description"},
|
{Name: "body", Short: "b", Usage: tr.T("flag.pr.body")},
|
||||||
{Name: "head", Usage: "Source branch", Required: true},
|
{Name: "head", Usage: tr.T("flag.pr.head"), Required: true},
|
||||||
{Name: "base", Usage: "Target branch", Default: "master"},
|
{Name: "base", Usage: tr.T("flag.pr.base"), Default: "master"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -71,9 +73,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "view",
|
Name: "view",
|
||||||
Description: "View pull request details",
|
Description: tr.T("cmd.pr.view.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -89,10 +91,10 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "merge",
|
Name: "merge",
|
||||||
Description: "Merge a pull request",
|
Description: tr.T("cmd.pr.merge.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
|
||||||
{Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"},
|
{Name: "method", Short: "m", Usage: tr.T("flag.pr.merge_method"), Default: "merge"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -115,9 +117,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "close",
|
Name: "close",
|
||||||
Description: "Close a pull request",
|
Description: tr.T("cmd.pr.close.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -133,9 +135,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "files",
|
Name: "files",
|
||||||
Description: "List changed files in a pull request",
|
Description: tr.T("cmd.pr.files.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -151,9 +153,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "diff",
|
Name: "diff",
|
||||||
Description: "Show diff for a pull request",
|
Description: tr.T("cmd.pr.diff.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -169,9 +171,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "versions",
|
Name: "versions",
|
||||||
Description: "List pull request patchset versions",
|
Description: tr.T("cmd.pr.versions.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -190,11 +192,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "version-diff",
|
Name: "version-diff",
|
||||||
Description: "Show diff for a pull request patchset version",
|
Description: tr.T("cmd.pr.version_diff.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
|
||||||
{Name: "version-id", Short: "v", Usage: "Patchset version ID", Required: true},
|
{Name: "version-id", Short: "v", Usage: tr.T("flag.pr.version_id"), Required: true},
|
||||||
{Name: "file", Short: "f", Usage: "Filter diff by file path"},
|
{Name: "file", Short: "f", Usage: tr.T("flag.pr.file")},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -227,10 +229,10 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "reviews",
|
Name: "reviews",
|
||||||
Description: "List pull request reviews",
|
Description: tr.T("cmd.pr.reviews.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
|
||||||
{Name: "status", Short: "s", Usage: "Filter review status: common, approved, rejected"},
|
{Name: "status", Short: "s", Usage: tr.T("flag.pr.review_status_filter")},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -256,13 +258,13 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "review",
|
Name: "review",
|
||||||
Description: "Create a pull request review",
|
Description: tr.T("cmd.pr.review.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
|
||||||
{Name: "status", Short: "s", Usage: "Review status: common, approved, rejected", Default: "common"},
|
{Name: "status", Short: "s", Usage: tr.T("flag.pr.review_status"), Default: "common"},
|
||||||
{Name: "content", Short: "c", Usage: "Review content", Required: true},
|
{Name: "content", Short: "c", Usage: tr.T("flag.pr.review_content"), Required: true},
|
||||||
{Name: "commit", Short: "m", Usage: "Commit SHA to attach the review to"},
|
{Name: "commit", Short: "m", Usage: tr.T("flag.pr.review_commit")},
|
||||||
{Name: "dry-run", Usage: "Preview the review request without creating it", Bool: true, Default: "false"},
|
{Name: "dry-run", Usage: tr.T("flag.dry_run"), Bool: true, Default: "false"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -308,10 +310,10 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "comment",
|
Name: "comment",
|
||||||
Description: "Add a comment to a pull request",
|
Description: tr.T("cmd.pr.comment.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "PR number", Required: true},
|
{Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true},
|
||||||
{Name: "body", Short: "b", Usage: "Comment body", Required: true},
|
{Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -342,6 +344,13 @@ func Shortcuts() []*common.Shortcut {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
|
||||||
|
if len(translators) > 0 && translators[0] != nil {
|
||||||
|
return translators[0]
|
||||||
|
}
|
||||||
|
return i18n.Default()
|
||||||
|
}
|
||||||
|
|
||||||
func prV1Path(ctx *common.RuntimeContext, id string) string {
|
func prV1Path(ctx *common.RuntimeContext, id string) string {
|
||||||
return fmt.Sprintf("/v1/%s/%s/pulls/%s", ctx.Owner, ctx.Repo, id)
|
return fmt.Sprintf("/v1/%s/%s/pulls/%s", ctx.Owner, ctx.Repo, id)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package shortcuts
|
||||||
import (
|
import (
|
||||||
"github.com/spf13/cobra"
|
"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/branch"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
|
@ -17,31 +18,31 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterAll mounts all shortcut groups onto the root command.
|
// RegisterAll mounts all shortcut groups onto the root command.
|
||||||
func RegisterAll(root *cobra.Command) {
|
func RegisterAll(root *cobra.Command, tr *i18n.Translator) {
|
||||||
groups := map[string][]*common.Shortcut{
|
groups := map[string][]*common.Shortcut{
|
||||||
"repo": repo.Shortcuts(),
|
"repo": repo.Shortcuts(tr),
|
||||||
"issue": issue.Shortcuts(),
|
"issue": issue.Shortcuts(tr),
|
||||||
"pr": pr.Shortcuts(),
|
"pr": pr.Shortcuts(tr),
|
||||||
"release": release.Shortcuts(),
|
"release": release.Shortcuts(tr),
|
||||||
"branch": branch.Shortcuts(),
|
"branch": branch.Shortcuts(tr),
|
||||||
"org": org.Shortcuts(),
|
"org": org.Shortcuts(tr),
|
||||||
"user": user.Shortcuts(),
|
"user": user.Shortcuts(tr),
|
||||||
"search": search.Shortcuts(),
|
"search": search.Shortcuts(tr),
|
||||||
"ci": ci.Shortcuts(),
|
"ci": ci.Shortcuts(tr),
|
||||||
"webhook": webhook.Shortcuts(),
|
"webhook": webhook.Shortcuts(tr),
|
||||||
}
|
}
|
||||||
|
|
||||||
descriptions := map[string]string{
|
descriptions := map[string]string{
|
||||||
"repo": "Repository operations",
|
"repo": tr.T("cmd.repo.short"),
|
||||||
"issue": "Issue operations",
|
"issue": tr.T("cmd.issue.short"),
|
||||||
"pr": "Pull request operations",
|
"pr": tr.T("cmd.pr.short"),
|
||||||
"release": "Release operations",
|
"release": tr.T("cmd.release.short"),
|
||||||
"branch": "Branch operations",
|
"branch": tr.T("cmd.branch.short"),
|
||||||
"org": "Organization operations",
|
"org": tr.T("cmd.org.short"),
|
||||||
"user": "User operations",
|
"user": tr.T("cmd.user.short"),
|
||||||
"search": "Search operations",
|
"search": tr.T("cmd.search.short"),
|
||||||
"ci": "CI/CD operations",
|
"ci": tr.T("cmd.ci.short"),
|
||||||
"webhook": "Webhook operations",
|
"webhook": tr.T("cmd.webhook.short"),
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, shortcuts := range groups {
|
for name, shortcuts := range groups {
|
||||||
|
|
@ -49,7 +50,7 @@ func RegisterAll(root *cobra.Command) {
|
||||||
Use: name,
|
Use: name,
|
||||||
Short: descriptions[name],
|
Short: descriptions[name],
|
||||||
}
|
}
|
||||||
common.MountShortcuts(groupCmd, shortcuts)
|
common.MountShortcuts(groupCmd, shortcuts, tr)
|
||||||
root.AddCommand(groupCmd)
|
root.AddCommand(groupCmd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,20 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/output"
|
"github.com/gitlink-org/gitlink-cli/internal/output"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"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{
|
return []*common.Shortcut{
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List releases",
|
Description: tr.T("cmd.release.list.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -33,13 +35,13 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "create",
|
Name: "create",
|
||||||
Description: "Create a release",
|
Description: tr.T("cmd.release.create.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "tag", Short: "t", Usage: "Tag name", Required: true},
|
{Name: "tag", Short: "t", Usage: tr.T("flag.release.tag"), Required: true},
|
||||||
{Name: "name", Short: "n", Usage: "Release name", Required: true},
|
{Name: "name", Short: "n", Usage: tr.T("flag.release.name"), Required: true},
|
||||||
{Name: "body", Short: "b", Usage: "Release notes"},
|
{Name: "body", Short: "b", Usage: tr.T("flag.release.body")},
|
||||||
{Name: "target", Usage: "Target branch", Default: "master"},
|
{Name: "target", Usage: tr.T("flag.release.target"), Default: "master"},
|
||||||
{Name: "prerelease", Usage: "Mark as prerelease (true/false)", Default: "false"},
|
{Name: "prerelease", Usage: tr.T("flag.release.prerelease"), Default: "false"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -69,9 +71,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "view",
|
Name: "view",
|
||||||
Description: "View release details",
|
Description: tr.T("cmd.release.view.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -87,9 +89,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "delete",
|
Name: "delete",
|
||||||
Description: "Delete a release",
|
Description: tr.T("cmd.release.delete.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,21 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"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{
|
return []*common.Shortcut{
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List repositories for a user or organization",
|
Description: tr.T("cmd.repo.list.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "user", Short: "u", Usage: "User login (default: current user)"},
|
{Name: "user", Short: "u", Usage: tr.T("flag.user"), Default: ""},
|
||||||
{Name: "category", Short: "c", Usage: "Filter: manage/mirror/sync/fork/all (default: manage)", Default: "manage"},
|
{Name: "category", Short: "c", Usage: tr.T("flag.repo.category"), Default: "manage"},
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
user := ctx.Arg("user")
|
user := ctx.Arg("user")
|
||||||
|
|
@ -40,7 +42,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "info",
|
Name: "info",
|
||||||
Description: "Show repository details",
|
Description: tr.T("cmd.repo.info.short"),
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -54,11 +56,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "create",
|
Name: "create",
|
||||||
Description: "Create a new repository",
|
Description: tr.T("cmd.repo.create.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "name", Short: "n", Usage: "Repository name", Required: true},
|
{Name: "name", Short: "n", Usage: tr.T("flag.repo.name"), Required: true},
|
||||||
{Name: "description", Short: "d", Usage: "Repository description"},
|
{Name: "description", Short: "d", Usage: tr.T("flag.repo.description")},
|
||||||
{Name: "private", Usage: "Make repository private (true/false)", Default: "false"},
|
{Name: "private", Usage: tr.T("flag.repo.private"), Default: "false"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
name, err := ctx.RequireArg("name")
|
name, err := ctx.RequireArg("name")
|
||||||
|
|
@ -96,7 +98,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "fork",
|
Name: "fork",
|
||||||
Description: "Fork a repository",
|
Description: tr.T("cmd.repo.fork.short"),
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -110,7 +112,7 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "delete",
|
Name: "delete",
|
||||||
Description: "Delete a repository",
|
Description: tr.T("cmd.repo.delete.short"),
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -124,3 +126,10 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
|
||||||
|
if len(translators) > 0 && translators[0] != nil {
|
||||||
|
return translators[0]
|
||||||
|
}
|
||||||
|
return i18n.Default()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,20 @@ package search
|
||||||
import (
|
import (
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"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{
|
return []*common.Shortcut{
|
||||||
{
|
{
|
||||||
Name: "repos",
|
Name: "repos",
|
||||||
Description: "Search repositories",
|
Description: tr.T("cmd.search.repos.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
|
{Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword"), Required: true},
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
keyword, _ := ctx.RequireArg("keyword")
|
keyword, _ := ctx.RequireArg("keyword")
|
||||||
|
|
@ -31,11 +33,11 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "users",
|
Name: "users",
|
||||||
Description: "Search users",
|
Description: tr.T("cmd.search.users.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "keyword", Short: "k", Usage: "Search keyword", Required: true},
|
{Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword"), Required: true},
|
||||||
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
|
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||||
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
|
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||||
},
|
},
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
keyword, _ := ctx.RequireArg("keyword")
|
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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,16 @@ package user
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"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{
|
return []*common.Shortcut{
|
||||||
{
|
{
|
||||||
Name: "me",
|
Name: "me",
|
||||||
Description: "Show current authenticated user",
|
Description: tr.T("cmd.user.me.short"),
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
env, err := ctx.CallAPI("GET", "/users/me", nil)
|
env, err := ctx.CallAPI("GET", "/users/me", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -21,9 +23,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "info",
|
Name: "info",
|
||||||
Description: "Show user profile",
|
Description: tr.T("cmd.user.info.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
login, err := ctx.RequireArg("login")
|
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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -22,11 +23,12 @@ var allowedWebhookEvents = map[string]bool{
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shortcuts returns webhook management shortcuts.
|
// Shortcuts returns webhook management shortcuts.
|
||||||
func Shortcuts() []*common.Shortcut {
|
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
||||||
|
tr := shortcutTranslator(translators...)
|
||||||
return []*common.Shortcut{
|
return []*common.Shortcut{
|
||||||
{
|
{
|
||||||
Name: "list",
|
Name: "list",
|
||||||
Description: "List repository webhooks",
|
Description: tr.T("cmd.webhook.list.short"),
|
||||||
Run: func(ctx *common.RuntimeContext) error {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -40,9 +42,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "view",
|
Name: "view",
|
||||||
Description: "View webhook details",
|
Description: tr.T("cmd.webhook.view.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -61,40 +63,40 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "create",
|
Name: "create",
|
||||||
Description: "Create a repository webhook",
|
Description: tr.T("cmd.webhook.create.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "url", Short: "u", Usage: "Webhook target URL", Required: true},
|
{Name: "url", Short: "u", Usage: tr.T("flag.webhook.url"), Required: true},
|
||||||
{Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only", Required: true},
|
{Name: "events", Short: "e", Usage: tr.T("flag.webhook.events"), Required: true},
|
||||||
{Name: "type", Short: "t", Usage: "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", Default: "gitea"},
|
{Name: "type", Short: "t", Usage: tr.T("flag.webhook.type"), Default: "gitea"},
|
||||||
{Name: "content-type", Usage: "Payload content type: json or form", Default: "json"},
|
{Name: "content-type", Usage: tr.T("flag.webhook.content_type"), Default: "json"},
|
||||||
{Name: "http-method", Usage: "HTTP method: POST or GET", Default: "POST"},
|
{Name: "http-method", Usage: tr.T("flag.webhook.http_method"), Default: "POST"},
|
||||||
{Name: "secret", Short: "s", Usage: "Webhook secret"},
|
{Name: "secret", Short: "s", Usage: tr.T("flag.webhook.secret")},
|
||||||
{Name: "branch-filter", Usage: "Branch glob filter for push/create/delete events", Default: "*"},
|
{Name: "branch-filter", Usage: tr.T("flag.webhook.branch_filter"), Default: "*"},
|
||||||
{Name: "active", Usage: "Whether the webhook is active: true or false", Default: "true"},
|
{Name: "active", Usage: tr.T("flag.webhook.active"), Default: "true"},
|
||||||
},
|
},
|
||||||
Run: runCreate,
|
Run: runCreate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "update",
|
Name: "update",
|
||||||
Description: "Update a repository webhook while preserving unspecified fields when available",
|
Description: tr.T("cmd.webhook.update.short"),
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "id", Short: "i", Usage: "Webhook ID", Required: true},
|
{Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true},
|
||||||
{Name: "url", Short: "u", Usage: "Webhook target URL"},
|
{Name: "url", Short: "u", Usage: tr.T("flag.webhook.url")},
|
||||||
{Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only"},
|
{Name: "events", Short: "e", Usage: tr.T("flag.webhook.events")},
|
||||||
{Name: "type", Short: "t", Usage: "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot"},
|
{Name: "type", Short: "t", Usage: tr.T("flag.webhook.type")},
|
||||||
{Name: "content-type", Usage: "Payload content type: json or form"},
|
{Name: "content-type", Usage: tr.T("flag.webhook.content_type")},
|
||||||
{Name: "http-method", Usage: "HTTP method: POST or GET"},
|
{Name: "http-method", Usage: tr.T("flag.webhook.http_method")},
|
||||||
{Name: "secret", Short: "s", Usage: "Webhook secret. Pass it again if the server does not return existing secrets."},
|
{Name: "secret", Short: "s", Usage: tr.T("flag.webhook.secret_update")},
|
||||||
{Name: "branch-filter", Usage: "Branch glob filter for push/create/delete events"},
|
{Name: "branch-filter", Usage: tr.T("flag.webhook.branch_filter")},
|
||||||
{Name: "active", Usage: "Whether the webhook is active: true or false"},
|
{Name: "active", Usage: tr.T("flag.webhook.active")},
|
||||||
},
|
},
|
||||||
Run: runUpdate,
|
Run: runUpdate,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "delete",
|
Name: "delete",
|
||||||
Description: "Delete a repository webhook",
|
Description: tr.T("cmd.webhook.delete.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -113,9 +115,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "test",
|
Name: "test",
|
||||||
Description: "Trigger a test delivery for a webhook",
|
Description: tr.T("cmd.webhook.test.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
|
@ -134,9 +136,9 @@ func Shortcuts() []*common.Shortcut {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "tasks",
|
Name: "tasks",
|
||||||
Description: "List webhook delivery tasks",
|
Description: tr.T("cmd.webhook.tasks.short"),
|
||||||
Flags: []common.Flag{
|
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 {
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
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 {
|
func runCreate(ctx *common.RuntimeContext) error {
|
||||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue