From 6b2d29e5f7cb315f39382f1d1f0900af5e4b3173 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Sun, 24 May 2026 16:25:03 +0800 Subject: [PATCH 1/7] feat(issue): add batch maintenance shortcuts --- .gitignore | 1 + README.md | 20 +- README.zh-CN.md | 20 +- docs/issue-batch-design.md | 92 +++++ docs/issue-batch-test-report.md | 55 +++ docs/pr-draft-issue-batch.md | 64 ++++ shortcuts/common/runner.go | 1 + shortcuts/common/types.go | 1 + shortcuts/issue/batch.go | 630 ++++++++++++++++++++++++++++---- shortcuts/issue/batch_test.go | 168 +++++++++ shortcuts/issue/issue.go | 2 + shortcuts/issue/issue_test.go | 65 +++- 12 files changed, 1046 insertions(+), 73 deletions(-) create mode 100644 .gitignore create mode 100644 docs/issue-batch-design.md create mode 100644 docs/issue-batch-test-report.md create mode 100644 docs/pr-draft-issue-batch.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..62569b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +gitlink-cli.exe diff --git a/README.md b/README.md index b4c55e1..6477542 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans | Category | Capabilities | |----------|-------------| | 📦 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 | | 🌿 Branch | Create, delete, list, protect, unprotect branches | | 🏷️ Release | Create, view, delete releases | @@ -190,16 +190,26 @@ gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123 # Close an issue gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 -# Preview batch close without changing data -gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run +# List batch maintenance candidates without changing data +gitlink-cli issue +batch-list --owner Gitlink --repo forgeplus --state open --older-than-days 30 --format table -# Batch close issues from a CSV file -gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv +# Preview batch close without changing data +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 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 ```bash diff --git a/README.zh-CN.md b/README.zh-CN.md index f426948..9855692 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -41,7 +41,7 @@ | 分类 | 能力 | |------|------| | 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 | -| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue | +| 🐛 Issue | 创建、更新、关闭、批量维护、评论 Issue | | 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | | 🌿 分支 | 创建、删除、保护分支 | | 🏷️ 发布 | 创建、查看、删除 Release | @@ -201,16 +201,26 @@ gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123 # 关闭 Issue gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 -# 预览批量关闭,不修改数据 -gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run +# 列出批量维护候选 Issue,不修改数据 +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 "已修复" ``` +批量写操作默认 dry-run,仅输出候选列表。确认无误后再传入 `--yes` +执行远端写操作。`issue +batch-close` 复用已有关闭 Issue API; +`issue +batch-label` 在标签写入 API 不明确时不会伪造远端成功。 + ### Pull Request ```bash diff --git a/docs/issue-batch-design.md b/docs/issue-batch-design.md new file mode 100644 index 0000000..de1a6a8 --- /dev/null +++ b/docs/issue-batch-design.md @@ -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. diff --git a/docs/issue-batch-test-report.md b/docs/issue-batch-test-report.md new file mode 100644 index 0000000..9a7db0d --- /dev/null +++ b/docs/issue-batch-test-report.md @@ -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. diff --git a/docs/pr-draft-issue-batch.md b/docs/pr-draft-issue-batch.md new file mode 100644 index 0000000..0406c08 --- /dev/null +++ b/docs/pr-draft-issue-batch.md @@ -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. diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 056b595..3648d1f 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -11,6 +11,7 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) { cmd := &cobra.Command{ Use: "+" + s.Name, Short: s.Description, + Long: s.Long, RunE: func(cmd *cobra.Command, args []string) error { // Collect flag values flagValues := make(map[string]string) diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 15441c9..ad01ce3 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -15,6 +15,7 @@ import ( type Shortcut struct { Name string Description string + Long string Flags []Flag Run func(ctx *RuntimeContext) error } diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index 2345b6c..73a7ee2 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -1,102 +1,466 @@ package issue import ( + "bytes" "encoding/csv" + "encoding/json" "fmt" + "io" + "net/url" "os" "strconv" "strings" + "text/tabwriter" + "time" + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -const closedIssueStatusID = 5 +const ( + closedIssueStatusID = 5 + maxBatchLimit = 100 +) -type batchCloseResult struct { - Number string `json:"number" yaml:"number"` - Action string `json:"action" yaml:"action"` - Status string `json:"status" yaml:"status"` - Error string `json:"error,omitempty" yaml:"error,omitempty"` +type BatchIssueCandidate struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + 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 { - Repository string `json:"repository" yaml:"repository"` - DryRun bool `json:"dry_run" yaml:"dry_run"` - Total int `json:"total" yaml:"total"` - Succeeded int `json:"succeeded" yaml:"succeeded"` - Failed int `json:"failed" yaml:"failed"` - Results []batchCloseResult `json:"results" yaml:"results"` +type BatchIssueResult struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + DryRun bool `json:"dry_run"` + Action string `json:"action"` + Total int `json:"total"` + Success int `json:"success"` + Failed int `json:"failed"` + Candidates []BatchIssueCandidate `json:"candidates"` + Errors []string `json:"errors,omitempty"` + Reason string `json:"reason,omitempty"` +} + +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() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-list", + Description: "List issue batch maintenance candidates without changing remote data", + Long: `List issue batch maintenance candidates without changing remote data. + +Examples: + gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table + gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json`, + Flags: []common.Flag{ + {Name: "state", Short: "s", Usage: "Filter by issue state: open, closed, all", Default: "open"}, + {Name: "label", Usage: "Filter by existing label"}, + {Name: "older-than-days", Usage: "Only include issues inactive for at least this many days"}, + {Name: "limit", Short: "l", Usage: "Maximum issues to return, capped at 100", Default: "50"}, + }, + Run: runBatchList, + } } func newBatchCloseShortcut() *common.Shortcut { return &common.Shortcut{ Name: "batch-close", - Description: "Close multiple issues by issue numbers or a CSV file", + Description: "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", + Long: `Close filtered issues in bulk. + +This command defaults to dry-run mode and only prints matching issues. +Pass --yes to execute remote close operations. Use restrictive filters and a small limit. + +Examples: + gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 + gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes`, Flags: []common.Flag{ - {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"}, - {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: "dry-run", Usage: "Preview the issues that would be closed without changing them", Bool: true, Default: "false"}, + {Name: "state", Short: "s", Usage: "Filter by issue state before closing", Default: "open"}, + {Name: "label", Usage: "Filter by existing label"}, + {Name: "older-than-days", Usage: "Required safety filter; must be at least 7"}, + {Name: "limit", Short: "l", Usage: "Maximum issues to process, capped at 100", Default: "20"}, + {Name: "yes", Usage: "Execute remote close operations. Without this flag the command is dry-run only.", Bool: true, Default: "false"}, + {Name: "reason", Usage: "Optional reason shown in the batch result"}, }, Run: runBatchClose, } } -func runBatchClose(ctx *common.RuntimeContext) error { - if err := ctx.ResolveOwnerRepo(); err != nil { - return err - } +func newBatchLabelShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-label", + Description: "Add a label to filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", + Long: `Add a label to filtered issues in bulk. - numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from")) +This command defaults to dry-run mode and only prints matching issues. +Pass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable. + +Examples: + gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 + gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes`, + Flags: []common.Flag{ + {Name: "state", Short: "s", Usage: "Filter by issue state", Default: "open"}, + {Name: "label", Usage: "Filter by existing label"}, + {Name: "add-label", Usage: "Label to add to each matching issue"}, + {Name: "older-than-days", Usage: "Only include issues inactive for at least this many days"}, + {Name: "limit", Short: "l", Usage: "Maximum issues to process, capped at 100", Default: "50"}, + {Name: "yes", Usage: "Execute remote label operations. Without this flag the command is dry-run only.", Bool: true, Default: "false"}, + }, + Run: runBatchLabel, + } +} + +func runBatchList(ctx *common.RuntimeContext) error { + opts, err := batchOptionsFromContext(ctx, "list") if err != nil { return err } - if len(numbers) == 0 { - return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") - } - - 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 { + candidates, err := fetchBatchIssueCandidates(ctx, opts) + if err != nil { return err } - if summary.Failed > 0 { - return fmt.Errorf("%d of %d issue(s) failed to close", summary.Failed, summary.Total) + result := BatchIssueResult{ + 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 } +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 { current, err := fetchExistingIssue(ctx, number) if err != nil { return fmt.Errorf("fetch issue: %w", err) } - body := map[string]interface{}{ "subject": current.Subject, "description": current.Description, @@ -108,6 +472,147 @@ func closeIssue(ctx *common.RuntimeContext, number string) error { 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) { numbers, err := parseIssueNumbers(numbersValue) if err != nil { @@ -116,7 +621,6 @@ func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) { if csvPath == "" { return numbers, nil } - csvNumbers, err := readIssueNumbersFromCSV(csvPath) if err != nil { return nil, err @@ -147,7 +651,6 @@ func readIssueNumbersFromCSV(path string) ([]string, error) { if len(records) == 0 { return nil, nil } - numberColumn := -1 startRow := 0 for i, cell := range records[0] { @@ -160,7 +663,6 @@ func readIssueNumbersFromCSV(path string) ([]string, error) { if numberColumn == -1 { numberColumn = 0 } - values := make([]string, 0, len(records)-startRow) for _, record := range records[startRow:] { if numberColumn >= len(record) { @@ -210,3 +712,9 @@ func parseBool(value string) bool { parsed, err := strconv.ParseBool(strings.TrimSpace(value)) 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 +} diff --git a/shortcuts/issue/batch_test.go b/shortcuts/issue/batch_test.go index 54cdf55..cd34474 100644 --- a/shortcuts/issue/batch_test.go +++ b/shortcuts/issue/batch_test.go @@ -1,10 +1,15 @@ package issue import ( + "encoding/json" "os" "path/filepath" "reflect" + "strings" "testing" + "time" + + "github.com/gitlink-org/gitlink-cli/internal/output" ) 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 { t.Helper() path := filepath.Join(t.TempDir(), "issues.csv") diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 902fda8..4ed2373 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -22,7 +22,9 @@ type existingIssue struct { func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ + newBatchListShortcut(), newBatchCloseShortcut(), + newBatchLabelShortcut(), { Name: "list", Description: "List issues", diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 088f82c..05c2860 100644 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -12,8 +13,20 @@ import ( func TestIssueClosePreservesCurrentDescription(t *testing.T) { 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) { 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": writeJSON(t, w, map[string]interface{}{ "subject": "Existing title", @@ -102,8 +115,20 @@ func TestIssueUpdatePreservesCurrentSubjectWhenChangingDescription(t *testing.T) func TestBatchClosePreservesCurrentDescription(t *testing.T) { 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) { 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": writeJSON(t, w, map[string]interface{}{ "subject": "Existing title", @@ -119,8 +144,8 @@ func TestBatchClosePreservesCurrentDescription(t *testing.T) { defer server.Close() err := runIssueShortcut(t, server, "batch-close", map[string]string{ - "numbers": "42", - "dry-run": "false", + "older-than-days": "60", + "yes": "true", }) if err != nil { t.Fatalf("batch-close shortcut failed: %v", err) @@ -131,6 +156,42 @@ func TestBatchClosePreservesCurrentDescription(t *testing.T) { 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 { t.Helper() shortcut := findIssueShortcut(t, name) -- 2.34.1 From 8ad6cd65c27b3ffa0fa24c60a124f7d8b1860954 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Sun, 24 May 2026 16:35:31 +0800 Subject: [PATCH 2/7] chore: remove test residue file --- pr-test-file.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 pr-test-file.txt diff --git a/pr-test-file.txt b/pr-test-file.txt deleted file mode 100644 index d848ff9..0000000 --- a/pr-test-file.txt +++ /dev/null @@ -1 +0,0 @@ -PR Test 2026年 4月 7日 星期二 11时45分56秒 CST -- 2.34.1 From 29401c83e5e4e142bc67e4cb2b82de23213be713 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Wed, 27 May 2026 21:43:04 +0800 Subject: [PATCH 3/7] feat(i18n): add localization infrastructure --- .github/workflows/test.yml | 27 +++ cmd/api/api.go | 16 +- cmd/auth/auth.go | 28 +-- cmd/cmdutil/globals.go | 1 + cmd/config/config.go | 33 ++-- cmd/root.go | 110 +++++++++--- cmd/root_test.go | 205 ++++++++++++++++++++++ docs/i18n-project-summary.md | 72 ++++++++ internal/config/config.go | 5 + internal/i18n/args.go | 4 + internal/i18n/cmd/check/main.go | 23 +++ internal/i18n/doc.go | 5 + internal/i18n/loader.go | 66 +++++++ internal/i18n/locale.go | 98 +++++++++++ internal/i18n/locale_test.go | 35 ++++ internal/i18n/locales/en-US.json | 164 +++++++++++++++++ internal/i18n/locales/zh-CN.json | 164 +++++++++++++++++ internal/i18n/options.go | 10 ++ internal/i18n/resolver.go | 66 +++++++ internal/i18n/resolver_test.go | 31 ++++ internal/i18n/schema/messages.schema.json | 12 ++ internal/i18n/template.go | 41 +++++ internal/i18n/template_test.go | 24 +++ internal/i18n/translator.go | 89 ++++++++++ internal/i18n/translator_test.go | 79 +++++++++ internal/i18n/validate.go | 104 +++++++++++ internal/i18n/validate_test.go | 31 ++++ shortcuts/branch/branch.go | 35 ++-- shortcuts/ci/ci.go | 33 ++-- shortcuts/issue/batch.go | 69 +++----- shortcuts/issue/issue.go | 61 ++++--- shortcuts/org/org.go | 35 ++-- shortcuts/pr/pr.go | 87 +++++---- shortcuts/register.go | 43 ++--- shortcuts/release/release.go | 37 ++-- shortcuts/repo/repo.go | 35 ++-- shortcuts/search/search.go | 27 ++- shortcuts/user/user.go | 17 +- shortcuts/webhook/webhook.go | 67 ++++--- 39 files changed, 1797 insertions(+), 292 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 cmd/root_test.go create mode 100644 docs/i18n-project-summary.md create mode 100644 internal/i18n/args.go create mode 100644 internal/i18n/cmd/check/main.go create mode 100644 internal/i18n/doc.go create mode 100644 internal/i18n/loader.go create mode 100644 internal/i18n/locale.go create mode 100644 internal/i18n/locale_test.go create mode 100644 internal/i18n/locales/en-US.json create mode 100644 internal/i18n/locales/zh-CN.json create mode 100644 internal/i18n/options.go create mode 100644 internal/i18n/resolver.go create mode 100644 internal/i18n/resolver_test.go create mode 100644 internal/i18n/schema/messages.schema.json create mode 100644 internal/i18n/template.go create mode 100644 internal/i18n/template_test.go create mode 100644 internal/i18n/translator.go create mode 100644 internal/i18n/translator_test.go create mode 100644 internal/i18n/validate.go create mode 100644 internal/i18n/validate_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5f86757 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: Test + +on: + pull_request: + push: + branches: + - main + - master + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Validate i18n messages + run: go run ./internal/i18n/cmd/check + + - name: Run Go tests + run: go test ./... diff --git a/cmd/api/api.go b/cmd/api/api.go index af0593d..f949622 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -10,14 +10,18 @@ import ( "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" ) -func NewAPICmd() *cobra.Command { +func NewAPICmd(tr *i18n.Translator) *cobra.Command { + if tr == nil { + tr = i18n.Default() + } apiCmd := &cobra.Command{ Use: "api ", - Short: "Make raw API requests to GitLink", - Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`, + Short: tr.T("cmd.api.short"), + Long: tr.T("cmd.api.long"), Example: ` gitlink-cli api GET /users/me gitlink-cli api GET /projects --query 'page=1&limit=10' gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`, @@ -25,9 +29,9 @@ func NewAPICmd() *cobra.Command { RunE: runAPI, } - apiCmd.Flags().String("body", "", "Request body (JSON string)") - apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)") - apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)") + apiCmd.Flags().String("body", "", tr.T("flag.api.body")) + apiCmd.Flags().String("query", "", tr.T("flag.api.query")) + apiCmd.Flags().StringSlice("header", nil, tr.T("flag.api.header")) return apiCmd } diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index ed9dc22..24ea8dc 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -11,27 +11,31 @@ import ( "golang.org/x/term" internalAuth "github.com/gitlink-org/gitlink-cli/internal/auth" + "github.com/gitlink-org/gitlink-cli/internal/i18n" ) const envTokenVar = "GITLINK_TOKEN" -func NewAuthCmd() *cobra.Command { +func NewAuthCmd(tr *i18n.Translator) *cobra.Command { + if tr == nil { + tr = i18n.Default() + } cmd := &cobra.Command{ Use: "auth", - Short: "Authentication commands", + Short: tr.T("cmd.auth.short"), } - cmd.AddCommand(newLoginCmd()) - cmd.AddCommand(newLogoutCmd()) - cmd.AddCommand(newStatusCmd()) + cmd.AddCommand(newLoginCmd(tr)) + cmd.AddCommand(newLogoutCmd(tr)) + cmd.AddCommand(newStatusCmd(tr)) return cmd } -func newLoginCmd() *cobra.Command { +func newLoginCmd(tr *i18n.Translator) *cobra.Command { var tokenMode bool cmd := &cobra.Command{ Use: "login", - Short: "Login to GitLink", + Short: tr.T("cmd.auth.login.short"), RunE: func(cmd *cobra.Command, args []string) error { if tokenMode { return loginWithToken() @@ -39,7 +43,7 @@ func newLoginCmd() *cobra.Command { return loginWithPassword() }, } - cmd.Flags().BoolVar(&tokenMode, "token", false, "Login by pasting an existing token") + cmd.Flags().BoolVar(&tokenMode, "token", false, tr.T("flag.auth.token")) return cmd } @@ -85,10 +89,10 @@ func loginWithToken() error { return nil } -func newLogoutCmd() *cobra.Command { +func newLogoutCmd(tr *i18n.Translator) *cobra.Command { return &cobra.Command{ Use: "logout", - Short: "Logout from GitLink", + Short: tr.T("cmd.auth.logout.short"), RunE: func(cmd *cobra.Command, args []string) error { if err := internalAuth.DeleteToken(); err != nil { return fmt.Errorf("failed to delete token: %w", err) @@ -99,10 +103,10 @@ func newLogoutCmd() *cobra.Command { } } -func newStatusCmd() *cobra.Command { +func newStatusCmd(tr *i18n.Translator) *cobra.Command { return &cobra.Command{ Use: "status", - Short: "Show authentication status", + Short: tr.T("cmd.auth.status.short"), RunE: func(cmd *cobra.Command, args []string) error { // Check env var token first if envToken := os.Getenv(envTokenVar); envToken != "" { diff --git a/cmd/cmdutil/globals.go b/cmd/cmdutil/globals.go index 859992a..bea4c28 100644 --- a/cmd/cmdutil/globals.go +++ b/cmd/cmdutil/globals.go @@ -6,4 +6,5 @@ var ( Repo string Format string Debug bool + Lang string ) diff --git a/cmd/config/config.go b/cmd/config/config.go index d7dc6ad..414f786 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -6,24 +6,28 @@ import ( "github.com/spf13/cobra" internalConfig "github.com/gitlink-org/gitlink-cli/internal/config" + "github.com/gitlink-org/gitlink-cli/internal/i18n" ) -func NewConfigCmd() *cobra.Command { +func NewConfigCmd(tr *i18n.Translator) *cobra.Command { + if tr == nil { + tr = i18n.Default() + } cmd := &cobra.Command{ Use: "config", - Short: "Manage gitlink-cli configuration", + Short: tr.T("cmd.config.short"), } - cmd.AddCommand(newInitCmd()) - cmd.AddCommand(newSetCmd()) - cmd.AddCommand(newGetCmd()) - cmd.AddCommand(newListCmd()) + cmd.AddCommand(newInitCmd(tr)) + cmd.AddCommand(newSetCmd(tr)) + cmd.AddCommand(newGetCmd(tr)) + cmd.AddCommand(newListCmd(tr)) return cmd } -func newInitCmd() *cobra.Command { +func newInitCmd(tr *i18n.Translator) *cobra.Command { return &cobra.Command{ Use: "init", - Short: "Initialize configuration file", + Short: tr.T("cmd.config.init.short"), RunE: func(cmd *cobra.Command, args []string) error { cfg := internalConfig.DefaultConfig() if err := internalConfig.Save(cfg); err != nil { @@ -35,10 +39,10 @@ func newInitCmd() *cobra.Command { } } -func newSetCmd() *cobra.Command { +func newSetCmd(tr *i18n.Translator) *cobra.Command { return &cobra.Command{ Use: "set ", - Short: "Set a configuration value", + Short: tr.T("cmd.config.set.short"), Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { if err := internalConfig.Set(args[0], args[1]); err != nil { @@ -50,10 +54,10 @@ func newSetCmd() *cobra.Command { } } -func newGetCmd() *cobra.Command { +func newGetCmd(tr *i18n.Translator) *cobra.Command { return &cobra.Command{ Use: "get ", - Short: "Get a configuration value", + Short: tr.T("cmd.config.get.short"), Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { val, err := internalConfig.Get(args[0]) @@ -70,10 +74,10 @@ func newGetCmd() *cobra.Command { } } -func newListCmd() *cobra.Command { +func newListCmd(tr *i18n.Translator) *cobra.Command { return &cobra.Command{ Use: "list", - Short: "List all configuration values", + Short: tr.T("cmd.config.list.short"), RunE: func(cmd *cobra.Command, args []string) error { cfg, err := internalConfig.Load() if err != nil { @@ -83,6 +87,7 @@ func newListCmd() *cobra.Command { fmt.Printf("default_format: %s\n", cfg.Format) fmt.Printf("editor: %s\n", cfg.Editor) fmt.Printf("pager: %s\n", cfg.Pager) + fmt.Printf("lang: %s\n", cfg.Lang) fmt.Printf("\nConfig file: %s\n", internalConfig.ConfigPath()) return nil }, diff --git a/cmd/root.go b/cmd/root.go index 0ed4c66..f67b516 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,49 +6,111 @@ import ( "github.com/spf13/cobra" - "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" - authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth" apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api" + authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth" + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" configCmd "github.com/gitlink-org/gitlink-cli/cmd/config" + internalConfig "github.com/gitlink-org/gitlink-cli/internal/config" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts" ) var Version = "dev" -var rootCmd = &cobra.Command{ - Use: "gitlink-cli", - Short: "GitLink CLI — command-line tool for gitlink.org.cn", - Long: `gitlink-cli is a command-line interface for the GitLink (确实开源) platform, providing repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows.`, - SilenceUsage: true, - SilenceErrors: true, +type RootOptions struct { + Version string + Args []string } -func init() { - rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", "Repository owner (auto-detected from git remote)") - rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", "Repository name (auto-detected from git remote)") - rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", "Output format: json, table, yaml (default: table)") - rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, "Enable debug output") +func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) { + if tr == nil { + var err error + tr, err = i18n.New(i18n.Options{Locale: "en-US"}) + if err != nil { + return nil, err + } + } - rootCmd.AddCommand(authCmd.NewAuthCmd()) - rootCmd.AddCommand(apiCmd.NewAPICmd()) - rootCmd.AddCommand(configCmd.NewConfigCmd()) - rootCmd.AddCommand(versionCmd) + version := opts.Version + if version == "" { + version = Version + } - shortcuts.RegisterAll(rootCmd) + rootCmd := &cobra.Command{ + Use: "gitlink-cli", + Short: tr.T("cmd.root.short"), + Long: tr.T("cmd.root.long"), + SilenceUsage: true, + SilenceErrors: true, + } + + rootCmd.PersistentFlags().StringVar(&cmdutil.Owner, "owner", "", tr.T("flag.owner")) + rootCmd.PersistentFlags().StringVar(&cmdutil.Repo, "repo", "", tr.T("flag.repo")) + rootCmd.PersistentFlags().StringVar(&cmdutil.Format, "format", "", tr.T("flag.format")) + rootCmd.PersistentFlags().BoolVar(&cmdutil.Debug, "debug", false, tr.T("flag.debug")) + rootCmd.PersistentFlags().StringVar(&cmdutil.Lang, "lang", "", tr.T("flag.lang")) + + rootCmd.AddCommand(authCmd.NewAuthCmd(tr)) + rootCmd.AddCommand(apiCmd.NewAPICmd(tr)) + rootCmd.AddCommand(configCmd.NewConfigCmd(tr)) + rootCmd.AddCommand(newVersionCmd(version, tr)) + + shortcuts.RegisterAll(rootCmd, tr) + + if opts.Args != nil { + rootCmd.SetArgs(opts.Args) + } + return rootCmd, nil } -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Print version information", - Run: func(cmd *cobra.Command, args []string) { - fmt.Printf("gitlink-cli %s\n", Version) - }, +func newVersionCmd(version string, tr *i18n.Translator) *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: tr.T("cmd.version.short"), + Run: func(cmd *cobra.Command, args []string) { + fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.version", i18n.Args{"version": version})) + }, + } } func Execute() error { + args := os.Args[1:] + tr, err := newTranslator(args) + if err != nil { + return err + } + rootCmd, err := NewRootCmd(RootOptions{ + Version: Version, + Args: args, + }, tr) + if err != nil { + return err + } + if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) return err } return nil } + +func newTranslator(args []string) (*i18n.Translator, error) { + available, err := i18n.AvailableLocales() + if err != nil { + return nil, err + } + locale := i18n.ResolveLocale(i18n.ResolveOptions{ + ExplicitLang: i18n.PreScanLang(args), + Env: i18n.EnvMap(), + ConfigLang: loadConfigLangBestEffort(), + }, available) + return i18n.New(i18n.Options{Locale: locale}) +} + +func loadConfigLangBestEffort() string { + cfg, err := internalConfig.Load() + if err != nil { + return "" + } + return cfg.Lang +} diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..3457b74 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,205 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" +) + +func TestRootHelpUsesSelectedLocale(t *testing.T) { + tr, err := i18n.New(i18n.Options{Locale: "zh-CN"}) + if err != nil { + t.Fatal(err) + } + root, err := NewRootCmd(RootOptions{Version: "test", Args: []string{"--help"}}, tr) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + help := out.String() + if !strings.Contains(help, "用于管理 GitLink 上的仓库") { + t.Fatalf("expected Chinese root long help, got:\n%s", help) + } + if !strings.Contains(help, "仓库操作") { + t.Fatalf("expected Chinese shortcut group help, got:\n%s", help) + } + if !strings.Contains(help, "认证命令") || !strings.Contains(help, "管理 gitlink-cli 配置") { + t.Fatalf("expected Chinese core command help, got:\n%s", help) + } + if !strings.Contains(help, "--lang") || !strings.Contains(help, "显示语言") { + t.Fatalf("expected localized lang flag help, got:\n%s", help) + } +} + +func TestCoreCommandHelpUsesSelectedLocale(t *testing.T) { + tr, err := i18n.New(i18n.Options{Locale: "zh-CN"}) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + args []string + want []string + }{ + { + args: []string{"api", "--help"}, + want: []string{"向 GitLink API 发送任意 HTTP 请求", "--body", "请求体(JSON 字符串)"}, + }, + { + args: []string{"auth", "login", "--help"}, + want: []string{"登录 GitLink", "--token", "通过粘贴已有 Token 登录"}, + }, + { + args: []string{"config", "--help"}, + want: []string{"管理 gitlink-cli 配置", "初始化配置文件", "列出所有配置项"}, + }, + } + + for _, tc := range cases { + root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatalf("%v: %v", tc.args, err) + } + + help := out.String() + for _, want := range tc.want { + if !strings.Contains(help, want) { + t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help) + } + } + } +} + +func TestShortcutHelpUsesSelectedLocale(t *testing.T) { + tr, err := i18n.New(i18n.Options{Locale: "zh-CN"}) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + args []string + want []string + }{ + { + args: []string{"repo", "+create", "--help"}, + want: []string{"创建新仓库", "--name", "仓库名称", "--private", "设为私有仓库"}, + }, + { + args: []string{"issue", "+batch-close", "--help"}, + want: []string{"批量关闭筛选后的议题", "--older-than-days", "必需的安全筛选条件", "--yes"}, + }, + { + args: []string{"pr", "+review", "--help"}, + want: []string{"创建拉取请求评审", "--content", "评审内容", "--dry-run"}, + }, + } + + for _, tc := range cases { + root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatalf("%v: %v", tc.args, err) + } + + help := out.String() + for _, want := range tc.want { + if !strings.Contains(help, want) { + t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help) + } + } + } +} + +func TestRemainingShortcutHelpUsesSelectedLocale(t *testing.T) { + tr, err := i18n.New(i18n.Options{Locale: "zh-CN"}) + if err != nil { + t.Fatal(err) + } + + cases := []struct { + args []string + want []string + }{ + { + args: []string{"branch", "+create", "--help"}, + want: []string{"创建分支", "--from", "源分支或 Commit"}, + }, + { + args: []string{"release", "+create", "--help"}, + want: []string{"创建发布", "--prerelease", "标记为预发布"}, + }, + { + args: []string{"webhook", "+create", "--help"}, + want: []string{"创建仓库 Webhook", "--events", "逗号分隔的事件"}, + }, + { + args: []string{"ci", "+logs", "--help"}, + want: []string{"查看构建日志", "--build", "构建编号"}, + }, + } + + for _, tc := range cases { + root, err := NewRootCmd(RootOptions{Version: "test", Args: tc.args}, tr) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatalf("%v: %v", tc.args, err) + } + + help := out.String() + for _, want := range tc.want { + if !strings.Contains(help, want) { + t.Fatalf("%v: expected %q in help, got:\n%s", tc.args, want, help) + } + } + } +} + +func TestVersionUsesInjectedVersion(t *testing.T) { + tr, err := i18n.New(i18n.Options{Locale: "en-US"}) + if err != nil { + t.Fatal(err) + } + root, err := NewRootCmd(RootOptions{Version: "1.2.3", Args: []string{"version"}}, tr) + if err != nil { + t.Fatal(err) + } + + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + if got := strings.TrimSpace(out.String()); got != "gitlink-cli 1.2.3" { + t.Fatalf("version output = %q", got) + } +} diff --git a/docs/i18n-project-summary.md b/docs/i18n-project-summary.md new file mode 100644 index 0000000..27d5b08 --- /dev/null +++ b/docs/i18n-project-summary.md @@ -0,0 +1,72 @@ +# GitLink CLI i18n Infrastructure Summary + +## Project Scope + +This work introduces a maintainable localization infrastructure for GitLink CLI. It turns user-facing command help and flag descriptions into validated locale resources while keeping machine-readable outputs stable. + +## Completed Capabilities + +- Added `internal/i18n` as a dedicated localization package. +- Embedded locale JSON files with `go:embed`. +- Added `Translator` with `T` and `Tf` APIs. +- Added fallback behavior from selected locale to `en-US`, then to the message key. +- Added locale resolution from `--lang`, `GITLINK_LANG`, `config.lang`, `LC_ALL`, and `LANG`. +- Added locale normalization and matching for common inputs such as `zh_CN`, `zh-Hans-CN`, `zh`, and `en`. +- Added simple `{name}` template rendering for parameterized messages. +- Added `go run ./internal/i18n/cmd/check` to validate key completeness, empty messages, key naming, and template argument consistency. +- Added GitHub Actions test workflow for i18n validation and Go tests. + +## CLI Integration + +- Reworked root command construction from package-level static initialization to `NewRootCmd(opts, tr)`. +- Added global `--lang` flag. +- Added `lang` support to the config model. +- Localized root command help, global flags, version help/output, and shortcut group descriptions. +- Localized command and flag help for: + - `api` + - `auth` + - `config` + - `repo` + - `issue` + - `pr` + - `branch` + - `release` + - `org` + - `user` + - `search` + - `ci` + - `webhook` + +## Deliberate Non-Goals + +The migration intentionally does not localize: + +- JSON field names +- API response bodies +- raw API errors returned by the server +- debug diagnostics +- internal developer-only error context + +This keeps scripted usage stable while improving human-facing CLI experience. + +## Validation + +The implementation has been validated with: + +```powershell +go run ./internal/i18n/cmd/check +go test ./... +git diff --check +``` + +The local Go toolchain used for validation is: + +```text +E:\tools\go-sdk\go1.26.1\go\bin\go.exe +``` + +## Follow-Up Work + +- Migrate selected runtime success/error messages where they are clearly user-facing. +- Consider a stricter scanner for new hardcoded help text. +- Add contributor documentation for adding a new locale. diff --git a/internal/config/config.go b/internal/config/config.go index e8ec429..0b3f269 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { Format string `yaml:"default_format"` Editor string `yaml:"editor,omitempty"` Pager string `yaml:"pager,omitempty"` + Lang string `yaml:"lang,omitempty"` } func DefaultConfig() *Config { @@ -85,6 +86,8 @@ func Get(key string) (string, error) { return cfg.Editor, nil case "pager": return cfg.Pager, nil + case "lang": + return cfg.Lang, nil default: return "", nil } @@ -104,6 +107,8 @@ func Set(key, value string) error { cfg.Editor = value case "pager": cfg.Pager = value + case "lang": + cfg.Lang = value } return Save(cfg) } diff --git a/internal/i18n/args.go b/internal/i18n/args.go new file mode 100644 index 0000000..0eb08fb --- /dev/null +++ b/internal/i18n/args.go @@ -0,0 +1,4 @@ +package i18n + +// Args contains named values used by parameterized messages. +type Args map[string]any diff --git a/internal/i18n/cmd/check/main.go b/internal/i18n/cmd/check/main.go new file mode 100644 index 0000000..adb0702 --- /dev/null +++ b/internal/i18n/cmd/check/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + "os" + + "github.com/gitlink-org/gitlink-cli/internal/i18n" +) + +func main() { + problems, err := i18n.Validate(i18n.NewEmbedLoader(), "en-US") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + if len(problems) > 0 { + for _, problem := range problems { + fmt.Fprintln(os.Stderr, problem.String()) + } + os.Exit(1) + } + fmt.Println("i18n messages are valid") +} diff --git a/internal/i18n/doc.go b/internal/i18n/doc.go new file mode 100644 index 0000000..1b19950 --- /dev/null +++ b/internal/i18n/doc.go @@ -0,0 +1,5 @@ +// Package i18n provides localized user-facing messages for the CLI. +// +// It intentionally does not localize machine-readable output such as JSON +// field names, API response bodies, or debug diagnostics. +package i18n diff --git a/internal/i18n/loader.go b/internal/i18n/loader.go new file mode 100644 index 0000000..ff29b1e --- /dev/null +++ b/internal/i18n/loader.go @@ -0,0 +1,66 @@ +package i18n + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "path/filepath" + "sort" + "strings" +) + +//go:embed locales/*.json +var embeddedLocales embed.FS + +// Loader loads locale messages from a backing store. +type Loader interface { + Load(locale string) (map[string]string, error) + AvailableLocales() ([]string, error) +} + +type embedLoader struct { + fs fs.FS +} + +// NewEmbedLoader returns the default loader backed by embedded locale files. +func NewEmbedLoader() Loader { + return embedLoader{fs: embeddedLocales} +} + +func (l embedLoader) Load(locale string) (map[string]string, error) { + locale = NormalizeLocale(locale) + path := filepath.ToSlash(filepath.Join("locales", locale+".json")) + data, err := fs.ReadFile(l.fs, path) + if err != nil { + return nil, fmt.Errorf("load locale %s: %w", locale, err) + } + + var messages map[string]string + if err := json.Unmarshal(data, &messages); err != nil { + return nil, fmt.Errorf("parse locale %s: %w", locale, err) + } + return messages, nil +} + +func (l embedLoader) AvailableLocales() ([]string, error) { + entries, err := fs.ReadDir(l.fs, "locales") + if err != nil { + return nil, err + } + + locales := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + locales = append(locales, strings.TrimSuffix(entry.Name(), ".json")) + } + sort.Strings(locales) + return locales, nil +} + +// AvailableLocales returns locales available from the embedded loader. +func AvailableLocales() ([]string, error) { + return NewEmbedLoader().AvailableLocales() +} diff --git a/internal/i18n/locale.go b/internal/i18n/locale.go new file mode 100644 index 0000000..7d85c61 --- /dev/null +++ b/internal/i18n/locale.go @@ -0,0 +1,98 @@ +package i18n + +import ( + "strings" +) + +// NormalizeLocale converts common locale spellings to a stable BCP-47-like form. +func NormalizeLocale(locale string) string { + locale = strings.TrimSpace(locale) + if locale == "" { + return "" + } + if idx := strings.IndexByte(locale, '.'); idx >= 0 { + locale = locale[:idx] + } + locale = strings.ReplaceAll(locale, "_", "-") + + parts := strings.Split(locale, "-") + normalized := make([]string, 0, len(parts)) + for i, part := range parts { + if part == "" { + continue + } + switch { + case i == 0: + normalized = append(normalized, strings.ToLower(part)) + case len(part) == 2: + normalized = append(normalized, strings.ToUpper(part)) + case len(part) == 4: + normalized = append(normalized, strings.ToUpper(part[:1])+strings.ToLower(part[1:])) + default: + normalized = append(normalized, part) + } + } + return strings.Join(normalized, "-") +} + +// MatchLocale resolves requested to one of available using exact, normalized, +// language-primary, then fallback matching. +func MatchLocale(requested string, available []string, fallback string) string { + fallback = NormalizeLocale(fallback) + if fallback == "" { + fallback = defaultFallbackLocale + } + if len(available) == 0 { + return fallback + } + + byLocale := make(map[string]string, len(available)) + byPrimary := make(map[string]string, len(available)) + for _, locale := range available { + normalized := NormalizeLocale(locale) + byLocale[normalized] = normalized + if primary := primaryLanguage(normalized); primary != "" { + if _, exists := byPrimary[primary]; !exists { + byPrimary[primary] = normalized + } + } + } + + candidate := NormalizeLocale(requested) + if candidate != "" { + if matched, ok := byLocale[candidate]; ok { + return matched + } + if alias := localeAlias(candidate); alias != "" { + if matched, ok := byLocale[alias]; ok { + return matched + } + } + if matched, ok := byPrimary[primaryLanguage(candidate)]; ok { + return matched + } + } + + if matched, ok := byLocale[fallback]; ok { + return matched + } + return NormalizeLocale(available[0]) +} + +func primaryLanguage(locale string) string { + if idx := strings.IndexByte(locale, '-'); idx >= 0 { + return locale[:idx] + } + return locale +} + +func localeAlias(locale string) string { + switch primaryLanguage(locale) { + case "zh": + return "zh-CN" + case "en": + return "en-US" + default: + return "" + } +} diff --git a/internal/i18n/locale_test.go b/internal/i18n/locale_test.go new file mode 100644 index 0000000..90dd4a6 --- /dev/null +++ b/internal/i18n/locale_test.go @@ -0,0 +1,35 @@ +package i18n + +import "testing" + +func TestNormalizeLocale(t *testing.T) { + cases := map[string]string{ + "zh_CN": "zh-CN", + "zh_CN.UTF-8": "zh-CN", + "zh-Hans-CN": "zh-Hans-CN", + "EN_us": "en-US", + " en-US ": "en-US", + "zh-hans-cn.utf": "zh-Hans-CN", + } + for input, want := range cases { + if got := NormalizeLocale(input); got != want { + t.Fatalf("NormalizeLocale(%q) = %q, want %q", input, got, want) + } + } +} + +func TestMatchLocale(t *testing.T) { + available := []string{"en-US", "zh-CN"} + cases := map[string]string{ + "zh_CN": "zh-CN", + "zh-Hans-CN": "zh-CN", + "zh": "zh-CN", + "en": "en-US", + "fr-FR": "en-US", + } + for input, want := range cases { + if got := MatchLocale(input, available, "en-US"); got != want { + t.Fatalf("MatchLocale(%q) = %q, want %q", input, got, want) + } + } +} diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json new file mode 100644 index 0000000..63de828 --- /dev/null +++ b/internal/i18n/locales/en-US.json @@ -0,0 +1,164 @@ +{ + "cmd.root.short": "GitLink CLI - command-line tool for GitLink", + "cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.", + "cmd.api.short": "Make raw API requests to GitLink", + "cmd.api.long": "Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.", + "cmd.auth.short": "Authentication commands", + "cmd.auth.login.short": "Login to GitLink", + "cmd.auth.logout.short": "Logout from GitLink", + "cmd.auth.status.short": "Show authentication status", + "cmd.config.short": "Manage gitlink-cli configuration", + "cmd.config.init.short": "Initialize configuration file", + "cmd.config.set.short": "Set a configuration value", + "cmd.config.get.short": "Get a configuration value", + "cmd.config.list.short": "List all configuration values", + "cmd.version.short": "Print version information", + "cmd.repo.short": "Repository operations", + "cmd.repo.list.short": "List repositories for a user or organization", + "cmd.repo.info.short": "Show repository details", + "cmd.repo.create.short": "Create a new repository", + "cmd.repo.fork.short": "Fork a repository", + "cmd.repo.delete.short": "Delete a repository", + "cmd.issue.short": "Issue operations", + "cmd.issue.list.short": "List issues", + "cmd.issue.create.short": "Create a new issue", + "cmd.issue.view.short": "View issue details", + "cmd.issue.close.short": "Close an issue", + "cmd.issue.update.short": "Update an issue", + "cmd.issue.comment.short": "Add a comment to an issue", + "cmd.issue.batch_list.short": "List issue batch maintenance candidates without changing remote data", + "cmd.issue.batch_list.long": "List issue batch maintenance candidates without changing remote data.\n\nExamples:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json", + "cmd.issue.batch_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", + "cmd.issue.batch_close.long": "Close filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote close operations. Use restrictive filters and a small limit.\n\nExamples:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", + "cmd.issue.batch_label.short": "Add a label to filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", + "cmd.issue.batch_label.long": "Add a label to filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.\n\nExamples:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", + "cmd.pr.short": "Pull request operations", + "cmd.pr.list.short": "List pull requests", + "cmd.pr.create.short": "Create a pull request", + "cmd.pr.view.short": "View pull request details", + "cmd.pr.merge.short": "Merge a pull request", + "cmd.pr.close.short": "Close a pull request", + "cmd.pr.files.short": "List changed files in a pull request", + "cmd.pr.diff.short": "Show diff for a pull request", + "cmd.pr.versions.short": "List pull request patchset versions", + "cmd.pr.version_diff.short": "Show diff for a pull request patchset version", + "cmd.pr.reviews.short": "List pull request reviews", + "cmd.pr.review.short": "Create a pull request review", + "cmd.pr.comment.short": "Add a comment to a pull request", + "cmd.release.short": "Release operations", + "cmd.release.list.short": "List releases", + "cmd.release.create.short": "Create a release", + "cmd.release.view.short": "View release details", + "cmd.release.delete.short": "Delete a release", + "cmd.branch.short": "Branch operations", + "cmd.branch.list.short": "List branches", + "cmd.branch.create.short": "Create a branch", + "cmd.branch.delete.short": "Delete a branch", + "cmd.branch.protect.short": "Set branch protection", + "cmd.branch.unprotect.short": "Remove branch protection", + "cmd.org.short": "Organization operations", + "cmd.org.list.short": "List organizations", + "cmd.org.info.short": "Show organization details", + "cmd.org.members.short": "List organization members", + "cmd.org.create.short": "Create an organization", + "cmd.user.short": "User operations", + "cmd.user.me.short": "Show current authenticated user", + "cmd.user.info.short": "Show user profile", + "cmd.search.short": "Search operations", + "cmd.search.repos.short": "Search repositories", + "cmd.search.users.short": "Search users", + "cmd.ci.short": "CI/CD operations", + "cmd.ci.builds.short": "List CI builds", + "cmd.ci.logs.short": "View build logs", + "cmd.ci.restart.short": "Restart a build", + "cmd.ci.stop.short": "Stop a build", + "cmd.webhook.short": "Webhook operations", + "cmd.webhook.list.short": "List repository webhooks", + "cmd.webhook.view.short": "View webhook details", + "cmd.webhook.create.short": "Create a repository webhook", + "cmd.webhook.update.short": "Update a repository webhook while preserving unspecified fields when available", + "cmd.webhook.delete.short": "Delete a repository webhook", + "cmd.webhook.test.short": "Trigger a test delivery for a webhook", + "cmd.webhook.tasks.short": "List webhook delivery tasks", + "flag.owner": "Repository owner (auto-detected from git remote)", + "flag.repo": "Repository name (auto-detected from git remote)", + "flag.format": "Output format: json, table, yaml (default: table)", + "flag.debug": "Enable debug output", + "flag.lang": "Display language", + "flag.api.body": "Request body (JSON string)", + "flag.api.query": "Query parameters (key=val&key2=val2)", + "flag.api.header": "Additional headers (key:value)", + "flag.auth.token": "Login by pasting an existing token", + "flag.user": "User login (default: current user)", + "flag.user.login": "User login name", + "flag.page": "Page number", + "flag.limit": "Items per page", + "flag.description": "Description", + "flag.dry_run": "Preview the request without creating it", + "flag.comment.body": "Comment body", + "flag.repo.category": "Filter: manage/mirror/sync/fork/all (default: manage)", + "flag.repo.name": "Repository name", + "flag.repo.description": "Repository description", + "flag.repo.private": "Make repository private (true/false)", + "flag.issue.state": "Filter by state: open, closed, all", + "flag.issue.title": "Issue title", + "flag.issue.body": "Issue description", + "flag.issue.assignee": "Assignee login", + "flag.issue.milestone": "Milestone ID", + "flag.issue.label": "Label ID", + "flag.issue.number": "Issue number (as shown in the web URL)", + "flag.issue.new_title": "New title", + "flag.issue.new_body": "New description", + "flag.issue.new_state": "New state: open, closed, or numeric status_id", + "flag.issue.label_filter": "Filter by existing label", + "flag.issue.older_than_days": "Only include issues inactive for at least this many days", + "flag.issue.batch_list.limit": "Maximum issues to return, capped at 100", + "flag.issue.batch_close.state": "Filter by issue state before closing", + "flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7", + "flag.issue.batch_process.limit": "Maximum issues to process, capped at 100", + "flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.", + "flag.issue.batch.reason": "Optional reason shown in the batch result", + "flag.issue.batch_label.state": "Filter by issue state", + "flag.issue.add_label": "Label to add to each matching issue", + "flag.pr.state": "Filter: open, merged, closed", + "flag.pr.title": "PR title", + "flag.pr.body": "PR description", + "flag.pr.head": "Source branch", + "flag.pr.base": "Target branch", + "flag.pr.id": "PR number", + "flag.pr.merge_method": "Merge method: merge, rebase, squash", + "flag.pr.version_id": "Patchset version ID", + "flag.pr.file": "Filter diff by file path", + "flag.pr.review_status_filter": "Filter review status: common, approved, rejected", + "flag.pr.review_status": "Review status: common, approved, rejected", + "flag.pr.review_content": "Review content", + "flag.pr.review_commit": "Commit SHA to attach the review to", + "flag.branch.name": "Branch name", + "flag.branch.from": "Source branch or commit", + "flag.release.tag": "Tag name", + "flag.release.name": "Release name", + "flag.release.body": "Release notes", + "flag.release.target": "Target branch", + "flag.release.prerelease": "Mark as prerelease (true/false)", + "flag.release.id_or_tag": "Release ID or tag", + "flag.release.id": "Release ID", + "flag.org.id_or_login": "Organization ID or login", + "flag.org.id": "Organization ID", + "flag.org.name": "Organization name", + "flag.search.keyword": "Search keyword", + "flag.ci.build": "Build number", + "flag.ci.stage": "Stage number", + "flag.ci.step": "Step number", + "flag.webhook.id": "Webhook ID", + "flag.webhook.url": "Webhook target URL", + "flag.webhook.events": "Comma-separated events, for example: push,issues_only", + "flag.webhook.type": "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", + "flag.webhook.content_type": "Payload content type: json or form", + "flag.webhook.http_method": "HTTP method: POST or GET", + "flag.webhook.secret": "Webhook secret", + "flag.webhook.secret_update": "Webhook secret. Pass it again if the server does not return existing secrets.", + "flag.webhook.branch_filter": "Branch glob filter for push/create/delete events", + "flag.webhook.active": "Whether the webhook is active: true or false", + "error.unsupported_language": "unsupported language: {lang}", + "output.version": "gitlink-cli {version}" +} diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json new file mode 100644 index 0000000..b60f8f8 --- /dev/null +++ b/internal/i18n/locales/zh-CN.json @@ -0,0 +1,164 @@ +{ + "cmd.root.short": "GitLink CLI - GitLink 命令行工具", + "cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。", + "cmd.api.short": "向 GitLink 发起原始 API 请求", + "cmd.api.long": "向 GitLink API 发送任意 HTTP 请求。认证信息会自动注入。", + "cmd.auth.short": "认证命令", + "cmd.auth.login.short": "登录 GitLink", + "cmd.auth.logout.short": "退出 GitLink 登录", + "cmd.auth.status.short": "显示认证状态", + "cmd.config.short": "管理 gitlink-cli 配置", + "cmd.config.init.short": "初始化配置文件", + "cmd.config.set.short": "设置配置项", + "cmd.config.get.short": "获取配置项", + "cmd.config.list.short": "列出所有配置项", + "cmd.version.short": "打印版本信息", + "cmd.repo.short": "仓库操作", + "cmd.repo.list.short": "列出用户或组织的仓库", + "cmd.repo.info.short": "显示仓库详情", + "cmd.repo.create.short": "创建新仓库", + "cmd.repo.fork.short": "Fork 仓库", + "cmd.repo.delete.short": "删除仓库", + "cmd.issue.short": "议题操作", + "cmd.issue.list.short": "列出议题", + "cmd.issue.create.short": "创建新议题", + "cmd.issue.view.short": "查看议题详情", + "cmd.issue.close.short": "关闭议题", + "cmd.issue.update.short": "更新议题", + "cmd.issue.comment.short": "给议题添加评论", + "cmd.issue.batch_list.short": "列出议题批量维护候选项,不修改远端数据", + "cmd.issue.batch_list.long": "列出议题批量维护候选项,不修改远端数据。\n\n示例:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json", + "cmd.issue.batch_close.short": "批量关闭筛选后的议题。默认 dry-run;传入 --yes 后执行。", + "cmd.issue.batch_close.long": "批量关闭筛选后的议题。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端关闭操作。请使用严格筛选条件和较小 limit。\n\n示例:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", + "cmd.issue.batch_label.short": "给筛选后的议题批量添加标签。默认 dry-run;传入 --yes 后执行。", + "cmd.issue.batch_label.long": "给筛选后的议题批量添加标签。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端标签操作。当前实现不会在 API 端点不可用时伪造写入结果。\n\n示例:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", + "cmd.pr.short": "拉取请求操作", + "cmd.pr.list.short": "列出拉取请求", + "cmd.pr.create.short": "创建拉取请求", + "cmd.pr.view.short": "查看拉取请求详情", + "cmd.pr.merge.short": "合并拉取请求", + "cmd.pr.close.short": "关闭拉取请求", + "cmd.pr.files.short": "列出拉取请求中的变更文件", + "cmd.pr.diff.short": "显示拉取请求 diff", + "cmd.pr.versions.short": "列出拉取请求补丁集版本", + "cmd.pr.version_diff.short": "显示拉取请求补丁集版本 diff", + "cmd.pr.reviews.short": "列出拉取请求评审", + "cmd.pr.review.short": "创建拉取请求评审", + "cmd.pr.comment.short": "给拉取请求添加评论", + "cmd.release.short": "发布操作", + "cmd.release.list.short": "列出发布", + "cmd.release.create.short": "创建发布", + "cmd.release.view.short": "查看发布详情", + "cmd.release.delete.short": "删除发布", + "cmd.branch.short": "分支操作", + "cmd.branch.list.short": "列出分支", + "cmd.branch.create.short": "创建分支", + "cmd.branch.delete.short": "删除分支", + "cmd.branch.protect.short": "设置分支保护", + "cmd.branch.unprotect.short": "移除分支保护", + "cmd.org.short": "组织操作", + "cmd.org.list.short": "列出组织", + "cmd.org.info.short": "显示组织详情", + "cmd.org.members.short": "列出组织成员", + "cmd.org.create.short": "创建组织", + "cmd.user.short": "用户操作", + "cmd.user.me.short": "显示当前认证用户", + "cmd.user.info.short": "显示用户资料", + "cmd.search.short": "搜索操作", + "cmd.search.repos.short": "搜索仓库", + "cmd.search.users.short": "搜索用户", + "cmd.ci.short": "CI/CD 操作", + "cmd.ci.builds.short": "列出 CI 构建", + "cmd.ci.logs.short": "查看构建日志", + "cmd.ci.restart.short": "重启构建", + "cmd.ci.stop.short": "停止构建", + "cmd.webhook.short": "Webhook 操作", + "cmd.webhook.list.short": "列出仓库 Webhook", + "cmd.webhook.view.short": "查看 Webhook 详情", + "cmd.webhook.create.short": "创建仓库 Webhook", + "cmd.webhook.update.short": "更新仓库 Webhook,并在可用时保留未指定字段", + "cmd.webhook.delete.short": "删除仓库 Webhook", + "cmd.webhook.test.short": "触发 Webhook 测试投递", + "cmd.webhook.tasks.short": "列出 Webhook 投递任务", + "flag.owner": "仓库所有者(自动从 git remote 检测)", + "flag.repo": "仓库名称(自动从 git remote 检测)", + "flag.format": "输出格式:json、table、yaml(默认:table)", + "flag.debug": "启用调试输出", + "flag.lang": "显示语言", + "flag.api.body": "请求体(JSON 字符串)", + "flag.api.query": "查询参数(key=val&key2=val2)", + "flag.api.header": "附加请求头(key:value)", + "flag.auth.token": "通过粘贴已有 Token 登录", + "flag.user": "用户登录名(默认:当前用户)", + "flag.user.login": "用户登录名", + "flag.page": "页码", + "flag.limit": "每页条目数", + "flag.description": "描述", + "flag.dry_run": "预览请求,不实际创建", + "flag.comment.body": "评论内容", + "flag.repo.category": "筛选:manage/mirror/sync/fork/all(默认:manage)", + "flag.repo.name": "仓库名称", + "flag.repo.description": "仓库描述", + "flag.repo.private": "设为私有仓库(true/false)", + "flag.issue.state": "按状态筛选:open、closed、all", + "flag.issue.title": "议题标题", + "flag.issue.body": "议题描述", + "flag.issue.assignee": "负责人登录名", + "flag.issue.milestone": "里程碑 ID", + "flag.issue.label": "标签 ID", + "flag.issue.number": "议题编号(网页 URL 中显示的编号)", + "flag.issue.new_title": "新标题", + "flag.issue.new_body": "新描述", + "flag.issue.new_state": "新状态:open、closed 或数字 status_id", + "flag.issue.label_filter": "按已有标签筛选", + "flag.issue.older_than_days": "只包含至少这么多天未活动的议题", + "flag.issue.batch_list.limit": "最多返回的议题数,上限 100", + "flag.issue.batch_close.state": "关闭前按议题状态筛选", + "flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7", + "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", + "flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。", + "flag.issue.batch.reason": "批量结果中显示的可选原因", + "flag.issue.batch_label.state": "按议题状态筛选", + "flag.issue.add_label": "要添加到每个匹配议题的标签", + "flag.pr.state": "筛选:open、merged、closed", + "flag.pr.title": "PR 标题", + "flag.pr.body": "PR 描述", + "flag.pr.head": "源分支", + "flag.pr.base": "目标分支", + "flag.pr.id": "PR 编号", + "flag.pr.merge_method": "合并方式:merge、rebase、squash", + "flag.pr.version_id": "补丁集版本 ID", + "flag.pr.file": "按文件路径筛选 diff", + "flag.pr.review_status_filter": "按评审状态筛选:common、approved、rejected", + "flag.pr.review_status": "评审状态:common、approved、rejected", + "flag.pr.review_content": "评审内容", + "flag.pr.review_commit": "关联评审的 Commit SHA", + "flag.branch.name": "分支名称", + "flag.branch.from": "源分支或 Commit", + "flag.release.tag": "标签名称", + "flag.release.name": "发布名称", + "flag.release.body": "发布说明", + "flag.release.target": "目标分支", + "flag.release.prerelease": "标记为预发布(true/false)", + "flag.release.id_or_tag": "发布 ID 或标签", + "flag.release.id": "发布 ID", + "flag.org.id_or_login": "组织 ID 或登录名", + "flag.org.id": "组织 ID", + "flag.org.name": "组织名称", + "flag.search.keyword": "搜索关键词", + "flag.ci.build": "构建编号", + "flag.ci.stage": "阶段编号", + "flag.ci.step": "步骤编号", + "flag.webhook.id": "Webhook ID", + "flag.webhook.url": "Webhook 目标 URL", + "flag.webhook.events": "逗号分隔的事件,例如:push,issues_only", + "flag.webhook.type": "Webhook 类型:gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", + "flag.webhook.content_type": "Payload 内容类型:json 或 form", + "flag.webhook.http_method": "HTTP 方法:POST 或 GET", + "flag.webhook.secret": "Webhook 密钥", + "flag.webhook.secret_update": "Webhook 密钥。如果服务端不返回已有密钥,请再次传入。", + "flag.webhook.branch_filter": "用于 push/create/delete 事件的分支 glob 筛选", + "flag.webhook.active": "Webhook 是否启用:true 或 false", + "error.unsupported_language": "不支持的语言:{lang}", + "output.version": "gitlink-cli {version}" +} diff --git a/internal/i18n/options.go b/internal/i18n/options.go new file mode 100644 index 0000000..a1f055d --- /dev/null +++ b/internal/i18n/options.go @@ -0,0 +1,10 @@ +package i18n + +const defaultFallbackLocale = "en-US" + +// Options controls Translator construction. +type Options struct { + Locale string + FallbackLocale string + Loader Loader +} diff --git a/internal/i18n/resolver.go b/internal/i18n/resolver.go new file mode 100644 index 0000000..f315815 --- /dev/null +++ b/internal/i18n/resolver.go @@ -0,0 +1,66 @@ +package i18n + +import ( + "os" + "strings" +) + +// ResolveOptions contains locale inputs ordered by caller intent. +type ResolveOptions struct { + ExplicitLang string + Env map[string]string + ConfigLang string +} + +// ResolveLocale resolves a locale using CLI flag, env, config, system env, fallback. +func ResolveLocale(opts ResolveOptions, available []string) string { + candidates := []string{ + opts.ExplicitLang, + envValue(opts.Env, "GITLINK_LANG"), + opts.ConfigLang, + envValue(opts.Env, "LC_ALL"), + envValue(opts.Env, "LANG"), + } + for _, candidate := range candidates { + if strings.TrimSpace(candidate) == "" { + continue + } + return MatchLocale(candidate, available, defaultFallbackLocale) + } + return MatchLocale(defaultFallbackLocale, available, defaultFallbackLocale) +} + +// PreScanLang reads --lang before Cobra constructs localized help text. +func PreScanLang(args []string) string { + for i, arg := range args { + if arg == "--lang" { + if i+1 < len(args) { + return args[i+1] + } + return "" + } + if strings.HasPrefix(arg, "--lang=") { + return strings.TrimPrefix(arg, "--lang=") + } + } + return "" +} + +// EnvMap returns process environment as a string map. +func EnvMap() map[string]string { + env := make(map[string]string) + for _, item := range os.Environ() { + key, value, ok := strings.Cut(item, "=") + if ok { + env[key] = value + } + } + return env +} + +func envValue(env map[string]string, key string) string { + if env == nil { + return os.Getenv(key) + } + return env[key] +} diff --git a/internal/i18n/resolver_test.go b/internal/i18n/resolver_test.go new file mode 100644 index 0000000..07360df --- /dev/null +++ b/internal/i18n/resolver_test.go @@ -0,0 +1,31 @@ +package i18n + +import "testing" + +func TestPreScanLang(t *testing.T) { + cases := []struct { + args []string + want string + }{ + {[]string{"--lang", "zh-CN", "repo"}, "zh-CN"}, + {[]string{"repo", "--lang=zh-CN"}, "zh-CN"}, + {[]string{"repo"}, ""}, + } + for _, tc := range cases { + if got := PreScanLang(tc.args); got != tc.want { + t.Fatalf("PreScanLang(%v) = %q, want %q", tc.args, got, tc.want) + } + } +} + +func TestResolveLocalePriority(t *testing.T) { + available := []string{"en-US", "zh-CN"} + got := ResolveLocale(ResolveOptions{ + ExplicitLang: "en-US", + Env: map[string]string{"GITLINK_LANG": "zh-CN"}, + ConfigLang: "zh-CN", + }, available) + if got != "en-US" { + t.Fatalf("ResolveLocale() = %q, want en-US", got) + } +} diff --git a/internal/i18n/schema/messages.schema.json b/internal/i18n/schema/messages.schema.json new file mode 100644 index 0000000..a4a1ad5 --- /dev/null +++ b/internal/i18n/schema/messages.schema.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "GitLink CLI locale messages", + "type": "object", + "additionalProperties": { + "type": "string", + "minLength": 1 + }, + "propertyNames": { + "pattern": "^(cmd|flag|error|output|prompt|table)\\.[a-z0-9_.-]+$" + } +} diff --git a/internal/i18n/template.go b/internal/i18n/template.go new file mode 100644 index 0000000..21c299c --- /dev/null +++ b/internal/i18n/template.go @@ -0,0 +1,41 @@ +package i18n + +import ( + "fmt" + "regexp" + "sort" +) + +var placeholderPattern = regexp.MustCompile(`\{([A-Za-z_][A-Za-z0-9_]*)\}`) + +func renderTemplate(message string, args Args) string { + if len(args) == 0 { + return message + } + return placeholderPattern.ReplaceAllStringFunc(message, func(match string) string { + name := match[1 : len(match)-1] + value, ok := args[name] + if !ok || value == nil { + return match + } + if stringer, ok := value.(fmt.Stringer); ok { + return stringer.String() + } + return fmt.Sprint(value) + }) +} + +func extractTemplateArgs(message string) []string { + matches := placeholderPattern.FindAllStringSubmatch(message, -1) + seen := make(map[string]struct{}, len(matches)) + for _, match := range matches { + seen[match[1]] = struct{}{} + } + + args := make([]string, 0, len(seen)) + for arg := range seen { + args = append(args, arg) + } + sort.Strings(args) + return args +} diff --git a/internal/i18n/template_test.go b/internal/i18n/template_test.go new file mode 100644 index 0000000..19e4730 --- /dev/null +++ b/internal/i18n/template_test.go @@ -0,0 +1,24 @@ +package i18n + +import "testing" + +func TestRenderTemplateKeepsMissingArgs(t *testing.T) { + got := renderTemplate("Delete {owner}/{repo}", Args{"owner": "alice"}) + want := "Delete alice/{repo}" + if got != want { + t.Fatalf("renderTemplate() = %q, want %q", got, want) + } +} + +func TestExtractTemplateArgs(t *testing.T) { + got := extractTemplateArgs("Delete {owner}/{repo}/{owner}") + want := []string{"owner", "repo"} + if len(got) != len(want) { + t.Fatalf("args length = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("arg[%d] = %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/internal/i18n/translator.go b/internal/i18n/translator.go new file mode 100644 index 0000000..aa8e550 --- /dev/null +++ b/internal/i18n/translator.go @@ -0,0 +1,89 @@ +package i18n + +import "fmt" + +// Translator resolves localized messages with fallback behavior suitable for CLI use. +type Translator struct { + locale string + fallbackLocale string + messages map[string]string + fallback map[string]string +} + +// Default returns an English translator for call sites that have not been +// wired for dependency injection yet. +func Default() *Translator { + tr, err := New(Options{Locale: defaultFallbackLocale}) + if err != nil { + return &Translator{ + locale: defaultFallbackLocale, + fallbackLocale: defaultFallbackLocale, + messages: map[string]string{}, + fallback: map[string]string{}, + } + } + return tr +} + +// New constructs a Translator. Missing messages fall back to FallbackLocale. +func New(opts Options) (*Translator, error) { + loader := opts.Loader + if loader == nil { + loader = NewEmbedLoader() + } + + available, err := loader.AvailableLocales() + if err != nil { + return nil, err + } + + fallbackLocale := opts.FallbackLocale + if fallbackLocale == "" { + fallbackLocale = defaultFallbackLocale + } + fallbackLocale = MatchLocale(fallbackLocale, available, defaultFallbackLocale) + locale := MatchLocale(opts.Locale, available, fallbackLocale) + + fallback, err := loader.Load(fallbackLocale) + if err != nil { + return nil, fmt.Errorf("load fallback locale: %w", err) + } + + messages := fallback + if locale != fallbackLocale { + messages, err = loader.Load(locale) + if err != nil { + return nil, fmt.Errorf("load locale: %w", err) + } + } + + return &Translator{ + locale: locale, + fallbackLocale: fallbackLocale, + messages: messages, + fallback: fallback, + }, nil +} + +func (t *Translator) Locale() string { + return t.locale +} + +// T returns a localized message, falling back to en-US and then the key itself. +func (t *Translator) T(key string) string { + if t == nil { + return key + } + if value, ok := t.messages[key]; ok { + return value + } + if value, ok := t.fallback[key]; ok { + return value + } + return key +} + +// Tf returns a localized message with {name} placeholders rendered from args. +func (t *Translator) Tf(key string, args Args) string { + return renderTemplate(t.T(key), args) +} diff --git a/internal/i18n/translator_test.go b/internal/i18n/translator_test.go new file mode 100644 index 0000000..541047c --- /dev/null +++ b/internal/i18n/translator_test.go @@ -0,0 +1,79 @@ +package i18n + +import ( + "errors" + "testing" +) + +type mapLoader struct { + messages map[string]map[string]string +} + +func (l mapLoader) Load(locale string) (map[string]string, error) { + messages, ok := l.messages[locale] + if !ok { + return nil, errors.New("missing locale") + } + return messages, nil +} + +func (l mapLoader) AvailableLocales() ([]string, error) { + locales := make([]string, 0, len(l.messages)) + for locale := range l.messages { + locales = append(locales, locale) + } + return locales, nil +} + +func TestTranslatorReturnsLocalizedMessage(t *testing.T) { + tr, err := New(Options{ + Locale: "zh-CN", + Loader: mapLoader{messages: map[string]map[string]string{ + "en-US": {"cmd.root.short": "GitLink CLI"}, + "zh-CN": {"cmd.root.short": "GitLink 命令行"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + + if got := tr.T("cmd.root.short"); got != "GitLink 命令行" { + t.Fatalf("expected localized message, got %q", got) + } +} + +func TestTranslatorFallsBackToBaseThenKey(t *testing.T) { + tr, err := New(Options{ + Locale: "zh-CN", + Loader: mapLoader{messages: map[string]map[string]string{ + "en-US": {"cmd.root.short": "GitLink CLI"}, + "zh-CN": {}, + }}, + }) + if err != nil { + t.Fatal(err) + } + + if got := tr.T("cmd.root.short"); got != "GitLink CLI" { + t.Fatalf("expected fallback message, got %q", got) + } + if got := tr.T("cmd.missing.short"); got != "cmd.missing.short" { + t.Fatalf("expected key fallback, got %q", got) + } +} + +func TestTranslatorRendersArgs(t *testing.T) { + tr, err := New(Options{ + Locale: "en-US", + Loader: mapLoader{messages: map[string]map[string]string{ + "en-US": {"output.version": "gitlink-cli {version}"}, + }}, + }) + if err != nil { + t.Fatal(err) + } + + if got := tr.Tf("output.version", Args{"version": "1.2.3"}); got != "gitlink-cli 1.2.3" { + t.Fatalf("expected rendered message, got %q", got) + } +} diff --git a/internal/i18n/validate.go b/internal/i18n/validate.go new file mode 100644 index 0000000..1510cfb --- /dev/null +++ b/internal/i18n/validate.go @@ -0,0 +1,104 @@ +package i18n + +import ( + "fmt" + "reflect" + "regexp" + "sort" + "strings" +) + +var keyPattern = regexp.MustCompile(`^(cmd|flag|error|output|prompt|table)\.[a-z0-9_.-]+$`) + +// Problem describes a locale validation issue. +type Problem struct { + Locale string + Key string + Message string +} + +func (p Problem) String() string { + if p.Key == "" { + return fmt.Sprintf("%s: %s", p.Locale, p.Message) + } + return fmt.Sprintf("%s:%s: %s", p.Locale, p.Key, p.Message) +} + +// Validate checks all locales against baseLocale. +func Validate(loader Loader, baseLocale string) ([]Problem, error) { + if loader == nil { + loader = NewEmbedLoader() + } + baseLocale = NormalizeLocale(baseLocale) + if baseLocale == "" { + baseLocale = defaultFallbackLocale + } + + locales, err := loader.AvailableLocales() + if err != nil { + return nil, err + } + sort.Strings(locales) + + allMessages := make(map[string]map[string]string, len(locales)) + for _, locale := range locales { + normalized := NormalizeLocale(locale) + if normalized != locale { + return []Problem{{Locale: locale, Message: "locale filename is not normalized"}}, nil + } + messages, err := loader.Load(locale) + if err != nil { + return nil, err + } + allMessages[locale] = messages + } + + base, ok := allMessages[baseLocale] + if !ok { + return []Problem{{Locale: baseLocale, Message: "base locale is missing"}}, nil + } + + var problems []Problem + for key, value := range base { + problems = append(problems, validateMessage(baseLocale, key, value)...) + } + + for _, locale := range locales { + messages := allMessages[locale] + for key, value := range messages { + problems = append(problems, validateMessage(locale, key, value)...) + if _, ok := base[key]; !ok { + problems = append(problems, Problem{Locale: locale, Key: key, Message: "key is not present in base locale"}) + } + } + for key, baseValue := range base { + value, ok := messages[key] + if !ok { + problems = append(problems, Problem{Locale: locale, Key: key, Message: "missing key"}) + continue + } + baseArgs := extractTemplateArgs(baseValue) + args := extractTemplateArgs(value) + if !reflect.DeepEqual(baseArgs, args) { + problems = append(problems, Problem{ + Locale: locale, + Key: key, + Message: fmt.Sprintf("template args mismatch: expected {%s}, got {%s}", strings.Join(baseArgs, ","), strings.Join(args, ",")), + }) + } + } + } + + return problems, nil +} + +func validateMessage(locale, key, value string) []Problem { + var problems []Problem + if !keyPattern.MatchString(key) { + problems = append(problems, Problem{Locale: locale, Key: key, Message: "key does not match naming rules"}) + } + if strings.TrimSpace(value) == "" { + problems = append(problems, Problem{Locale: locale, Key: key, Message: "message is empty"}) + } + return problems +} diff --git a/internal/i18n/validate_test.go b/internal/i18n/validate_test.go new file mode 100644 index 0000000..d5d2cf1 --- /dev/null +++ b/internal/i18n/validate_test.go @@ -0,0 +1,31 @@ +package i18n + +import "testing" + +func TestValidateEmbeddedLocales(t *testing.T) { + problems, err := Validate(NewEmbedLoader(), "en-US") + if err != nil { + t.Fatal(err) + } + if len(problems) > 0 { + t.Fatalf("expected no problems, got %v", problems) + } +} + +func TestValidateFindsMissingKeyAndArgMismatch(t *testing.T) { + problems, err := Validate(mapLoader{messages: map[string]map[string]string{ + "en-US": { + "cmd.root.short": "Hello {name}", + "flag.owner": "Owner", + }, + "zh-CN": { + "cmd.root.short": "你好", + }, + }}, "en-US") + if err != nil { + t.Fatal(err) + } + if len(problems) != 2 { + t.Fatalf("expected 2 problems, got %d: %v", len(problems), problems) + } +} diff --git a/shortcuts/branch/branch.go b/shortcuts/branch/branch.go index 37684b6..0393ad5 100644 --- a/shortcuts/branch/branch.go +++ b/shortcuts/branch/branch.go @@ -4,17 +4,19 @@ import ( "fmt" "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List branches", + Description: tr.T("cmd.branch.list.short"), Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -32,10 +34,10 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a branch", + Description: tr.T("cmd.branch.create.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Branch name", Required: true}, - {Name: "from", Short: "f", Usage: "Source branch or commit", Default: "master"}, + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, + {Name: "from", Short: "f", Usage: tr.T("flag.branch.from"), Default: "master"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -59,9 +61,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "delete", - Description: "Delete a branch", + Description: tr.T("cmd.branch.delete.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Branch name", Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -80,9 +82,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "protect", - Description: "Set branch protection", + Description: tr.T("cmd.branch.protect.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Branch name", Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -101,9 +103,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "unprotect", - Description: "Remove branch protection", + Description: tr.T("cmd.branch.unprotect.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Branch name", Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.branch.name"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -119,3 +121,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/ci/ci.go b/shortcuts/ci/ci.go index eb15ad3..6794c47 100644 --- a/shortcuts/ci/ci.go +++ b/shortcuts/ci/ci.go @@ -4,17 +4,19 @@ import ( "fmt" "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "builds", - Description: "List CI builds", + Description: tr.T("cmd.ci.builds.short"), Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -32,11 +34,11 @@ func Shortcuts() []*common.Shortcut { }, { Name: "logs", - Description: "View build logs", + Description: tr.T("cmd.ci.logs.short"), Flags: []common.Flag{ - {Name: "build", Short: "b", Usage: "Build number", Required: true}, - {Name: "stage", Short: "s", Usage: "Stage number", Default: "1"}, - {Name: "step", Usage: "Step number", Default: "1"}, + {Name: "build", Short: "b", Usage: tr.T("flag.ci.build"), Required: true}, + {Name: "stage", Short: "s", Usage: tr.T("flag.ci.stage"), Default: "1"}, + {Name: "step", Usage: tr.T("flag.ci.step"), Default: "1"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -60,9 +62,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "restart", - Description: "Restart a build", + Description: tr.T("cmd.ci.restart.short"), Flags: []common.Flag{ - {Name: "build", Short: "b", Usage: "Build number", Required: true}, + {Name: "build", Short: "b", Usage: tr.T("flag.ci.build"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -78,9 +80,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "stop", - Description: "Stop a build", + Description: tr.T("cmd.ci.stop.short"), Flags: []common.Flag{ - {Name: "build", Short: "b", Usage: "Build number", Required: true}, + {Name: "build", Short: "b", Usage: tr.T("flag.ci.build"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -96,3 +98,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index 73a7ee2..ae03986 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -14,6 +14,7 @@ import ( "time" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -60,68 +61,50 @@ type batchIssueOptions struct { Action string } -func newBatchListShortcut() *common.Shortcut { +func newBatchListShortcut(tr *i18n.Translator) *common.Shortcut { return &common.Shortcut{ Name: "batch-list", - Description: "List issue batch maintenance candidates without changing remote data", - Long: `List issue batch maintenance candidates without changing remote data. - -Examples: - gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table - gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json`, + Description: tr.T("cmd.issue.batch_list.short"), + Long: tr.T("cmd.issue.batch_list.long"), Flags: []common.Flag{ - {Name: "state", Short: "s", Usage: "Filter by issue state: open, closed, all", Default: "open"}, - {Name: "label", Usage: "Filter by existing label"}, - {Name: "older-than-days", Usage: "Only include issues inactive for at least this many days"}, - {Name: "limit", Short: "l", Usage: "Maximum issues to return, capped at 100", Default: "50"}, + {Name: "state", Short: "s", Usage: tr.T("flag.issue.state"), Default: "open"}, + {Name: "label", Usage: tr.T("flag.issue.label_filter")}, + {Name: "older-than-days", Usage: tr.T("flag.issue.older_than_days")}, + {Name: "limit", Short: "l", Usage: tr.T("flag.issue.batch_list.limit"), Default: "50"}, }, Run: runBatchList, } } -func newBatchCloseShortcut() *common.Shortcut { +func newBatchCloseShortcut(tr *i18n.Translator) *common.Shortcut { return &common.Shortcut{ Name: "batch-close", - Description: "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", - Long: `Close filtered issues in bulk. - -This command defaults to dry-run mode and only prints matching issues. -Pass --yes to execute remote close operations. Use restrictive filters and a small limit. - -Examples: - gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 - gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes`, + Description: tr.T("cmd.issue.batch_close.short"), + Long: tr.T("cmd.issue.batch_close.long"), Flags: []common.Flag{ - {Name: "state", Short: "s", Usage: "Filter by issue state before closing", Default: "open"}, - {Name: "label", Usage: "Filter by existing label"}, - {Name: "older-than-days", Usage: "Required safety filter; must be at least 7"}, - {Name: "limit", Short: "l", Usage: "Maximum issues to process, capped at 100", Default: "20"}, - {Name: "yes", Usage: "Execute remote close operations. Without this flag the command is dry-run only.", Bool: true, Default: "false"}, - {Name: "reason", Usage: "Optional reason shown in the batch result"}, + {Name: "state", Short: "s", Usage: tr.T("flag.issue.batch_close.state"), Default: "open"}, + {Name: "label", Usage: tr.T("flag.issue.label_filter")}, + {Name: "older-than-days", Usage: tr.T("flag.issue.batch_close.older_than_days")}, + {Name: "limit", Short: "l", Usage: tr.T("flag.issue.batch_process.limit"), Default: "20"}, + {Name: "yes", Usage: tr.T("flag.issue.batch.yes"), Bool: true, Default: "false"}, + {Name: "reason", Usage: tr.T("flag.issue.batch.reason")}, }, Run: runBatchClose, } } -func newBatchLabelShortcut() *common.Shortcut { +func newBatchLabelShortcut(tr *i18n.Translator) *common.Shortcut { return &common.Shortcut{ Name: "batch-label", - Description: "Add a label to filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", - Long: `Add a label to filtered issues in bulk. - -This command defaults to dry-run mode and only prints matching issues. -Pass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable. - -Examples: - gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 - gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes`, + Description: tr.T("cmd.issue.batch_label.short"), + Long: tr.T("cmd.issue.batch_label.long"), Flags: []common.Flag{ - {Name: "state", Short: "s", Usage: "Filter by issue state", Default: "open"}, - {Name: "label", Usage: "Filter by existing label"}, - {Name: "add-label", Usage: "Label to add to each matching issue"}, - {Name: "older-than-days", Usage: "Only include issues inactive for at least this many days"}, - {Name: "limit", Short: "l", Usage: "Maximum issues to process, capped at 100", Default: "50"}, - {Name: "yes", Usage: "Execute remote label operations. Without this flag the command is dry-run only.", Bool: true, Default: "false"}, + {Name: "state", Short: "s", Usage: tr.T("flag.issue.batch_label.state"), Default: "open"}, + {Name: "label", Usage: tr.T("flag.issue.label_filter")}, + {Name: "add-label", Usage: tr.T("flag.issue.add_label")}, + {Name: "older-than-days", Usage: tr.T("flag.issue.older_than_days")}, + {Name: "limit", Short: "l", Usage: tr.T("flag.issue.batch_process.limit"), Default: "50"}, + {Name: "yes", Usage: tr.T("flag.issue.batch.yes"), Bool: true, Default: "false"}, }, Run: runBatchLabel, } diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 4ed2373..77760c7 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -20,18 +21,19 @@ type existingIssue struct { Description string } -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ - newBatchListShortcut(), - newBatchCloseShortcut(), - newBatchLabelShortcut(), + newBatchListShortcut(tr), + newBatchCloseShortcut(tr), + newBatchLabelShortcut(tr), { Name: "list", - Description: "List issues", + Description: tr.T("cmd.issue.list.short"), Flags: []common.Flag{ - {Name: "state", Short: "s", Usage: "Filter by state: open, closed, all", Default: "open"}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "state", Short: "s", Usage: tr.T("flag.issue.state"), Default: "open"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -53,13 +55,13 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a new issue", + Description: tr.T("cmd.issue.create.short"), Flags: []common.Flag{ - {Name: "title", Short: "t", Usage: "Issue title", Required: true}, - {Name: "body", Short: "b", Usage: "Issue description"}, - {Name: "assignee", Short: "a", Usage: "Assignee login"}, - {Name: "milestone", Short: "m", Usage: "Milestone ID"}, - {Name: "label", Usage: "Label ID"}, + {Name: "title", Short: "t", Usage: tr.T("flag.issue.title"), Required: true}, + {Name: "body", Short: "b", Usage: tr.T("flag.issue.body")}, + {Name: "assignee", Short: "a", Usage: tr.T("flag.issue.assignee")}, + {Name: "milestone", Short: "m", Usage: tr.T("flag.issue.milestone")}, + {Name: "label", Usage: tr.T("flag.issue.label")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -93,9 +95,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "view", - Description: "View issue details", + Description: tr.T("cmd.issue.view.short"), Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, + {Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -114,9 +116,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "close", - Description: "Close an issue", + Description: tr.T("cmd.issue.close.short"), Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, + {Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -145,12 +147,12 @@ func Shortcuts() []*common.Shortcut { }, { Name: "update", - Description: "Update an issue", + Description: tr.T("cmd.issue.update.short"), Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, - {Name: "title", Short: "t", Usage: "New title"}, - {Name: "body", Short: "b", Usage: "New description"}, - {Name: "state", Short: "s", Usage: "New state: open, closed, or numeric status_id"}, + {Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), Required: true}, + {Name: "title", Short: "t", Usage: tr.T("flag.issue.new_title")}, + {Name: "body", Short: "b", Usage: tr.T("flag.issue.new_body")}, + {Name: "state", Short: "s", Usage: tr.T("flag.issue.new_state")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -198,10 +200,10 @@ func Shortcuts() []*common.Shortcut { }, { Name: "comment", - Description: "Add a comment to an issue", + Description: tr.T("cmd.issue.comment.short"), Flags: []common.Flag{ - {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, - {Name: "body", Short: "b", Usage: "Comment body", Required: true}, + {Name: "number", Short: "n", Usage: tr.T("flag.issue.number"), Required: true}, + {Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -228,6 +230,13 @@ func Shortcuts() []*common.Shortcut { } } +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} + // normalizeIssueListIDs adds "number" (project_issues_index) and renames // "id" to "database_id" so the user-facing output uses the project-level // issue number, not the global database primary key. diff --git a/shortcuts/org/org.go b/shortcuts/org/org.go index 4cd6299..f0b5e72 100644 --- a/shortcuts/org/org.go +++ b/shortcuts/org/org.go @@ -4,17 +4,19 @@ import ( "fmt" "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List organizations", + Description: tr.T("cmd.org.list.short"), Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { q := url.Values{} @@ -29,9 +31,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "info", - Description: "Show organization details", + Description: tr.T("cmd.org.info.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Organization ID or login", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.org.id_or_login"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { id, _ := ctx.RequireArg("id") @@ -44,11 +46,11 @@ func Shortcuts() []*common.Shortcut { }, { Name: "members", - Description: "List organization members", + Description: tr.T("cmd.org.members.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Organization ID", Required: true}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { id, _ := ctx.RequireArg("id") @@ -64,10 +66,10 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create an organization", + Description: tr.T("cmd.org.create.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Organization name", Required: true}, - {Name: "description", Short: "d", Usage: "Description"}, + {Name: "name", Short: "n", Usage: tr.T("flag.org.name"), Required: true}, + {Name: "description", Short: "d", Usage: tr.T("flag.description")}, }, Run: func(ctx *common.RuntimeContext) error { name, _ := ctx.RequireArg("name") @@ -86,3 +88,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 7f9984c..5a4fca1 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -4,19 +4,21 @@ import ( "fmt" "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List pull requests", + Description: tr.T("cmd.pr.list.short"), Flags: []common.Flag{ - {Name: "state", Short: "s", Usage: "Filter: open, merged, closed", Default: "open"}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "state", Short: "s", Usage: tr.T("flag.pr.state"), Default: "open"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -37,12 +39,12 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a pull request", + Description: tr.T("cmd.pr.create.short"), Flags: []common.Flag{ - {Name: "title", Short: "t", Usage: "PR title", Required: true}, - {Name: "body", Short: "b", Usage: "PR description"}, - {Name: "head", Usage: "Source branch", Required: true}, - {Name: "base", Usage: "Target branch", Default: "master"}, + {Name: "title", Short: "t", Usage: tr.T("flag.pr.title"), Required: true}, + {Name: "body", Short: "b", Usage: tr.T("flag.pr.body")}, + {Name: "head", Usage: tr.T("flag.pr.head"), Required: true}, + {Name: "base", Usage: tr.T("flag.pr.base"), Default: "master"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -71,9 +73,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "view", - Description: "View pull request details", + Description: tr.T("cmd.pr.view.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -89,10 +91,10 @@ func Shortcuts() []*common.Shortcut { }, { Name: "merge", - Description: "Merge a pull request", + Description: tr.T("cmd.pr.merge.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, - {Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "method", Short: "m", Usage: tr.T("flag.pr.merge_method"), Default: "merge"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -115,9 +117,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "close", - Description: "Close a pull request", + Description: tr.T("cmd.pr.close.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -133,9 +135,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "files", - Description: "List changed files in a pull request", + Description: tr.T("cmd.pr.files.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -151,9 +153,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "diff", - Description: "Show diff for a pull request", + Description: tr.T("cmd.pr.diff.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -169,9 +171,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "versions", - Description: "List pull request patchset versions", + Description: tr.T("cmd.pr.versions.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -190,11 +192,11 @@ func Shortcuts() []*common.Shortcut { }, { Name: "version-diff", - Description: "Show diff for a pull request patchset version", + Description: tr.T("cmd.pr.version_diff.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, - {Name: "version-id", Short: "v", Usage: "Patchset version ID", Required: true}, - {Name: "file", Short: "f", Usage: "Filter diff by file path"}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "version-id", Short: "v", Usage: tr.T("flag.pr.version_id"), Required: true}, + {Name: "file", Short: "f", Usage: tr.T("flag.pr.file")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -227,10 +229,10 @@ func Shortcuts() []*common.Shortcut { }, { Name: "reviews", - Description: "List pull request reviews", + Description: tr.T("cmd.pr.reviews.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, - {Name: "status", Short: "s", Usage: "Filter review status: common, approved, rejected"}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "status", Short: "s", Usage: tr.T("flag.pr.review_status_filter")}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -256,13 +258,13 @@ func Shortcuts() []*common.Shortcut { }, { Name: "review", - Description: "Create a pull request review", + Description: tr.T("cmd.pr.review.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, - {Name: "status", Short: "s", Usage: "Review status: common, approved, rejected", Default: "common"}, - {Name: "content", Short: "c", Usage: "Review content", Required: true}, - {Name: "commit", Short: "m", Usage: "Commit SHA to attach the review to"}, - {Name: "dry-run", Usage: "Preview the review request without creating it", Bool: true, Default: "false"}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "status", Short: "s", Usage: tr.T("flag.pr.review_status"), Default: "common"}, + {Name: "content", Short: "c", Usage: tr.T("flag.pr.review_content"), Required: true}, + {Name: "commit", Short: "m", Usage: tr.T("flag.pr.review_commit")}, + {Name: "dry-run", Usage: tr.T("flag.dry_run"), Bool: true, Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -308,10 +310,10 @@ func Shortcuts() []*common.Shortcut { }, { Name: "comment", - Description: "Add a comment to a pull request", + Description: tr.T("cmd.pr.comment.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "PR number", Required: true}, - {Name: "body", Short: "b", Usage: "Comment body", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.pr.id"), Required: true}, + {Name: "body", Short: "b", Usage: tr.T("flag.comment.body"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -342,6 +344,13 @@ func Shortcuts() []*common.Shortcut { } } +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} + func prV1Path(ctx *common.RuntimeContext, id string) string { return fmt.Sprintf("/v1/%s/%s/pulls/%s", ctx.Owner, ctx.Repo, id) } diff --git a/shortcuts/register.go b/shortcuts/register.go index 84362f5..26e42ed 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -3,6 +3,7 @@ package shortcuts import ( "github.com/spf13/cobra" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -17,31 +18,31 @@ import ( ) // RegisterAll mounts all shortcut groups onto the root command. -func RegisterAll(root *cobra.Command) { +func RegisterAll(root *cobra.Command, tr *i18n.Translator) { groups := map[string][]*common.Shortcut{ - "repo": repo.Shortcuts(), - "issue": issue.Shortcuts(), - "pr": pr.Shortcuts(), - "release": release.Shortcuts(), - "branch": branch.Shortcuts(), - "org": org.Shortcuts(), - "user": user.Shortcuts(), - "search": search.Shortcuts(), - "ci": ci.Shortcuts(), - "webhook": webhook.Shortcuts(), + "repo": repo.Shortcuts(tr), + "issue": issue.Shortcuts(tr), + "pr": pr.Shortcuts(tr), + "release": release.Shortcuts(tr), + "branch": branch.Shortcuts(tr), + "org": org.Shortcuts(tr), + "user": user.Shortcuts(tr), + "search": search.Shortcuts(tr), + "ci": ci.Shortcuts(tr), + "webhook": webhook.Shortcuts(tr), } descriptions := map[string]string{ - "repo": "Repository operations", - "issue": "Issue operations", - "pr": "Pull request operations", - "release": "Release operations", - "branch": "Branch operations", - "org": "Organization operations", - "user": "User operations", - "search": "Search operations", - "ci": "CI/CD operations", - "webhook": "Webhook operations", + "repo": tr.T("cmd.repo.short"), + "issue": tr.T("cmd.issue.short"), + "pr": tr.T("cmd.pr.short"), + "release": tr.T("cmd.release.short"), + "branch": tr.T("cmd.branch.short"), + "org": tr.T("cmd.org.short"), + "user": tr.T("cmd.user.short"), + "search": tr.T("cmd.search.short"), + "ci": tr.T("cmd.ci.short"), + "webhook": tr.T("cmd.webhook.short"), } for name, shortcuts := range groups { diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index 06240bc..c93d6a0 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -4,18 +4,20 @@ import ( "fmt" "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List releases", + Description: tr.T("cmd.release.list.short"), Flags: []common.Flag{ - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -33,13 +35,13 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a release", + Description: tr.T("cmd.release.create.short"), Flags: []common.Flag{ - {Name: "tag", Short: "t", Usage: "Tag name", Required: true}, - {Name: "name", Short: "n", Usage: "Release name", Required: true}, - {Name: "body", Short: "b", Usage: "Release notes"}, - {Name: "target", Usage: "Target branch", Default: "master"}, - {Name: "prerelease", Usage: "Mark as prerelease (true/false)", Default: "false"}, + {Name: "tag", Short: "t", Usage: tr.T("flag.release.tag"), Required: true}, + {Name: "name", Short: "n", Usage: tr.T("flag.release.name"), Required: true}, + {Name: "body", Short: "b", Usage: tr.T("flag.release.body")}, + {Name: "target", Usage: tr.T("flag.release.target"), Default: "master"}, + {Name: "prerelease", Usage: tr.T("flag.release.prerelease"), Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -69,9 +71,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "view", - Description: "View release details", + Description: tr.T("cmd.release.view.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Release ID or tag", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.release.id_or_tag"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -87,9 +89,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "delete", - Description: "Delete a release", + Description: tr.T("cmd.release.delete.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Release ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.release.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -117,3 +119,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 75091a4..6d76264 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -4,19 +4,21 @@ import ( "fmt" "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List repositories for a user or organization", + Description: tr.T("cmd.repo.list.short"), Flags: []common.Flag{ - {Name: "user", Short: "u", Usage: "User login (default: current user)"}, - {Name: "category", Short: "c", Usage: "Filter: manage/mirror/sync/fork/all (default: manage)", Default: "manage"}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "user", Short: "u", Usage: tr.T("flag.user"), Default: ""}, + {Name: "category", Short: "c", Usage: tr.T("flag.repo.category"), Default: "manage"}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { user := ctx.Arg("user") @@ -40,7 +42,7 @@ func Shortcuts() []*common.Shortcut { }, { Name: "info", - Description: "Show repository details", + Description: tr.T("cmd.repo.info.short"), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -54,11 +56,11 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a new repository", + Description: tr.T("cmd.repo.create.short"), Flags: []common.Flag{ - {Name: "name", Short: "n", Usage: "Repository name", Required: true}, - {Name: "description", Short: "d", Usage: "Repository description"}, - {Name: "private", Usage: "Make repository private (true/false)", Default: "false"}, + {Name: "name", Short: "n", Usage: tr.T("flag.repo.name"), Required: true}, + {Name: "description", Short: "d", Usage: tr.T("flag.repo.description")}, + {Name: "private", Usage: tr.T("flag.repo.private"), Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { name, err := ctx.RequireArg("name") @@ -96,7 +98,7 @@ func Shortcuts() []*common.Shortcut { }, { Name: "fork", - Description: "Fork a repository", + Description: tr.T("cmd.repo.fork.short"), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -110,7 +112,7 @@ func Shortcuts() []*common.Shortcut { }, { Name: "delete", - Description: "Delete a repository", + Description: tr.T("cmd.repo.delete.short"), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -124,3 +126,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/search/search.go b/shortcuts/search/search.go index 443dec3..a0ee4c2 100644 --- a/shortcuts/search/search.go +++ b/shortcuts/search/search.go @@ -3,18 +3,20 @@ package search import ( "net/url" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "repos", - Description: "Search repositories", + Description: tr.T("cmd.search.repos.short"), Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: "Search keyword", Required: true}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword"), Required: true}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { keyword, _ := ctx.RequireArg("keyword") @@ -31,11 +33,11 @@ func Shortcuts() []*common.Shortcut { }, { Name: "users", - Description: "Search users", + Description: tr.T("cmd.search.users.short"), Flags: []common.Flag{ - {Name: "keyword", Short: "k", Usage: "Search keyword", Required: true}, - {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, - {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + {Name: "keyword", Short: "k", Usage: tr.T("flag.search.keyword"), Required: true}, + {Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"}, + {Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { keyword, _ := ctx.RequireArg("keyword") @@ -52,3 +54,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/user/user.go b/shortcuts/user/user.go index cfcaa2a..563db88 100644 --- a/shortcuts/user/user.go +++ b/shortcuts/user/user.go @@ -3,14 +3,16 @@ package user import ( "fmt" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "me", - Description: "Show current authenticated user", + Description: tr.T("cmd.user.me.short"), Run: func(ctx *common.RuntimeContext) error { env, err := ctx.CallAPI("GET", "/users/me", nil) if err != nil { @@ -21,9 +23,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "info", - Description: "Show user profile", + Description: tr.T("cmd.user.info.short"), Flags: []common.Flag{ - {Name: "login", Short: "l", Usage: "User login name", Required: true}, + {Name: "login", Short: "l", Usage: tr.T("flag.user.login"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { login, err := ctx.RequireArg("login") @@ -39,3 +41,10 @@ func Shortcuts() []*common.Shortcut { }, } } + +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go index 091200e..7d0574a 100644 --- a/shortcuts/webhook/webhook.go +++ b/shortcuts/webhook/webhook.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -22,11 +23,12 @@ var allowedWebhookEvents = map[string]bool{ } // Shortcuts returns webhook management shortcuts. -func Shortcuts() []*common.Shortcut { +func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { + tr := shortcutTranslator(translators...) return []*common.Shortcut{ { Name: "list", - Description: "List repository webhooks", + Description: tr.T("cmd.webhook.list.short"), Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -40,9 +42,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "view", - Description: "View webhook details", + Description: tr.T("cmd.webhook.view.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -61,40 +63,40 @@ func Shortcuts() []*common.Shortcut { }, { Name: "create", - Description: "Create a repository webhook", + Description: tr.T("cmd.webhook.create.short"), Flags: []common.Flag{ - {Name: "url", Short: "u", Usage: "Webhook target URL", Required: true}, - {Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only", Required: true}, - {Name: "type", Short: "t", Usage: "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", Default: "gitea"}, - {Name: "content-type", Usage: "Payload content type: json or form", Default: "json"}, - {Name: "http-method", Usage: "HTTP method: POST or GET", Default: "POST"}, - {Name: "secret", Short: "s", Usage: "Webhook secret"}, - {Name: "branch-filter", Usage: "Branch glob filter for push/create/delete events", Default: "*"}, - {Name: "active", Usage: "Whether the webhook is active: true or false", Default: "true"}, + {Name: "url", Short: "u", Usage: tr.T("flag.webhook.url"), Required: true}, + {Name: "events", Short: "e", Usage: tr.T("flag.webhook.events"), Required: true}, + {Name: "type", Short: "t", Usage: tr.T("flag.webhook.type"), Default: "gitea"}, + {Name: "content-type", Usage: tr.T("flag.webhook.content_type"), Default: "json"}, + {Name: "http-method", Usage: tr.T("flag.webhook.http_method"), Default: "POST"}, + {Name: "secret", Short: "s", Usage: tr.T("flag.webhook.secret")}, + {Name: "branch-filter", Usage: tr.T("flag.webhook.branch_filter"), Default: "*"}, + {Name: "active", Usage: tr.T("flag.webhook.active"), Default: "true"}, }, Run: runCreate, }, { Name: "update", - Description: "Update a repository webhook while preserving unspecified fields when available", + Description: tr.T("cmd.webhook.update.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, - {Name: "url", Short: "u", Usage: "Webhook target URL"}, - {Name: "events", Short: "e", Usage: "Comma-separated events, for example: push,issues_only"}, - {Name: "type", Short: "t", Usage: "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot"}, - {Name: "content-type", Usage: "Payload content type: json or form"}, - {Name: "http-method", Usage: "HTTP method: POST or GET"}, - {Name: "secret", Short: "s", Usage: "Webhook secret. Pass it again if the server does not return existing secrets."}, - {Name: "branch-filter", Usage: "Branch glob filter for push/create/delete events"}, - {Name: "active", Usage: "Whether the webhook is active: true or false"}, + {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, + {Name: "url", Short: "u", Usage: tr.T("flag.webhook.url")}, + {Name: "events", Short: "e", Usage: tr.T("flag.webhook.events")}, + {Name: "type", Short: "t", Usage: tr.T("flag.webhook.type")}, + {Name: "content-type", Usage: tr.T("flag.webhook.content_type")}, + {Name: "http-method", Usage: tr.T("flag.webhook.http_method")}, + {Name: "secret", Short: "s", Usage: tr.T("flag.webhook.secret_update")}, + {Name: "branch-filter", Usage: tr.T("flag.webhook.branch_filter")}, + {Name: "active", Usage: tr.T("flag.webhook.active")}, }, Run: runUpdate, }, { Name: "delete", - Description: "Delete a repository webhook", + Description: tr.T("cmd.webhook.delete.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -113,9 +115,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "test", - Description: "Trigger a test delivery for a webhook", + Description: tr.T("cmd.webhook.test.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -134,9 +136,9 @@ func Shortcuts() []*common.Shortcut { }, { Name: "tasks", - Description: "List webhook delivery tasks", + Description: tr.T("cmd.webhook.tasks.short"), Flags: []common.Flag{ - {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "id", Short: "i", Usage: tr.T("flag.webhook.id"), Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -156,6 +158,13 @@ func Shortcuts() []*common.Shortcut { } } +func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { + if len(translators) > 0 && translators[0] != nil { + return translators[0] + } + return i18n.Default() +} + func runCreate(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err -- 2.34.1 From 9f0c63d2fbacbeb64778e0a027f456f2aeadd775 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Thu, 28 May 2026 11:00:05 +0800 Subject: [PATCH 4/7] feat(i18n): complete cli localization foundation --- cmd/auth/auth.go | 50 +++--- cmd/config/config.go | 22 +-- cmd/root.go | 36 ++-- cmd/root_test.go | 109 ++++++++++++ docs/i18n.md | 70 ++++++++ internal/i18n/cmd/check/main.go | 134 ++++++++++++++ internal/i18n/locale.go | 40 +++-- internal/i18n/locales/en-US.json | 292 +++++++++++++++++-------------- internal/i18n/locales/zh-CN.json | 292 +++++++++++++++++-------------- internal/i18n/resolver.go | 47 ++++- internal/i18n/translator.go | 4 +- internal/i18n/validate.go | 2 +- shortcuts/common/runner.go | 26 ++- shortcuts/common/types.go | 11 +- shortcuts/register.go | 2 +- 15 files changed, 777 insertions(+), 360 deletions(-) create mode 100644 docs/i18n.md diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 24ea8dc..13ca350 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -2,7 +2,9 @@ package auth import ( "bufio" + "errors" "fmt" + "io" "os" "strings" "syscall" @@ -38,54 +40,54 @@ func newLoginCmd(tr *i18n.Translator) *cobra.Command { Short: tr.T("cmd.auth.login.short"), RunE: func(cmd *cobra.Command, args []string) error { if tokenMode { - return loginWithToken() + return loginWithToken(cmd.InOrStdin(), cmd.OutOrStdout(), tr) } - return loginWithPassword() + return loginWithPassword(cmd.OutOrStdout(), tr) }, } cmd.Flags().BoolVar(&tokenMode, "token", false, tr.T("flag.auth.token")) return cmd } -func loginWithPassword() error { +func loginWithPassword(out io.Writer, tr *i18n.Translator) error { reader := bufio.NewReader(os.Stdin) - fmt.Print("Username/Email/Phone: ") + fmt.Fprint(out, tr.T("prompt.auth.username")) username, _ := reader.ReadString('\n') username = strings.TrimSpace(username) - fmt.Print("Password: ") + fmt.Fprint(out, tr.T("prompt.auth.password")) passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) if err != nil { return fmt.Errorf("failed to read password: %w", err) } - fmt.Println() + fmt.Fprintln(out) password := string(passwordBytes) result, err := internalAuth.Login(username, password) if err != nil { - return fmt.Errorf("login failed: %w", err) + return errors.New(tr.Tf("error.auth.login_failed", i18n.Args{"message": err.Error()})) } - fmt.Printf("✓ Logged in as %s\n", result.Login) + fmt.Fprintln(out, tr.Tf("success.auth.logged_in_as", i18n.Args{"login": result.Login})) return nil } -func loginWithToken() error { - reader := bufio.NewReader(os.Stdin) - fmt.Print("Paste your token: ") +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 = strings.TrimSpace(token) if token == "" { - return fmt.Errorf("token cannot be empty") + return errors.New(tr.T("error.auth.token_empty")) } if err := internalAuth.StoreToken(token); err != nil { - return fmt.Errorf("failed to store token: %w", err) + 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 } @@ -97,7 +99,7 @@ func newLogoutCmd(tr *i18n.Translator) *cobra.Command { if err := internalAuth.DeleteToken(); err != nil { return fmt.Errorf("failed to delete token: %w", err) } - fmt.Println("✓ Logged out") + fmt.Fprintln(cmd.OutOrStdout(), tr.T("success.auth.logged_out")) return nil }, } @@ -110,35 +112,35 @@ func newStatusCmd(tr *i18n.Translator) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { // Check env var token first 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() if err != nil || token == "" { if os.Getenv(envTokenVar) == "" { - fmt.Println("✗ Not logged in") - fmt.Println(" Run: gitlink-cli auth login") - fmt.Printf(" Or set %s environment variable\n", envTokenVar) + fmt.Fprintln(cmd.OutOrStdout(), tr.T("warning.auth.not_logged_in")) + fmt.Fprintln(cmd.OutOrStdout(), tr.T("output.auth.login_hint")) + fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("output.auth.env_hint", i18n.Args{"env": envTokenVar})) } return nil } user, err := internalAuth.GetCurrentUser() 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 } login, _ := user["login"].(string) name, _ := user["name"].(string) if login != "" { - fmt.Printf("✓ Logged in as %s", login) + text := tr.Tf("success.auth.logged_in_as", i18n.Args{"login": login}) if name != "" { - fmt.Printf(" (%s)", name) + text = fmt.Sprintf("%s (%s)", text, name) } - fmt.Println() + fmt.Fprintln(cmd.OutOrStdout(), text) } else { - fmt.Println("✓ Token stored (user info unavailable)") + fmt.Fprintln(cmd.OutOrStdout(), tr.T("warning.auth.user_unavailable")) } return nil }, diff --git a/cmd/config/config.go b/cmd/config/config.go index 414f786..c21abc8 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "github.com/spf13/cobra" @@ -31,9 +32,9 @@ func newInitCmd(tr *i18n.Translator) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { cfg := internalConfig.DefaultConfig() if err := internalConfig.Save(cfg); err != nil { - return fmt.Errorf("failed to save config: %w", err) + return errors.New(tr.Tf("error.config.save_failed", i18n.Args{"message": err.Error()})) } - fmt.Printf("✓ Config initialized at %s\n", internalConfig.ConfigPath()) + fmt.Fprintln(cmd.OutOrStdout(), tr.Tf("success.config.initialized", i18n.Args{"path": internalConfig.ConfigPath()})) return nil }, } @@ -65,9 +66,9 @@ func newGetCmd(tr *i18n.Translator) *cobra.Command { return err } 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 { - fmt.Printf("%s: %s\n", args[0], val) + fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", args[0], val) } return nil }, @@ -83,12 +84,13 @@ func newListCmd(tr *i18n.Translator) *cobra.Command { if err != nil { return err } - fmt.Printf("base_url: %s\n", cfg.BaseURL) - fmt.Printf("default_format: %s\n", cfg.Format) - fmt.Printf("editor: %s\n", cfg.Editor) - fmt.Printf("pager: %s\n", cfg.Pager) - fmt.Printf("lang: %s\n", cfg.Lang) - fmt.Printf("\nConfig file: %s\n", internalConfig.ConfigPath()) + fmt.Fprintf(cmd.OutOrStdout(), "base_url: %s\n", cfg.BaseURL) + fmt.Fprintf(cmd.OutOrStdout(), "default_format: %s\n", cfg.Format) + fmt.Fprintf(cmd.OutOrStdout(), "editor: %s\n", cfg.Editor) + fmt.Fprintf(cmd.OutOrStdout(), "pager: %s\n", cfg.Pager) + 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 }, } diff --git a/cmd/root.go b/cmd/root.go index f67b516..eaaa370 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" @@ -18,14 +19,16 @@ import ( var Version = "dev" type RootOptions struct { - Version string - Args []string + Version string + Args []string + Env map[string]string + ConfigLang string } func NewRootCmd(opts RootOptions, tr *i18n.Translator) (*cobra.Command, error) { if tr == nil { var err error - tr, err = i18n.New(i18n.Options{Locale: "en-US"}) + tr, err = newTranslator(opts.Args, opts.Env, opts.ConfigLang) if err != nil { return nil, err } @@ -75,15 +78,12 @@ func newVersionCmd(version string, tr *i18n.Translator) *cobra.Command { func Execute() error { args := os.Args[1:] - tr, err := newTranslator(args) - if err != nil { - return err - } rootCmd, err := NewRootCmd(RootOptions{ Version: Version, Args: args, - }, tr) + }, nil) if err != nil { + fmt.Fprintln(os.Stderr, err) return err } @@ -94,17 +94,27 @@ func Execute() error { return nil } -func newTranslator(args []string) (*i18n.Translator, error) { +func newTranslator(args []string, env map[string]string, configLang string) (*i18n.Translator, error) { available, err := i18n.AvailableLocales() if err != nil { return nil, err } - locale := i18n.ResolveLocale(i18n.ResolveOptions{ + if env == nil { + env = i18n.EnvMap() + } + if configLang == "" { + configLang = loadConfigLangBestEffort() + } + resolved := i18n.ResolveLocaleDetailed(i18n.ResolveOptions{ ExplicitLang: i18n.PreScanLang(args), - Env: i18n.EnvMap(), - ConfigLang: loadConfigLangBestEffort(), + Env: env, + ConfigLang: configLang, }, available) - return i18n.New(i18n.Options{Locale: locale}) + 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 { diff --git a/cmd/root_test.go b/cmd/root_test.go index 3457b74..0c71a76 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -40,6 +40,115 @@ func TestRootHelpUsesSelectedLocale(t *testing.T) { } } +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 { diff --git a/docs/i18n.md b/docs/i18n.md new file mode 100644 index 0000000..ee53fbc --- /dev/null +++ b/docs/i18n.md @@ -0,0 +1,70 @@ +# 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. + +## Runtime Access + +Command construction receives `*i18n.Translator` from `NewRootCmd`. Shortcut execution receives the same translator through `RuntimeContext.Tr`. + +New command code should receive a translator explicitly. `i18n.Default()` exists only as a legacy migration fallback and should not be used for new command paths. + +## Review Checklist + +- Locale JSON is sorted and formatted with two spaces. +- Every locale has the same keys as `en-US`. +- Template placeholders match across locales. +- New command/runtime text uses i18n only when it is human-facing. +- JSON output, API raw responses, debug logs, and machine-readable values are unchanged. diff --git a/internal/i18n/cmd/check/main.go b/internal/i18n/cmd/check/main.go index adb0702..f5de219 100644 --- a/internal/i18n/cmd/check/main.go +++ b/internal/i18n/cmd/check/main.go @@ -1,13 +1,24 @@ 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) @@ -19,5 +30,128 @@ func main() { } 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 +} diff --git a/internal/i18n/locale.go b/internal/i18n/locale.go index 7d85c61..0ec7131 100644 --- a/internal/i18n/locale.go +++ b/internal/i18n/locale.go @@ -35,48 +35,50 @@ func NormalizeLocale(locale string) string { return strings.Join(normalized, "-") } -// MatchLocale resolves requested to one of available using exact, normalized, -// language-primary, then fallback matching. +// 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 fallback + return localeMatch{Locale: fallback, Requested: NormalizeLocale(requested), Fallbacked: true} } byLocale := make(map[string]string, len(available)) - byPrimary := make(map[string]string, len(available)) for _, locale := range available { normalized := NormalizeLocale(locale) byLocale[normalized] = normalized - if primary := primaryLanguage(normalized); primary != "" { - if _, exists := byPrimary[primary]; !exists { - byPrimary[primary] = normalized - } - } } candidate := NormalizeLocale(requested) if candidate != "" { if matched, ok := byLocale[candidate]; ok { - return matched + return localeMatch{Locale: matched, Requested: candidate, Supported: true} } if alias := localeAlias(candidate); alias != "" { if matched, ok := byLocale[alias]; ok { - return matched + return localeMatch{Locale: matched, Requested: candidate, Supported: true} } } - if matched, ok := byPrimary[primaryLanguage(candidate)]; ok { - return matched - } } if matched, ok := byLocale[fallback]; ok { - return matched + return localeMatch{Locale: matched, Requested: candidate, Fallbacked: candidate != "", Supported: false} } - return NormalizeLocale(available[0]) + return localeMatch{Locale: NormalizeLocale(available[0]), Requested: candidate, Fallbacked: candidate != "", Supported: false} } func primaryLanguage(locale string) string { @@ -87,10 +89,10 @@ func primaryLanguage(locale string) string { } func localeAlias(locale string) string { - switch primaryLanguage(locale) { - case "zh": + switch { + case locale == "zh" || locale == "zh-CN" || locale == "zh-Hans" || locale == "zh-Hans-CN": return "zh-CN" - case "en": + case primaryLanguage(locale) == "en": return "en-US" default: return "" diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 63de828..c8ba55c 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -1,164 +1,184 @@ { - "cmd.root.short": "GitLink CLI - command-line tool for GitLink", - "cmd.root.long": "Manage repositories, issues, pull requests, releases, CI and workflows on GitLink.", - "cmd.api.short": "Make raw API requests to GitLink", "cmd.api.long": "Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.", - "cmd.auth.short": "Authentication commands", + "cmd.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.config.short": "Manage gitlink-cli configuration", - "cmd.config.init.short": "Initialize configuration file", - "cmd.config.set.short": "Set a configuration value", - "cmd.config.get.short": "Get a configuration value", - "cmd.config.list.short": "List all configuration values", - "cmd.version.short": "Print version information", - "cmd.repo.short": "Repository operations", - "cmd.repo.list.short": "List repositories for a user or organization", - "cmd.repo.info.short": "Show repository details", - "cmd.repo.create.short": "Create a new repository", - "cmd.repo.fork.short": "Fork a repository", - "cmd.repo.delete.short": "Delete a repository", - "cmd.issue.short": "Issue operations", - "cmd.issue.list.short": "List issues", - "cmd.issue.create.short": "Create a new issue", - "cmd.issue.view.short": "View issue details", - "cmd.issue.close.short": "Close an issue", - "cmd.issue.update.short": "Update an issue", - "cmd.issue.comment.short": "Add a comment to an issue", - "cmd.issue.batch_list.short": "List issue batch maintenance candidates without changing remote data", - "cmd.issue.batch_list.long": "List issue batch maintenance candidates without changing remote data.\n\nExamples:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json", - "cmd.issue.batch_close.short": "Close filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", - "cmd.issue.batch_close.long": "Close filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote close operations. Use restrictive filters and a small limit.\n\nExamples:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", - "cmd.issue.batch_label.short": "Add a label to filtered issues in bulk. Defaults to dry-run; pass --yes to execute.", - "cmd.issue.batch_label.long": "Add a label to filtered issues in bulk.\n\nThis command defaults to dry-run mode and only prints matching issues.\nPass --yes to execute remote label operations. The current implementation does not fake label writes when the API endpoint is unavailable.\n\nExamples:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", - "cmd.pr.short": "Pull request operations", - "cmd.pr.list.short": "List pull requests", - "cmd.pr.create.short": "Create a pull request", - "cmd.pr.view.short": "View pull request details", - "cmd.pr.merge.short": "Merge a pull request", - "cmd.pr.close.short": "Close a pull request", - "cmd.pr.files.short": "List changed files in a pull request", - "cmd.pr.diff.short": "Show diff for a pull request", - "cmd.pr.versions.short": "List pull request patchset versions", - "cmd.pr.version_diff.short": "Show diff for a pull request patchset version", - "cmd.pr.reviews.short": "List pull request reviews", - "cmd.pr.review.short": "Create a pull request review", - "cmd.pr.comment.short": "Add a comment to a pull request", - "cmd.release.short": "Release operations", - "cmd.release.list.short": "List releases", - "cmd.release.create.short": "Create a release", - "cmd.release.view.short": "View release details", - "cmd.release.delete.short": "Delete a release", - "cmd.branch.short": "Branch operations", - "cmd.branch.list.short": "List branches", "cmd.branch.create.short": "Create a branch", "cmd.branch.delete.short": "Delete a branch", + "cmd.branch.list.short": "List branches", "cmd.branch.protect.short": "Set branch protection", + "cmd.branch.short": "Branch operations", "cmd.branch.unprotect.short": "Remove branch protection", - "cmd.org.short": "Organization operations", - "cmd.org.list.short": "List organizations", - "cmd.org.info.short": "Show organization details", - "cmd.org.members.short": "List organization members", - "cmd.org.create.short": "Create an organization", - "cmd.user.short": "User operations", - "cmd.user.me.short": "Show current authenticated user", - "cmd.user.info.short": "Show user profile", - "cmd.search.short": "Search operations", - "cmd.search.repos.short": "Search repositories", - "cmd.search.users.short": "Search users", - "cmd.ci.short": "CI/CD operations", "cmd.ci.builds.short": "List CI builds", "cmd.ci.logs.short": "View build logs", "cmd.ci.restart.short": "Restart a build", + "cmd.ci.short": "CI/CD operations", "cmd.ci.stop.short": "Stop a build", - "cmd.webhook.short": "Webhook operations", - "cmd.webhook.list.short": "List repository webhooks", - "cmd.webhook.view.short": "View webhook details", + "cmd.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.update.short": "Update a repository webhook while preserving unspecified fields when available", "cmd.webhook.delete.short": "Delete a repository webhook", - "cmd.webhook.test.short": "Trigger a test delivery for a webhook", + "cmd.webhook.list.short": "List repository webhooks", + "cmd.webhook.short": "Webhook operations", "cmd.webhook.tasks.short": "List webhook delivery tasks", - "flag.owner": "Repository owner (auto-detected from git remote)", - "flag.repo": "Repository name (auto-detected from git remote)", - "flag.format": "Output format: json, table, yaml (default: table)", - "flag.debug": "Enable debug output", - "flag.lang": "Display language", + "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.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.query": "Query parameters (key=val&key2=val2)", "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.user": "User login (default: current user)", - "flag.user.login": "User login name", - "flag.page": "Page number", - "flag.limit": "Items per page", - "flag.description": "Description", - "flag.dry_run": "Preview the request without creating it", - "flag.comment.body": "Comment body", - "flag.repo.category": "Filter: manage/mirror/sync/fork/all (default: manage)", - "flag.repo.name": "Repository name", - "flag.repo.description": "Repository description", - "flag.repo.private": "Make repository private (true/false)", - "flag.issue.state": "Filter by state: open, closed, all", - "flag.issue.title": "Issue title", - "flag.issue.body": "Issue description", - "flag.issue.assignee": "Assignee login", - "flag.issue.milestone": "Milestone ID", - "flag.issue.label": "Label ID", - "flag.issue.number": "Issue number (as shown in the web URL)", - "flag.issue.new_title": "New title", - "flag.issue.new_body": "New description", - "flag.issue.new_state": "New state: open, closed, or numeric status_id", - "flag.issue.label_filter": "Filter by existing label", - "flag.issue.older_than_days": "Only include issues inactive for at least this many days", - "flag.issue.batch_list.limit": "Maximum issues to return, capped at 100", - "flag.issue.batch_close.state": "Filter by issue state before closing", - "flag.issue.batch_close.older_than_days": "Required safety filter; must be at least 7", - "flag.issue.batch_process.limit": "Maximum issues to process, capped at 100", - "flag.issue.batch.yes": "Execute remote operations. Without this flag the command is dry-run only.", - "flag.issue.batch.reason": "Optional reason shown in the batch result", - "flag.issue.batch_label.state": "Filter by issue state", - "flag.issue.add_label": "Label to add to each matching issue", - "flag.pr.state": "Filter: open, merged, closed", - "flag.pr.title": "PR title", - "flag.pr.body": "PR description", - "flag.pr.head": "Source branch", - "flag.pr.base": "Target branch", - "flag.pr.id": "PR number", - "flag.pr.merge_method": "Merge method: merge, rebase, squash", - "flag.pr.version_id": "Patchset version ID", - "flag.pr.file": "Filter diff by file path", - "flag.pr.review_status_filter": "Filter review status: common, approved, rejected", - "flag.pr.review_status": "Review status: common, approved, rejected", - "flag.pr.review_content": "Review content", - "flag.pr.review_commit": "Commit SHA to attach the review to", - "flag.branch.name": "Branch name", "flag.branch.from": "Source branch or commit", - "flag.release.tag": "Tag name", - "flag.release.name": "Release name", - "flag.release.body": "Release notes", - "flag.release.target": "Target branch", - "flag.release.prerelease": "Mark as prerelease (true/false)", - "flag.release.id_or_tag": "Release ID or tag", - "flag.release.id": "Release ID", - "flag.org.id_or_login": "Organization ID or login", - "flag.org.id": "Organization ID", - "flag.org.name": "Organization name", - "flag.search.keyword": "Search keyword", + "flag.branch.name": "Branch name", "flag.ci.build": "Build number", "flag.ci.stage": "Stage number", "flag.ci.step": "Step number", - "flag.webhook.id": "Webhook ID", - "flag.webhook.url": "Webhook target URL", - "flag.webhook.events": "Comma-separated events, for example: push,issues_only", - "flag.webhook.type": "Webhook type: gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", + "flag.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.branch_filter": "Branch glob filter for push/create/delete events", - "flag.webhook.active": "Whether the webhook is active: true or false", - "error.unsupported_language": "unsupported language: {lang}", - "output.version": "gitlink-cli {version}" + "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}", + "warning.auth.not_logged_in": "✗ Not logged in", + "warning.auth.token_unverified": "✓ Token stored (but cannot verify: {message})", + "warning.auth.user_unavailable": "✓ Token stored (user info unavailable)" } diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index b60f8f8..b70298c 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -1,164 +1,184 @@ { - "cmd.root.short": "GitLink CLI - GitLink 命令行工具", - "cmd.root.long": "用于管理 GitLink 上的仓库、议题、拉取请求、发布、CI 和工作流。", - "cmd.api.short": "向 GitLink 发起原始 API 请求", "cmd.api.long": "向 GitLink API 发送任意 HTTP 请求。认证信息会自动注入。", - "cmd.auth.short": "认证命令", + "cmd.api.short": "向 GitLink 发起原始 API 请求", "cmd.auth.login.short": "登录 GitLink", "cmd.auth.logout.short": "退出 GitLink 登录", + "cmd.auth.short": "认证命令", "cmd.auth.status.short": "显示认证状态", - "cmd.config.short": "管理 gitlink-cli 配置", - "cmd.config.init.short": "初始化配置文件", - "cmd.config.set.short": "设置配置项", - "cmd.config.get.short": "获取配置项", - "cmd.config.list.short": "列出所有配置项", - "cmd.version.short": "打印版本信息", - "cmd.repo.short": "仓库操作", - "cmd.repo.list.short": "列出用户或组织的仓库", - "cmd.repo.info.short": "显示仓库详情", - "cmd.repo.create.short": "创建新仓库", - "cmd.repo.fork.short": "Fork 仓库", - "cmd.repo.delete.short": "删除仓库", - "cmd.issue.short": "议题操作", - "cmd.issue.list.short": "列出议题", - "cmd.issue.create.short": "创建新议题", - "cmd.issue.view.short": "查看议题详情", - "cmd.issue.close.short": "关闭议题", - "cmd.issue.update.short": "更新议题", - "cmd.issue.comment.short": "给议题添加评论", - "cmd.issue.batch_list.short": "列出议题批量维护候选项,不修改远端数据", - "cmd.issue.batch_list.long": "列出议题批量维护候选项,不修改远端数据。\n\n示例:\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --state open --older-than-days 30 --limit 50 --format table\n gitlink-cli issue +batch-list --owner Gitlink --repo gitlink-cli --label bug --format json", - "cmd.issue.batch_close.short": "批量关闭筛选后的议题。默认 dry-run;传入 --yes 后执行。", - "cmd.issue.batch_close.long": "批量关闭筛选后的议题。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端关闭操作。请使用严格筛选条件和较小 limit。\n\n示例:\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20\n gitlink-cli issue +batch-close --owner Gitlink --repo gitlink-cli --older-than-days 60 --limit 20 --yes", - "cmd.issue.batch_label.short": "给筛选后的议题批量添加标签。默认 dry-run;传入 --yes 后执行。", - "cmd.issue.batch_label.long": "给筛选后的议题批量添加标签。\n\n该命令默认处于 dry-run 模式,只打印匹配的议题。\n传入 --yes 后执行远端标签操作。当前实现不会在 API 端点不可用时伪造写入结果。\n\n示例:\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50\n gitlink-cli issue +batch-label --owner Gitlink --repo gitlink-cli --add-label stale --older-than-days 30 --limit 50 --yes", - "cmd.pr.short": "拉取请求操作", - "cmd.pr.list.short": "列出拉取请求", - "cmd.pr.create.short": "创建拉取请求", - "cmd.pr.view.short": "查看拉取请求详情", - "cmd.pr.merge.short": "合并拉取请求", - "cmd.pr.close.short": "关闭拉取请求", - "cmd.pr.files.short": "列出拉取请求中的变更文件", - "cmd.pr.diff.short": "显示拉取请求 diff", - "cmd.pr.versions.short": "列出拉取请求补丁集版本", - "cmd.pr.version_diff.short": "显示拉取请求补丁集版本 diff", - "cmd.pr.reviews.short": "列出拉取请求评审", - "cmd.pr.review.short": "创建拉取请求评审", - "cmd.pr.comment.short": "给拉取请求添加评论", - "cmd.release.short": "发布操作", - "cmd.release.list.short": "列出发布", - "cmd.release.create.short": "创建发布", - "cmd.release.view.short": "查看发布详情", - "cmd.release.delete.short": "删除发布", - "cmd.branch.short": "分支操作", - "cmd.branch.list.short": "列出分支", "cmd.branch.create.short": "创建分支", "cmd.branch.delete.short": "删除分支", + "cmd.branch.list.short": "列出分支", "cmd.branch.protect.short": "设置分支保护", + "cmd.branch.short": "分支操作", "cmd.branch.unprotect.short": "移除分支保护", - "cmd.org.short": "组织操作", - "cmd.org.list.short": "列出组织", - "cmd.org.info.short": "显示组织详情", - "cmd.org.members.short": "列出组织成员", - "cmd.org.create.short": "创建组织", - "cmd.user.short": "用户操作", - "cmd.user.me.short": "显示当前认证用户", - "cmd.user.info.short": "显示用户资料", - "cmd.search.short": "搜索操作", - "cmd.search.repos.short": "搜索仓库", - "cmd.search.users.short": "搜索用户", - "cmd.ci.short": "CI/CD 操作", "cmd.ci.builds.short": "列出 CI 构建", "cmd.ci.logs.short": "查看构建日志", "cmd.ci.restart.short": "重启构建", + "cmd.ci.short": "CI/CD 操作", "cmd.ci.stop.short": "停止构建", - "cmd.webhook.short": "Webhook 操作", - "cmd.webhook.list.short": "列出仓库 Webhook", - "cmd.webhook.view.short": "查看 Webhook 详情", + "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.update.short": "更新仓库 Webhook,并在可用时保留未指定字段", "cmd.webhook.delete.short": "删除仓库 Webhook", - "cmd.webhook.test.short": "触发 Webhook 测试投递", + "cmd.webhook.list.short": "列出仓库 Webhook", + "cmd.webhook.short": "Webhook 操作", "cmd.webhook.tasks.short": "列出 Webhook 投递任务", - "flag.owner": "仓库所有者(自动从 git remote 检测)", - "flag.repo": "仓库名称(自动从 git remote 检测)", - "flag.format": "输出格式:json、table、yaml(默认:table)", - "flag.debug": "启用调试输出", - "flag.lang": "显示语言", + "cmd.webhook.test.short": "触发 Webhook 测试投递", + "cmd.webhook.update.short": "更新仓库 Webhook,并在可用时保留未指定字段", + "cmd.webhook.view.short": "查看 Webhook 详情", + "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.query": "查询参数(key=val&key2=val2)", "flag.api.header": "附加请求头(key:value)", + "flag.api.query": "查询参数(key=val&key2=val2)", "flag.auth.token": "通过粘贴已有 Token 登录", - "flag.user": "用户登录名(默认:当前用户)", - "flag.user.login": "用户登录名", - "flag.page": "页码", - "flag.limit": "每页条目数", - "flag.description": "描述", - "flag.dry_run": "预览请求,不实际创建", - "flag.comment.body": "评论内容", - "flag.repo.category": "筛选:manage/mirror/sync/fork/all(默认:manage)", - "flag.repo.name": "仓库名称", - "flag.repo.description": "仓库描述", - "flag.repo.private": "设为私有仓库(true/false)", - "flag.issue.state": "按状态筛选:open、closed、all", - "flag.issue.title": "议题标题", - "flag.issue.body": "议题描述", - "flag.issue.assignee": "负责人登录名", - "flag.issue.milestone": "里程碑 ID", - "flag.issue.label": "标签 ID", - "flag.issue.number": "议题编号(网页 URL 中显示的编号)", - "flag.issue.new_title": "新标题", - "flag.issue.new_body": "新描述", - "flag.issue.new_state": "新状态:open、closed 或数字 status_id", - "flag.issue.label_filter": "按已有标签筛选", - "flag.issue.older_than_days": "只包含至少这么多天未活动的议题", - "flag.issue.batch_list.limit": "最多返回的议题数,上限 100", - "flag.issue.batch_close.state": "关闭前按议题状态筛选", - "flag.issue.batch_close.older_than_days": "必需的安全筛选条件;至少为 7", - "flag.issue.batch_process.limit": "最多处理的议题数,上限 100", - "flag.issue.batch.yes": "执行远端操作。未传入该参数时仅 dry-run。", - "flag.issue.batch.reason": "批量结果中显示的可选原因", - "flag.issue.batch_label.state": "按议题状态筛选", - "flag.issue.add_label": "要添加到每个匹配议题的标签", - "flag.pr.state": "筛选:open、merged、closed", - "flag.pr.title": "PR 标题", - "flag.pr.body": "PR 描述", - "flag.pr.head": "源分支", - "flag.pr.base": "目标分支", - "flag.pr.id": "PR 编号", - "flag.pr.merge_method": "合并方式:merge、rebase、squash", - "flag.pr.version_id": "补丁集版本 ID", - "flag.pr.file": "按文件路径筛选 diff", - "flag.pr.review_status_filter": "按评审状态筛选:common、approved、rejected", - "flag.pr.review_status": "评审状态:common、approved、rejected", - "flag.pr.review_content": "评审内容", - "flag.pr.review_commit": "关联评审的 Commit SHA", - "flag.branch.name": "分支名称", "flag.branch.from": "源分支或 Commit", - "flag.release.tag": "标签名称", - "flag.release.name": "发布名称", - "flag.release.body": "发布说明", - "flag.release.target": "目标分支", - "flag.release.prerelease": "标记为预发布(true/false)", - "flag.release.id_or_tag": "发布 ID 或标签", - "flag.release.id": "发布 ID", - "flag.org.id_or_login": "组织 ID 或登录名", - "flag.org.id": "组织 ID", - "flag.org.name": "组织名称", - "flag.search.keyword": "搜索关键词", + "flag.branch.name": "分支名称", "flag.ci.build": "构建编号", "flag.ci.stage": "阶段编号", "flag.ci.step": "步骤编号", - "flag.webhook.id": "Webhook ID", - "flag.webhook.url": "Webhook 目标 URL", - "flag.webhook.events": "逗号分隔的事件,例如:push,issues_only", - "flag.webhook.type": "Webhook 类型:gitea/slack/discord/dingtalk/telegram/msteams/feishu/matrix/jianmu/softbot", + "flag.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.branch_filter": "用于 push/create/delete 事件的分支 glob 筛选", - "flag.webhook.active": "Webhook 是否启用:true 或 false", - "error.unsupported_language": "不支持的语言:{lang}", - "output.version": "gitlink-cli {version}" + "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}", + "warning.auth.not_logged_in": "✗ 未登录", + "warning.auth.token_unverified": "✓ Token 已保存(但无法验证:{message})", + "warning.auth.user_unavailable": "✓ Token 已保存(用户信息不可用)" } diff --git a/internal/i18n/resolver.go b/internal/i18n/resolver.go index f315815..5e2e694 100644 --- a/internal/i18n/resolver.go +++ b/internal/i18n/resolver.go @@ -12,22 +12,51 @@ type ResolveOptions struct { 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 { - candidates := []string{ - opts.ExplicitLang, - envValue(opts.Env, "GITLINK_LANG"), - opts.ConfigLang, - envValue(opts.Env, "LC_ALL"), - envValue(opts.Env, "LANG"), + 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) == "" { + if strings.TrimSpace(candidate.value) == "" { continue } - return MatchLocale(candidate, available, defaultFallbackLocale) + 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, } - return MatchLocale(defaultFallbackLocale, available, defaultFallbackLocale) } // PreScanLang reads --lang before Cobra constructs localized help text. diff --git a/internal/i18n/translator.go b/internal/i18n/translator.go index aa8e550..170f3bd 100644 --- a/internal/i18n/translator.go +++ b/internal/i18n/translator.go @@ -10,8 +10,8 @@ type Translator struct { fallback map[string]string } -// Default returns an English translator for call sites that have not been -// wired for dependency injection yet. +// 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 { diff --git a/internal/i18n/validate.go b/internal/i18n/validate.go index 1510cfb..99bb4e9 100644 --- a/internal/i18n/validate.go +++ b/internal/i18n/validate.go @@ -8,7 +8,7 @@ import ( "strings" ) -var keyPattern = regexp.MustCompile(`^(cmd|flag|error|output|prompt|table)\.[a-z0-9_.-]+$`) +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 { diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 3648d1f..9bdc5bc 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -3,11 +3,16 @@ package common import ( "strconv" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/spf13/cobra" ) // MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand. -func MountShortcut(parent *cobra.Command, s *Shortcut) { +func MountShortcut(parent *cobra.Command, s *Shortcut, translators ...*i18n.Translator) { + tr := i18n.Default() + if len(translators) > 0 && translators[0] != nil { + tr = translators[0] + } cmd := &cobra.Command{ Use: "+" + s.Name, Short: s.Description, @@ -27,10 +32,16 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) { } } - ctx, err := NewRuntimeContext(flagValues) + ctx, err := NewRuntimeContext(flagValues, tr) if err != nil { return err } + for _, f := range s.Flags { + if f.Required && flagValues[f.Name] == "" { + _, err := ctx.RequireArg(f.Name) + return err + } + } return s.Run(ctx) }, @@ -49,17 +60,18 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) { } else { cmd.Flags().String(f.Name, f.Default, f.Usage) } - if f.Required { - cmd.MarkFlagRequired(f.Name) - } } parent.AddCommand(cmd) } // MountShortcuts mounts multiple shortcuts under a parent command. -func MountShortcuts(parent *cobra.Command, shortcuts []*Shortcut) { +func MountShortcuts(parent *cobra.Command, shortcuts []*Shortcut, translators ...*i18n.Translator) { + var tr *i18n.Translator + if len(translators) > 0 { + tr = translators[0] + } for _, s := range shortcuts { - MountShortcut(parent, s) + MountShortcut(parent, s, tr) } } diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index ad01ce3..f7d5a45 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -2,12 +2,14 @@ package common import ( "encoding/json" + "errors" "fmt" "net/url" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/client" "github.com/gitlink-org/gitlink-cli/internal/context" + "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/internal/output" ) @@ -37,10 +39,14 @@ type RuntimeContext struct { Repo string Format string Args map[string]string + Tr *i18n.Translator } // NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo. -func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) { +func NewRuntimeContext(args map[string]string, tr *i18n.Translator) (*RuntimeContext, error) { + if tr == nil { + tr = i18n.Default() + } cli, err := client.New() if err != nil { return nil, err @@ -58,6 +64,7 @@ func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) { Repo: cmdutil.Repo, Format: format, Args: args, + Tr: tr, }, nil } @@ -114,7 +121,7 @@ func (ctx *RuntimeContext) Arg(name string) string { func (ctx *RuntimeContext) RequireArg(name string) (string, error) { v := ctx.Arg(name) if v == "" { - return "", fmt.Errorf("required flag --%s is missing", name) + return "", errors.New(ctx.Tr.Tf("error.missing_required_flag", i18n.Args{"name": name})) } return v, nil } diff --git a/shortcuts/register.go b/shortcuts/register.go index 26e42ed..5d64222 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -50,7 +50,7 @@ func RegisterAll(root *cobra.Command, tr *i18n.Translator) { Use: name, Short: descriptions[name], } - common.MountShortcuts(groupCmd, shortcuts) + common.MountShortcuts(groupCmd, shortcuts, tr) root.AddCommand(groupCmd) } } -- 2.34.1 From ceede4ea5977871016dfd2525a6539b3253df4af Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Thu, 28 May 2026 12:14:42 +0800 Subject: [PATCH 5/7] test(i18n): close auth config validation gaps --- .github/workflows/test.yml | 3 ++ cmd/auth/auth.go | 35 ++++++++++---- cmd/auth/auth_test.go | 83 ++++++++++++++++++++++++++++++++ cmd/config/config.go | 5 +- cmd/config/config_test.go | 32 ++++++++++++ docs/i18n.md | 7 +++ internal/i18n/locales/en-US.json | 2 + internal/i18n/locales/zh-CN.json | 2 + 8 files changed, 159 insertions(+), 10 deletions(-) create mode 100644 cmd/auth/auth_test.go create mode 100644 cmd/config/config_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5f86757..3d5673c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,5 +23,8 @@ jobs: - name: Validate i18n messages run: go run ./internal/i18n/cmd/check + - name: Scan i18n key references + run: go run ./internal/i18n/cmd/check --scan-code + - name: Run Go tests run: go test ./... diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 13ca350..bcbe27d 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -7,7 +7,6 @@ import ( "io" "os" "strings" - "syscall" "github.com/spf13/cobra" "golang.org/x/term" @@ -18,6 +17,11 @@ import ( const envTokenVar = "GITLINK_TOKEN" +var ( + storeToken = internalAuth.StoreToken + loadToken = internalAuth.LoadToken +) + func NewAuthCmd(tr *i18n.Translator) *cobra.Command { if tr == nil { tr = i18n.Default() @@ -42,22 +46,21 @@ func newLoginCmd(tr *i18n.Translator) *cobra.Command { if tokenMode { return loginWithToken(cmd.InOrStdin(), cmd.OutOrStdout(), tr) } - return loginWithPassword(cmd.OutOrStdout(), tr) + return loginWithPassword(cmd.InOrStdin(), cmd.OutOrStdout(), tr) }, } cmd.Flags().BoolVar(&tokenMode, "token", false, tr.T("flag.auth.token")) return cmd } -func loginWithPassword(out io.Writer, tr *i18n.Translator) error { - reader := bufio.NewReader(os.Stdin) - +func loginWithPassword(in io.Reader, out io.Writer, tr *i18n.Translator) error { + reader := bufio.NewReader(in) fmt.Fprint(out, tr.T("prompt.auth.username")) username, _ := reader.ReadString('\n') username = strings.TrimSpace(username) fmt.Fprint(out, tr.T("prompt.auth.password")) - passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) + passwordBytes, err := readPassword(in, reader) if err != nil { return fmt.Errorf("failed to read password: %w", err) } @@ -73,6 +76,20 @@ func loginWithPassword(out io.Writer, tr *i18n.Translator) error { return nil } +func readPassword(in io.Reader, reader *bufio.Reader) ([]byte, error) { + if file, ok := in.(*os.File); ok { + fd := int(file.Fd()) + if term.IsTerminal(fd) { + return term.ReadPassword(fd) + } + } + password, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return nil, err + } + return []byte(strings.TrimRight(password, "\r\n")), nil +} + func loginWithToken(in io.Reader, out io.Writer, tr *i18n.Translator) error { reader := bufio.NewReader(in) fmt.Fprint(out, tr.T("prompt.auth.token")) @@ -83,7 +100,7 @@ func loginWithToken(in io.Reader, out io.Writer, tr *i18n.Translator) error { return errors.New(tr.T("error.auth.token_empty")) } - if err := internalAuth.StoreToken(token); err != nil { + if err := storeToken(token); err != nil { return errors.New(tr.Tf("error.auth.store_token_failed", i18n.Args{"message": err.Error()})) } @@ -97,7 +114,7 @@ func newLogoutCmd(tr *i18n.Translator) *cobra.Command { Short: tr.T("cmd.auth.logout.short"), RunE: func(cmd *cobra.Command, args []string) error { if err := internalAuth.DeleteToken(); err != nil { - return fmt.Errorf("failed to delete token: %w", err) + return errors.New(tr.Tf("error.auth.delete_token_failed", i18n.Args{"message": err.Error()})) } fmt.Fprintln(cmd.OutOrStdout(), tr.T("success.auth.logged_out")) return nil @@ -115,7 +132,7 @@ func newStatusCmd(tr *i18n.Translator) *cobra.Command { 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 os.Getenv(envTokenVar) == "" { fmt.Fprintln(cmd.OutOrStdout(), tr.T("warning.auth.not_logged_in")) diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go new file mode 100644 index 0000000..681a748 --- /dev/null +++ b/cmd/auth/auth_test.go @@ -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) + } +} diff --git a/cmd/config/config.go b/cmd/config/config.go index c21abc8..93e1efa 100644 --- a/cmd/config/config.go +++ b/cmd/config/config.go @@ -49,7 +49,10 @@ func newSetCmd(tr *i18n.Translator) *cobra.Command { if err := internalConfig.Set(args[0], args[1]); err != nil { 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 }, } diff --git a/cmd/config/config_test.go b/cmd/config/config_test.go new file mode 100644 index 0000000..564e732 --- /dev/null +++ b/cmd/config/config_test.go @@ -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) + } +} diff --git a/docs/i18n.md b/docs/i18n.md index ee53fbc..23efe57 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -55,6 +55,13 @@ Do not invent numbered keys such as `msg001`. Prefer names that describe ownersh Use `go run ./internal/i18n/cmd/check --fix` to format locale JSON. +Use `go run ./internal/i18n/cmd/check --scan-code` before opening a PR. The scanner is intentionally lightweight: + +- Name command-construction translators `tr` when calling `tr.T(...)` or `tr.Tf(...)`. +- Use `ctx.Tr.T(...)` or `ctx.Tr.Tf(...)` in runtime shortcut code. +- Avoid calling translator methods through other variable names such as `translator.T(...)`; the current scan may not detect them. +- Do not add new `i18n.Default().T(...)` or `i18n.Default().Tf(...)` usages. + ## Runtime Access Command construction receives `*i18n.Translator` from `NewRootCmd`. Shortcut execution receives the same translator through `RuntimeContext.Tr`. diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index c8ba55c..30b6274 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -80,6 +80,7 @@ "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", @@ -178,6 +179,7 @@ "success.auth.logged_out": "✓ Logged out", "success.auth.token_saved": "✓ Token saved", "success.config.initialized": "✓ Config initialized at {path}", + "success.config.set": "✓ {key} = {value}", "warning.auth.not_logged_in": "✗ Not logged in", "warning.auth.token_unverified": "✓ Token stored (but cannot verify: {message})", "warning.auth.user_unavailable": "✓ Token stored (user info unavailable)" diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index b70298c..bd1a7ed 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -80,6 +80,7 @@ "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 不能为空", @@ -178,6 +179,7 @@ "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 已保存(用户信息不可用)" -- 2.34.1 From 351b87404510d623c18fb36a88002a5875a36761 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Thu, 28 May 2026 13:31:04 +0800 Subject: [PATCH 6/7] docs(i18n): clarify locale fallback wording --- docs/i18n-project-summary.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/i18n-project-summary.md b/docs/i18n-project-summary.md index 27d5b08..2c5e9e1 100644 --- a/docs/i18n-project-summary.md +++ b/docs/i18n-project-summary.md @@ -11,7 +11,7 @@ This work introduces a maintainable localization infrastructure for GitLink CLI. - Added `Translator` with `T` and `Tf` APIs. - Added fallback behavior from selected locale to `en-US`, then to the message key. - Added locale resolution from `--lang`, `GITLINK_LANG`, `config.lang`, `LC_ALL`, and `LANG`. -- Added locale normalization and matching for common inputs such as `zh_CN`, `zh-Hans-CN`, `zh`, and `en`. +- Added 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. -- 2.34.1 From 85011dc65946136ef35d943c64d220b54abf9437 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Thu, 28 May 2026 13:46:45 +0800 Subject: [PATCH 7/7] docs(i18n): remove process-specific review wording --- docs/i18n-project-summary.md | 8 +------- docs/i18n.md | 4 ++-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/docs/i18n-project-summary.md b/docs/i18n-project-summary.md index 2c5e9e1..a6a4d2c 100644 --- a/docs/i18n-project-summary.md +++ b/docs/i18n-project-summary.md @@ -55,14 +55,8 @@ The implementation has been validated with: ```powershell go run ./internal/i18n/cmd/check +go run ./internal/i18n/cmd/check --scan-code go test ./... -git diff --check -``` - -The local Go toolchain used for validation is: - -```text -E:\tools\go-sdk\go1.26.1\go\bin\go.exe ``` ## Follow-Up Work diff --git a/docs/i18n.md b/docs/i18n.md index 23efe57..c03d0a9 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -55,7 +55,7 @@ Do not invent numbered keys such as `msg001`. Prefer names that describe ownersh Use `go run ./internal/i18n/cmd/check --fix` to format locale JSON. -Use `go run ./internal/i18n/cmd/check --scan-code` before opening a PR. The scanner is intentionally lightweight: +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. @@ -68,7 +68,7 @@ Command construction receives `*i18n.Translator` from `NewRootCmd`. Shortcut exe New command code should receive a translator explicitly. `i18n.Default()` exists only as a legacy migration fallback and should not be used for new command paths. -## Review Checklist +## Maintenance Checks - Locale JSON is sorted and formatted with two spaces. - Every locale has the same keys as `en-US`. -- 2.34.1