diff --git a/cmd/root.go b/cmd/root.go index 0ed4c66..ee6167a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,6 +10,7 @@ import ( authCmd "github.com/gitlink-org/gitlink-cli/cmd/auth" apiCmd "github.com/gitlink-org/gitlink-cli/cmd/api" configCmd "github.com/gitlink-org/gitlink-cli/cmd/config" + clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" "github.com/gitlink-org/gitlink-cli/shortcuts" ) @@ -47,7 +48,10 @@ var versionCmd = &cobra.Command{ func Execute() error { if err := rootCmd.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) + fmt.Fprintf(os.Stderr, "错误: %s\n", err) + if s := clierrors.FindSuggestion(err); s != "" { + fmt.Fprintf(os.Stderr, "建议: %s\n", s) + } return err } return nil diff --git a/internal/client/client.go b/internal/client/client.go index c955361..c3bc54a 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -24,12 +24,17 @@ type APIError struct { StatusCode int Code interface{} Message string + Suggestion string } func (e *APIError) Error() string { return fmt.Sprintf("[%v] %s", e.Code, e.Message) } +func (e *APIError) Suggest() string { + return e.Suggestion +} + func New() (*Client, error) { cfg, err := config.Load() if err != nil { @@ -102,10 +107,12 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o // Check HTTP-level errors if resp.StatusCode >= 400 { + suggestion := suggestFix(resp.StatusCode) return nil, &APIError{ StatusCode: resp.StatusCode, Code: resp.StatusCode, Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))), + Suggestion: suggestion, } } @@ -132,6 +139,7 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o StatusCode: int(statusCode), Code: int(statusCode), Message: msg, + Suggestion: suggestion, } } } @@ -258,14 +266,20 @@ func (c *Client) DoRaw(method, path string, body interface{}, query url.Values) func suggestFix(code int) string { switch code { + case 400: + return "请求参数格式错误,请检查参数值是否正确" case 401: return "请先运行 gitlink-cli auth login 登录" case 403: return "权限不足,请确认账户权限或联系项目管理员" case 404: return "资源不存在,请检查 owner/repo/id 是否正确" + case 409: + return "资源冲突,可能存在同名资源" case 422: return "参数校验失败,请检查请求参数" + case 500, 502, 503: + return "服务器内部错误,请稍后重试或联系平台管理员" default: return "" } diff --git a/internal/context/repo.go b/internal/context/repo.go index f3a7085..861d38d 100644 --- a/internal/context/repo.go +++ b/internal/context/repo.go @@ -17,7 +17,7 @@ func ResolveOwnerRepo(flagOwner, flagRepo string) (string, string, error) { owner, repo, err := fromGitRemote() if err != nil { if flagOwner == "" || flagRepo == "" { - return "", "", fmt.Errorf("cannot detect owner/repo from git remote: %w\nUse --owner and --repo flags to specify explicitly", err) + return "", "", fmt.Errorf("无法从 git remote 自动检测 owner/repo: %w\n请使用 --owner 和 --repo 参数手动指定", err) } } @@ -34,7 +34,7 @@ func ResolveOwnerRepo(flagOwner, flagRepo string) (string, string, error) { func fromGitRemote() (string, string, error) { out, err := exec.Command("git", "remote", "get-url", "origin").Output() if err != nil { - return "", "", fmt.Errorf("not a git repository or no remote 'origin'") + return "", "", fmt.Errorf("当前目录不是 git 仓库或未配置 remote 'origin'") } remote := strings.TrimSpace(string(out)) return parseRemoteURL(remote) @@ -45,7 +45,7 @@ func parseRemoteURL(remote string) (string, string, error) { if strings.HasPrefix(remote, "git@") { parts := strings.SplitN(remote, ":", 2) if len(parts) != 2 { - return "", "", fmt.Errorf("cannot parse SSH remote: %s", remote) + return "", "", fmt.Errorf("无法解析 SSH 远程地址: %s", remote) } return parsePathSegments(parts[1]) } @@ -53,7 +53,7 @@ func parseRemoteURL(remote string) (string, string, error) { // HTTPS format: https://www.gitlink.org.cn/owner/repo.git u, err := url.Parse(remote) if err != nil { - return "", "", fmt.Errorf("cannot parse remote URL: %s", remote) + return "", "", fmt.Errorf("无法解析远程地址 URL: %s", remote) } return parsePathSegments(u.Path) } @@ -63,7 +63,7 @@ func parsePathSegments(path string) (string, string, error) { path = strings.TrimSuffix(path, ".git") parts := strings.SplitN(path, "/", 3) if len(parts) < 2 { - return "", "", fmt.Errorf("cannot extract owner/repo from path: %s", path) + return "", "", fmt.Errorf("无法从路径提取 owner/repo: %s", path) } return parts[0], parts[1], nil } diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..4c1a2ab --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,61 @@ +package errors + +import ( + "errors" + "fmt" +) + +// Suggester is implemented by errors that carry a user-facing suggestion. +type Suggester interface { + Suggest() string +} + +// Error is a CLI error with an optional suggestion for the user. +type Error struct { + Message string + Suggestion string + Err error +} + +func (e *Error) Error() string { + if e.Err != nil { + return fmt.Sprintf("%s: %v", e.Message, e.Err) + } + return e.Message +} + +func (e *Error) Unwrap() error { + return e.Err +} + +func (e *Error) Suggest() string { + return e.Suggestion +} + +// New creates a new Error with an optional suggestion. +func New(message, suggestion string) *Error { + return &Error{Message: message, Suggestion: suggestion} +} + +// Wrapf wraps an error with a formatted message and optional suggestion. +func Wrapf(err error, suggestion, format string, args ...interface{}) *Error { + return &Error{ + Message: fmt.Sprintf(format, args...), + Suggestion: suggestion, + Err: err, + } +} + +// Wrap wraps an error with a message and optional suggestion. +func Wrap(err error, message, suggestion string) *Error { + return &Error{Message: message, Suggestion: suggestion, Err: err} +} + +// FindSuggestion walks the error chain and returns the first suggestion found. +func FindSuggestion(err error) string { + var s Suggester + if errors.As(err, &s) { + return s.Suggest() + } + return "" +} diff --git a/shortcuts/branch/branch.go b/shortcuts/branch/branch.go index 37684b6..76e5728 100644 --- a/shortcuts/branch/branch.go +++ b/shortcuts/branch/branch.go @@ -18,14 +18,14 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q) if err != nil { - return err + return fmt.Errorf("获取分支列表失败: %w", err) } return ctx.Output(env) }, @@ -39,9 +39,12 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { + return fmt.Errorf("解析仓库信息失败: %w", err) + } + name, err := ctx.RequireArg("name") + if err != nil { return err } - name, _ := ctx.RequireArg("name") from := ctx.Arg("from") if from == "" { from = "master" @@ -52,7 +55,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches", payload) if err != nil { - return err + return fmt.Errorf("创建分支失败: %w", err) } return ctx.Output(env) }, @@ -65,15 +68,18 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { + return fmt.Errorf("解析仓库信息失败: %w", err) + } + name, err := ctx.RequireArg("name") + if err != nil { return err } - name, _ := ctx.RequireArg("name") payload := map[string]interface{}{ "branch_name": name, } env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload) if err != nil { - return err + return fmt.Errorf("删除分支失败: %w", err) } return ctx.Output(env) }, @@ -86,15 +92,18 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { + return fmt.Errorf("解析仓库信息失败: %w", err) + } + name, err := ctx.RequireArg("name") + if err != nil { return err } - name, _ := ctx.RequireArg("name") payload := map[string]interface{}{ "branch_name": name, } env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload) if err != nil { - return err + return fmt.Errorf("设置分支保护失败: %w", err) } return ctx.Output(env) }, @@ -107,13 +116,16 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } - name, _ := ctx.RequireArg("name") - env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil) + name, err := ctx.RequireArg("name") if err != nil { return err } + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil) + if err != nil { + return fmt.Errorf("取消分支保护失败: %w", err) + } return ctx.Output(env) }, }, diff --git a/shortcuts/ci/ci.go b/shortcuts/ci/ci.go index eb15ad3..dfc0899 100644 --- a/shortcuts/ci/ci.go +++ b/shortcuts/ci/ci.go @@ -18,14 +18,14 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q) if err != nil { - return err + return fmt.Errorf("获取构建列表失败: %w", err) } return ctx.Output(env) }, @@ -40,9 +40,12 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { + return fmt.Errorf("解析仓库信息失败: %w", err) + } + build, err := ctx.RequireArg("build") + if err != nil { return err } - build, _ := ctx.RequireArg("build") stage := ctx.Arg("stage") step := ctx.Arg("step") if stage == "" { @@ -53,7 +56,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/builds/%s/logs/%s/%s", ctx.RepoPath(), build, stage, step), nil) if err != nil { - return err + return fmt.Errorf("获取构建日志失败: %w", err) } return ctx.Output(env) }, @@ -66,13 +69,16 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } - build, _ := ctx.RequireArg("build") - env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil) + build, err := ctx.RequireArg("build") if err != nil { return err } + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil) + if err != nil { + return fmt.Errorf("重启构建失败: %w", err) + } return ctx.Output(env) }, }, @@ -84,13 +90,16 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } - build, _ := ctx.RequireArg("build") - env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil) + build, err := ctx.RequireArg("build") if err != nil { return err } + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil) + if err != nil { + return fmt.Errorf("停止构建失败: %w", err) + } return ctx.Output(env) }, }, diff --git a/shortcuts/commit/commit.go b/shortcuts/commit/commit.go index 10ee7b2..7d4fcd0 100644 --- a/shortcuts/commit/commit.go +++ b/shortcuts/commit/commit.go @@ -19,7 +19,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } q := url.Values{} q.Set("page", ctx.Arg("page")) @@ -29,7 +29,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits", ctx.Owner, ctx.Repo), q) if err != nil { - return err + return fmt.Errorf("获取提交列表失败: %w", err) } return ctx.Output(env) }, @@ -44,7 +44,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } sha, err := ctx.RequireArg("sha") if err != nil { @@ -55,7 +55,7 @@ func Shortcuts() []*common.Shortcut { q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/files", ctx.Owner, ctx.Repo, sha), q) if err != nil { - return err + return fmt.Errorf("查看提交详情失败: %w", err) } return ctx.Output(env) }, @@ -68,7 +68,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } sha, err := ctx.RequireArg("sha") if err != nil { @@ -76,7 +76,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("GET", fmt.Sprintf("/v1/%s/%s/commits/%s/diff", ctx.Owner, ctx.Repo, sha), nil) if err != nil { - return err + return fmt.Errorf("获取提交 Diff 失败: %w", err) } return ctx.Output(env) }, @@ -90,7 +90,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } filePath, err := ctx.RequireArg("path") if err != nil { @@ -101,7 +101,7 @@ func Shortcuts() []*common.Shortcut { q.Set("sha", ctx.Arg("sha")) env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/blame", ctx.Owner, ctx.Repo), q) if err != nil { - return err + return fmt.Errorf("获取 Blame 信息失败: %w", err) } return ctx.Output(env) }, diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index 154afef..3ea0dbe 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -45,7 +45,7 @@ func newBatchCloseShortcut() *common.Shortcut { func runBatchClose(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } start := time.Now() @@ -55,7 +55,7 @@ func runBatchClose(ctx *common.RuntimeContext) error { return err } if len(numbers) == 0 { - return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") + return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv") } dryRun := parseBool(ctx.Arg("dry-run")) @@ -92,7 +92,7 @@ func runBatchClose(ctx *common.RuntimeContext) error { return err } if summary.Failed > 0 { - return fmt.Errorf("%d of %d issue(s) failed to close", summary.Failed, summary.Total) + return fmt.Errorf("%d / %d 个 Issue 关闭失败", summary.Failed, summary.Total) } return nil } @@ -100,7 +100,7 @@ func runBatchClose(ctx *common.RuntimeContext) error { func closeIssue(ctx *common.RuntimeContext, number string) error { current, err := fetchExistingIssue(ctx, number) if err != nil { - return fmt.Errorf("fetch issue: %w", err) + return fmt.Errorf("获取 Issue 详情: %w", err) } body := map[string]interface{}{ @@ -109,7 +109,7 @@ func closeIssue(ctx *common.RuntimeContext, number string) error { "status_id": closedIssueStatusID, } if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil { - return fmt.Errorf("close issue: %w", err) + return fmt.Errorf("关闭 Issue: %w", err) } return nil } @@ -130,7 +130,7 @@ func newBatchAssignShortcut() *common.Shortcut { func runBatchAssign(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } user, err := ctx.RequireArg("user") @@ -143,7 +143,7 @@ func runBatchAssign(ctx *common.RuntimeContext) error { return err } if len(numbers) == 0 { - return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") + return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv") } dryRun := parseBool(ctx.Arg("dry-run")) @@ -178,7 +178,7 @@ func runBatchAssign(ctx *common.RuntimeContext) error { return err } if summary.Failed > 0 { - return fmt.Errorf("%d of %d issue(s) failed to assign", summary.Failed, summary.Total) + return fmt.Errorf("%d / %d 个 Issue 分配失败", summary.Failed, summary.Total) } return nil } @@ -188,7 +188,7 @@ func assignIssue(ctx *common.RuntimeContext, number, user string) error { "assigned_to_id": user, } if _, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/issues/%s/assignees", v1RepoPath(ctx), number), body); err != nil { - return fmt.Errorf("assign issue: %w", err) + return fmt.Errorf("分配 Issue: %w", err) } return nil } @@ -210,13 +210,13 @@ func newBatchLabelShortcut() *common.Shortcut { func runBatchLabel(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } addIDs := ctx.Arg("add") removeIDs := ctx.Arg("remove") if addIDs == "" && removeIDs == "" { - return fmt.Errorf("at least one of --add or --remove is required") + return fmt.Errorf("至少需要指定 --add 或 --remove 中的一个") } numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from")) @@ -224,7 +224,7 @@ func runBatchLabel(ctx *common.RuntimeContext) error { return err } if len(numbers) == 0 { - return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") + return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv") } dryRun := parseBool(ctx.Arg("dry-run")) @@ -262,7 +262,7 @@ func runBatchLabel(ctx *common.RuntimeContext) error { return err } if summary.Failed > 0 { - return fmt.Errorf("%d of %d issue(s) failed to label", summary.Failed, summary.Total) + return fmt.Errorf("%d / %d 个 Issue 标签操作失败", summary.Failed, summary.Total) } return nil } @@ -274,12 +274,12 @@ func labelIssue(ctx *common.RuntimeContext, number string, addLabels, removeLabe "tag_id": labelID, } if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/tags", v1RepoPath(ctx), number), body); err != nil { - errs = append(errs, fmt.Sprintf("add label %s: %v", labelID, err)) + errs = append(errs, fmt.Sprintf("添加标签 %s: %v", labelID, err)) } } for _, labelID := range removeLabels { if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/tags/%s", v1RepoPath(ctx), number, labelID), nil); err != nil { - errs = append(errs, fmt.Sprintf("remove label %s: %v", labelID, err)) + errs = append(errs, fmt.Sprintf("移除标签 %s: %v", labelID, err)) } } if len(errs) > 0 { @@ -304,7 +304,7 @@ func newBatchMilestoneShortcut() *common.Shortcut { func runBatchMilestone(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } milestoneID, err := ctx.RequireArg("milestone") @@ -317,7 +317,7 @@ func runBatchMilestone(ctx *common.RuntimeContext) error { return err } if len(numbers) == 0 { - return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") + return fmt.Errorf("未提供 Issue 编号,请使用 --numbers 1,2,3 或 --from issues.csv") } dryRun := parseBool(ctx.Arg("dry-run")) @@ -352,7 +352,7 @@ func runBatchMilestone(ctx *common.RuntimeContext) error { return err } if summary.Failed > 0 { - return fmt.Errorf("%d of %d issue(s) failed to set milestone", summary.Failed, summary.Total) + return fmt.Errorf("%d / %d 个 Issue 设置里程碑失败", summary.Failed, summary.Total) } return nil } @@ -360,16 +360,16 @@ func runBatchMilestone(ctx *common.RuntimeContext) error { func setMilestone(ctx *common.RuntimeContext, number, milestoneID string) error { current, err := fetchExistingIssue(ctx, number) if err != nil { - return fmt.Errorf("fetch issue: %w", err) + return fmt.Errorf("获取 Issue 详情: %w", err) } body := map[string]interface{}{ - "subject": current.Subject, - "description": current.Description, + "subject": current.Subject, + "description": current.Description, "fixed_version_id": milestoneID, } if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil { - return fmt.Errorf("set milestone: %w", err) + return fmt.Errorf("设置里程碑: %w", err) } return nil } @@ -413,7 +413,7 @@ func parseIssueNumbers(value string) ([]string, error) { func readIssueNumbersFromCSV(path string) ([]string, error) { file, err := os.Open(path) if err != nil { - return nil, fmt.Errorf("read issue numbers from CSV: %w", err) + return nil, fmt.Errorf("读取 CSV 文件失败: %w", err) } defer file.Close() @@ -421,7 +421,7 @@ func readIssueNumbersFromCSV(path string) ([]string, error) { reader.TrimLeadingSpace = true records, err := reader.ReadAll() if err != nil { - return nil, fmt.Errorf("parse issue numbers from CSV: %w", err) + return nil, fmt.Errorf("解析 CSV 文件失败: %w", err) } if len(records) == 0 { return nil, nil @@ -459,7 +459,7 @@ func normalizeIssueNumbers(values []string) ([]string, error) { continue } if _, err := strconv.ParseInt(number, 10, 64); err != nil { - return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number) + return nil, fmt.Errorf("无效的 Issue 编号 %q: Issue 编号必须是整数", number) } if seen[number] { continue diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index f640e2d..e8deb70 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -36,7 +36,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } q := url.Values{} q.Set("page", ctx.Arg("page")) @@ -46,7 +46,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q) if err != nil { - return err + return fmt.Errorf("获取 Issue 列表失败: %w", err) } return ctx.Output(env) }, @@ -63,7 +63,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } title, err := ctx.RequireArg("title") if err != nil { @@ -86,7 +86,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body) if err != nil { - return err + return fmt.Errorf("创建 Issue 失败: %w", err) } return ctx.Output(env) }, @@ -99,7 +99,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } number, err := ctx.RequireArg("number") if err != nil { @@ -107,7 +107,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil) if err != nil { - return err + return fmt.Errorf("查看 Issue 详情失败: %w", err) } return ctx.Output(env) }, @@ -120,7 +120,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } number, err := ctx.RequireArg("number") if err != nil { @@ -128,7 +128,7 @@ func Shortcuts() []*common.Shortcut { } current, err := fetchExistingIssue(ctx, number) if err != nil { - return err + return fmt.Errorf("关闭 Issue 失败: %w", err) } body := map[string]interface{}{ @@ -138,7 +138,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) if err != nil { - return err + return fmt.Errorf("关闭 Issue 失败: %w", err) } return ctx.Output(env) }, @@ -154,7 +154,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } number, err := ctx.RequireArg("number") if err != nil { @@ -164,12 +164,12 @@ func Shortcuts() []*common.Shortcut { description := ctx.Arg("body") state := ctx.Arg("state") if title == "" && description == "" && state == "" { - return fmt.Errorf("at least one of --title, --body, or --state is required") + return fmt.Errorf("至少需要指定 --title、--body 或 --state 中的一个") } current, err := fetchExistingIssue(ctx, number) if err != nil { - return err + return fmt.Errorf("更新 Issue 失败: %w", err) } body := map[string]interface{}{ @@ -191,7 +191,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) if err != nil { - return err + return fmt.Errorf("更新 Issue 失败: %w", err) } return ctx.Output(env) }, @@ -205,16 +205,22 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { + return fmt.Errorf("解析仓库信息失败: %w", err) + } + number, err := ctx.RequireArg("number") + if err != nil { + return err + } + user, err := ctx.RequireArg("user") + if err != nil { return err } - number, _ := ctx.RequireArg("number") - user, _ := ctx.RequireArg("user") body := map[string]interface{}{ "assigned_to_id": user, } env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/issues/%s/assignees", v1RepoPath(ctx), number), body) if err != nil { - return err + return fmt.Errorf("分配 Issue 失败: %w", err) } return ctx.Output(env) }, @@ -229,13 +235,16 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { + return fmt.Errorf("解析仓库信息失败: %w", err) + } + number, err := ctx.RequireArg("number") + if err != nil { return err } - number, _ := ctx.RequireArg("number") addIDs := ctx.Arg("add") removeIDs := ctx.Arg("remove") if addIDs == "" && removeIDs == "" { - return fmt.Errorf("at least one of --add or --remove is required") + return fmt.Errorf("至少需要指定 --add 或 --remove 中的一个") } var errs []string @@ -249,7 +258,7 @@ func Shortcuts() []*common.Shortcut { "tag_id": labelID, } if _, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/tags", v1RepoPath(ctx), number), body); err != nil { - errs = append(errs, fmt.Sprintf("add label %s: %v", labelID, err)) + errs = append(errs, fmt.Sprintf("添加标签 %s: %v", labelID, err)) } } } @@ -260,12 +269,12 @@ func Shortcuts() []*common.Shortcut { continue } if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/tags/%s", v1RepoPath(ctx), number, labelID), nil); err != nil { - errs = append(errs, fmt.Sprintf("remove label %s: %v", labelID, err)) + errs = append(errs, fmt.Sprintf("移除标签 %s: %v", labelID, err)) } } } if len(errs) > 0 { - return fmt.Errorf("label operations failed:\n%s", strings.Join(errs, "\n")) + return fmt.Errorf("标签操作失败:\n%s", strings.Join(errs, "\n")) } return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ "message": "标签操作成功", @@ -281,7 +290,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } number, err := ctx.RequireArg("number") if err != nil { @@ -296,7 +305,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload) if err != nil { - return err + return fmt.Errorf("添加评论失败: %w", err) } return ctx.Output(env) }, @@ -307,15 +316,15 @@ func Shortcuts() []*common.Shortcut { func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) { getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil) if err != nil { - return nil, err + return nil, fmt.Errorf("获取 Issue 详情: %w", err) } issueData, ok := getEnv.Data.(map[string]interface{}) if !ok { - return nil, fmt.Errorf("failed to parse issue data") + return nil, fmt.Errorf("解析 Issue 数据失败") } subject, _ := issueData["subject"].(string) if subject == "" { - return nil, fmt.Errorf("failed to parse issue subject") + return nil, fmt.Errorf("解析 Issue 标题失败") } description, _ := issueData["description"].(string) return &existingIssue{ @@ -334,6 +343,6 @@ func normalizeIssueStatus(state string) (interface{}, error) { if id, err := strconv.Atoi(state); err == nil { return id, nil } - return nil, fmt.Errorf("invalid --state %q: use open, closed, or a numeric status_id", state) + return nil, fmt.Errorf("无效的 --state 值 %q: 请使用 open、closed 或数字 status_id", state) } } diff --git a/shortcuts/issue/issue_test.go b/shortcuts/issue/issue_test.go index 30a9814..7649119 100755 --- a/shortcuts/issue/issue_test.go +++ b/shortcuts/issue/issue_test.go @@ -320,8 +320,8 @@ func TestIssueAssignRequiresNumberAndUser(t *testing.T) { defer server.Close() err := runIssueShortcut(t, server, "assign", map[string]string{"number": ""}) - if err != nil { - t.Fatalf("assign shortcut should not error (follows existing pattern): %v", err) + if err == nil { + t.Fatal("assign shortcut should error when required flags are missing") } } diff --git a/shortcuts/label/label.go b/shortcuts/label/label.go index 5954719..a26ddd8 100644 --- a/shortcuts/label/label.go +++ b/shortcuts/label/label.go @@ -23,7 +23,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } q := url.Values{} q.Set("page", ctx.Arg("page")) @@ -33,7 +33,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issue_tags", q) if err != nil { - return err + return fmt.Errorf("获取标签列表失败: %w", err) } return ctx.Output(env) }, @@ -48,7 +48,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } name, err := ctx.RequireArg("name") if err != nil { @@ -63,7 +63,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issue_tags", body) if err != nil { - return err + return fmt.Errorf("创建标签失败: %w", err) } return ctx.Output(env) }, @@ -79,7 +79,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } id, err := ctx.RequireArg("id") if err != nil { @@ -89,7 +89,7 @@ func Shortcuts() []*common.Shortcut { color := ctx.Arg("color") desc := ctx.Arg("description") if name == "" && color == "" && desc == "" { - return fmt.Errorf("at least one of --name, --color, or --description is required") + return fmt.Errorf("至少需要指定 --name、--color 或 --description 中的一个") } body := map[string]interface{}{} if name != "" { @@ -103,7 +103,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issue_tags/%s", v1RepoPath(ctx), id), body) if err != nil { - return err + return fmt.Errorf("更新标签失败: %w", err) } return ctx.Output(env) }, @@ -116,7 +116,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } id, err := ctx.RequireArg("id") if err != nil { @@ -124,7 +124,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issue_tags/%s", v1RepoPath(ctx), id), nil) if err != nil { - return err + return fmt.Errorf("删除标签失败: %w", err) } return ctx.Output(env) }, diff --git a/shortcuts/milestone/milestone.go b/shortcuts/milestone/milestone.go index 3e2845f..9d1c730 100644 --- a/shortcuts/milestone/milestone.go +++ b/shortcuts/milestone/milestone.go @@ -26,7 +26,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } q := url.Values{} q.Set("page", ctx.Arg("page")) @@ -45,7 +45,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/milestones", q) if err != nil { - return err + return fmt.Errorf("获取里程碑列表失败: %w", err) } return ctx.Output(env) }, @@ -58,7 +58,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } id, err := ctx.RequireArg("id") if err != nil { @@ -66,7 +66,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), nil) if err != nil { - return err + return fmt.Errorf("查看里程碑详情失败: %w", err) } return ctx.Output(env) }, @@ -81,7 +81,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } name, err := ctx.RequireArg("name") if err != nil { @@ -98,7 +98,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/milestones", body) if err != nil { - return err + return fmt.Errorf("创建里程碑失败: %w", err) } return ctx.Output(env) }, @@ -114,7 +114,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } id, err := ctx.RequireArg("id") if err != nil { @@ -124,7 +124,7 @@ func Shortcuts() []*common.Shortcut { desc := ctx.Arg("description") due := ctx.Arg("due") if name == "" && desc == "" && due == "" { - return fmt.Errorf("at least one of --name, --description, or --due is required") + return fmt.Errorf("至少需要指定 --name、--description 或 --due 中的一个") } body := map[string]interface{}{} if name != "" { @@ -138,7 +138,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), body) if err != nil { - return err + return fmt.Errorf("更新里程碑失败: %w", err) } return ctx.Output(env) }, @@ -151,7 +151,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } id, err := ctx.RequireArg("id") if err != nil { @@ -162,7 +162,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/milestones/%s/update_status", ctx.Owner, ctx.Repo, id), body) if err != nil { - return err + return fmt.Errorf("关闭里程碑失败: %w", err) } return ctx.Output(env) }, @@ -175,7 +175,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } id, err := ctx.RequireArg("id") if err != nil { @@ -183,7 +183,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/milestones/%s", v1RepoPath(ctx), id), nil) if err != nil { - return err + return fmt.Errorf("删除里程碑失败: %w", err) } return ctx.Output(env) }, diff --git a/shortcuts/org/batch.go b/shortcuts/org/batch.go new file mode 100644 index 0000000..ce86fef --- /dev/null +++ b/shortcuts/org/batch.go @@ -0,0 +1,214 @@ +package org + +import ( + "encoding/csv" + "fmt" + "os" + "strings" + "time" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type batchInviteResult struct { + User string `json:"user" yaml:"user"` + Action string `json:"action" yaml:"action"` + Status string `json:"status" yaml:"status"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` +} + +type batchInviteSummary struct { + Org string `json:"org" yaml:"org"` + 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"` + Duration string `json:"duration" yaml:"duration"` + Results []batchInviteResult `json:"results" yaml:"results"` +} + +func newBatchInviteShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-invite", + Description: "批量邀请成员加入组织,支持逗号分隔列表或 CSV 文件", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "组织 ID 或 login", Required: true}, + {Name: "users", Short: "u", Usage: "逗号分隔的用户名或 ID,例如: alice,bob,charlie"}, + {Name: "from", Usage: "从 CSV 文件读取用户名。支持 user/login/user_id 列名或无表头首列"}, + {Name: "role", Short: "r", Usage: "成员角色: member 或 admin", Default: "member"}, + {Name: "dry-run", Usage: "仅预览将要邀请的成员,不实际执行", Bool: true, Default: "false"}, + }, + Run: runBatchInvite, + } +} + +func runBatchInvite(ctx *common.RuntimeContext) error { + start := time.Now() + + orgID, err := ctx.RequireArg("id") + if err != nil { + return err + } + + role := ctx.Arg("role") + if role == "" { + role = "member" + } + if role != "member" && role != "admin" { + return fmt.Errorf("无效的角色 %q: 必须为 member 或 admin", role) + } + + users, err := collectUsers(ctx.Arg("users"), ctx.Arg("from")) + if err != nil { + return err + } + if len(users) == 0 { + return fmt.Errorf("未提供用户名,请使用 --users alice,bob 或 --from users.csv") + } + + dryRun := parseBool(ctx.Arg("dry-run")) + + summary := batchInviteSummary{ + Org: orgID, + DryRun: dryRun, + Total: len(users), + Results: make([]batchInviteResult, 0, len(users)), + } + + for _, user := range users { + result := batchInviteResult{User: user, Action: "invite"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + body := map[string]interface{}{ + "user_id": user, + "role": role, + } + path := fmt.Sprintf("/organizations/%s/organization_users", orgID) + if _, err := ctx.CallAPI("POST", path, body); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "invited" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + summary.Duration = time.Since(start).String() + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d / %d 个成员邀请失败", summary.Failed, summary.Total) + } + return nil +} + +func collectUsers(usersValue, csvPath string) ([]string, error) { + users, err := parseUserList(usersValue) + if err != nil { + return nil, err + } + if csvPath == "" { + return users, nil + } + + csvUsers, err := readUsersFromCSV(csvPath) + if err != nil { + return nil, err + } + return mergeUserLists(users, csvUsers), nil +} + +func parseUserList(value string) ([]string, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + return normalizeUserIDs(strings.Split(value, ",")) +} + +func readUsersFromCSV(path string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("读取 CSV 文件失败: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + reader.TrimLeadingSpace = true + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("解析 CSV 文件失败: %w", err) + } + if len(records) == 0 { + return nil, nil + } + + userCol := -1 + startRow := 0 + for i, cell := range records[0] { + switch strings.ToLower(strings.TrimSpace(cell)) { + case "user", "login", "user_id", "username": + userCol = i + startRow = 1 + } + } + if userCol == -1 { + userCol = 0 + } + + values := make([]string, 0, len(records)-startRow) + for _, record := range records[startRow:] { + if userCol >= len(record) { + continue + } + values = append(values, record[userCol]) + } + return normalizeUserIDs(values) +} + +func normalizeUserIDs(values []string) ([]string, error) { + users := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + user := strings.TrimSpace(value) + if user == "" { + continue + } + if seen[user] { + continue + } + seen[user] = true + users = append(users, user) + } + return users, nil +} + +func parseBool(value string) bool { + if strings.EqualFold(strings.TrimSpace(value), "true") { + return true + } + return false +} + +func mergeUserLists(values ...[]string) []string { + merged := []string{} + seen := map[string]bool{} + for _, users := range values { + for _, u := range users { + if seen[u] { + continue + } + seen[u] = true + merged = append(merged, u) + } + } + return merged +} diff --git a/shortcuts/org/org.go b/shortcuts/org/org.go index 4cd6299..8be93fc 100644 --- a/shortcuts/org/org.go +++ b/shortcuts/org/org.go @@ -9,6 +9,7 @@ import ( func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ + newBatchInviteShortcut(), { Name: "list", Description: "List organizations", @@ -22,7 +23,7 @@ func Shortcuts() []*common.Shortcut { q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", "/organizations", q) if err != nil { - return err + return fmt.Errorf("获取组织列表失败: %w", err) } return ctx.Output(env) }, @@ -34,11 +35,14 @@ func Shortcuts() []*common.Shortcut { {Name: "id", Short: "i", Usage: "Organization ID or login", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil) + id, err := ctx.RequireArg("id") if err != nil { return err } + env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil) + if err != nil { + return fmt.Errorf("查看组织详情失败: %w", err) + } return ctx.Output(env) }, }, @@ -51,13 +55,16 @@ func Shortcuts() []*common.Shortcut { {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { - id, _ := ctx.RequireArg("id") + id, err := ctx.RequireArg("id") + if err != nil { + return err + } q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/organization_users", id), q) if err != nil { - return err + return fmt.Errorf("获取组织成员列表失败: %w", err) } return ctx.Output(env) }, @@ -70,7 +77,10 @@ func Shortcuts() []*common.Shortcut { {Name: "description", Short: "d", Usage: "Description"}, }, Run: func(ctx *common.RuntimeContext) error { - name, _ := ctx.RequireArg("name") + name, err := ctx.RequireArg("name") + if err != nil { + return err + } payload := map[string]interface{}{ "name": name, } @@ -79,7 +89,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", "/organizations", payload) if err != nil { - return err + return fmt.Errorf("创建组织失败: %w", err) } return ctx.Output(env) }, diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 1e12c02..efee647 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -20,7 +20,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } q := url.Values{} q.Set("page", ctx.Arg("page")) @@ -30,7 +30,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q) if err != nil { - return err + return fmt.Errorf("获取 PR 列表失败: %w", err) } return ctx.Output(env) }, @@ -46,10 +46,16 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { + return fmt.Errorf("解析仓库信息失败: %w", err) + } + title, err := ctx.RequireArg("title") + if err != nil { + return err + } + head, err := ctx.RequireArg("head") + if err != nil { return err } - title, _ := ctx.RequireArg("title") - head, _ := ctx.RequireArg("head") base := ctx.Arg("base") if base == "" { base = "master" @@ -64,7 +70,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload) if err != nil { - return err + return fmt.Errorf("创建 PR 失败: %w", err) } return ctx.Output(env) }, @@ -77,13 +83,16 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil) + id, err := ctx.RequireArg("id") if err != nil { return err } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil) + if err != nil { + return fmt.Errorf("查看 PR 详情失败: %w", err) + } return ctx.Output(env) }, }, @@ -96,9 +105,12 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { + return fmt.Errorf("解析仓库信息失败: %w", err) + } + id, err := ctx.RequireArg("id") + if err != nil { return err } - id, _ := ctx.RequireArg("id") method := ctx.Arg("method") if method == "" { method = "merge" @@ -108,7 +120,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload) if err != nil { - return err + return fmt.Errorf("合并 PR 失败: %w", err) } return ctx.Output(env) }, @@ -121,13 +133,16 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil) + id, err := ctx.RequireArg("id") if err != nil { return err } + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil) + if err != nil { + return fmt.Errorf("关闭 PR 失败: %w", err) + } return ctx.Output(env) }, }, @@ -139,13 +154,16 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil) + id, err := ctx.RequireArg("id") if err != nil { return err } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil) + if err != nil { + return fmt.Errorf("获取 PR 变更文件失败: %w", err) + } return ctx.Output(env) }, }, @@ -157,13 +175,16 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil) + id, err := ctx.RequireArg("id") if err != nil { return err } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil) + if err != nil { + return fmt.Errorf("获取 PR Diff 失败: %w", err) + } return ctx.Output(env) }, }, @@ -177,10 +198,16 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { + return fmt.Errorf("解析仓库信息失败: %w", err) + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + body, err := ctx.RequireArg("body") + if err != nil { return err } - id, _ := ctx.RequireArg("id") - body, _ := ctx.RequireArg("body") action := ctx.Arg("action") if action == "" { action = "comment" @@ -193,7 +220,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), payload) if err != nil { - return err + return fmt.Errorf("提交 PR Review 失败: %w", err) } return ctx.Output(env) }, @@ -207,14 +234,20 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { + return fmt.Errorf("解析仓库信息失败: %w", err) + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + body, err := ctx.RequireArg("body") + if err != nil { return err } - id, _ := ctx.RequireArg("id") - body, _ := ctx.RequireArg("body") prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil) if err != nil { - return fmt.Errorf("fetch PR: %w", err) + return fmt.Errorf("获取 PR 详情: %w", err) } issueID, err := extractIssueID(prEnv) if err != nil { @@ -226,7 +259,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), payload) if err != nil { - return err + return fmt.Errorf("添加 PR 评论失败: %w", err) } return ctx.Output(env) }, @@ -248,15 +281,15 @@ func mapAction(action string) string { func extractIssueID(env *output.Envelope) (int64, error) { data, ok := env.Data.(map[string]interface{}) if !ok { - return 0, fmt.Errorf("unexpected PR response format") + return 0, fmt.Errorf("PR 响应格式异常") } issue, ok := data["issue"].(map[string]interface{}) if !ok { - return 0, fmt.Errorf("PR response missing issue field") + return 0, fmt.Errorf("PR 响应缺少 issue 字段") } idFloat, ok := issue["id"].(float64) if !ok { - return 0, fmt.Errorf("PR response missing issue.id field") + return 0, fmt.Errorf("PR 响应缺少 issue.id 字段") } return int64(idFloat), nil } diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index 2c84b4a..30ce72b 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -22,14 +22,14 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q) if err != nil { - return err + return fmt.Errorf("获取 Release 列表失败: %w", err) } return ctx.Output(env) }, @@ -46,10 +46,16 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { + return fmt.Errorf("解析仓库信息失败: %w", err) + } + tag, err := ctx.RequireArg("tag") + if err != nil { + return err + } + name, err := ctx.RequireArg("name") + if err != nil { return err } - tag, _ := ctx.RequireArg("tag") - name, _ := ctx.RequireArg("name") payload := map[string]interface{}{ "tag_name": tag, "name": name, @@ -65,7 +71,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload) if err != nil { - return err + return fmt.Errorf("创建 Release 失败: %w", err) } return ctx.Output(env) }, @@ -78,13 +84,16 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) + id, err := ctx.RequireArg("id") if err != nil { return err } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) + if err != nil { + return fmt.Errorf("查看 Release 详情失败: %w", err) + } return ctx.Output(env) }, }, @@ -98,24 +107,26 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { + return fmt.Errorf("解析仓库信息失败: %w", err) + } + id, err := ctx.RequireArg("id") + if err != nil { return err } - id, _ := ctx.RequireArg("id") assetName := ctx.Arg("asset") dlDir := ctx.Arg("dir") if dlDir == "" { dlDir = "." } - // Fetch release to get assets releaseEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) if err != nil { - return err + return fmt.Errorf("获取 Release 信息失败: %w", err) } releaseData, ok := releaseEnv.Data.(map[string]interface{}) if !ok { - return fmt.Errorf("unexpected release response format") + return fmt.Errorf("Release 响应格式异常") } assets, ok := releaseData["assets"].([]interface{}) @@ -126,7 +137,6 @@ func Shortcuts() []*common.Shortcut { } if assetName == "" { - // List all assets var assetList []map[string]interface{} for _, a := range assets { asset, _ := a.(map[string]interface{}) @@ -140,7 +150,6 @@ func Shortcuts() []*common.Shortcut { }) } - // Find and download the specific asset var downloadURL string for _, a := range assets { asset, _ := a.(map[string]interface{}) @@ -155,39 +164,37 @@ func Shortcuts() []*common.Shortcut { } if downloadURL == "" { - return fmt.Errorf("asset %q not found in release", assetName) + return fmt.Errorf("Release 中未找到附件 %q", assetName) } - // Ensure target directory exists if err := os.MkdirAll(dlDir, 0755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", dlDir, err) + return fmt.Errorf("创建目录 %s 失败: %w", dlDir, err) } - // Download using authenticated client so private repos work resp, err := ctx.Client.HTTP.Get(downloadURL) if err != nil { - return fmt.Errorf("download failed: %w", err) + return fmt.Errorf("下载失败: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { - return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) + return fmt.Errorf("下载失败: HTTP %d", resp.StatusCode) } destPath := filepath.Join(dlDir, assetName) file, err := os.Create(destPath) if err != nil { - return fmt.Errorf("failed to create file %s: %w", destPath, err) + return fmt.Errorf("创建文件 %s 失败: %w", destPath, err) } defer file.Close() written, err := io.Copy(file, resp.Body) if err != nil { - return fmt.Errorf("failed to write file: %w", err) + return fmt.Errorf("写入文件失败: %w", err) } return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ - "message": fmt.Sprintf("下载完成"), + "message": "下载完成", "path": destPath, "size": written, }, nil)) @@ -201,22 +208,21 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { + return fmt.Errorf("解析仓库信息失败: %w", err) + } + id, err := ctx.RequireArg("id") + if err != nil { return err } - id, _ := ctx.RequireArg("id") _, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) if delErr != nil { - // GitLink API bug: delete succeeds but returns error status. - // Verify by checking if the release still exists. _, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) if viewErr != nil { - // Release no longer exists — delete actually succeeded return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ "message": "删除成功", }, nil)) } - // Release still exists — delete truly failed - return delErr + return fmt.Errorf("删除 Release 失败: %w", delErr) } return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ "message": "删除成功", diff --git a/shortcuts/repo/batch.go b/shortcuts/repo/batch.go new file mode 100644 index 0000000..11db3e9 --- /dev/null +++ b/shortcuts/repo/batch.go @@ -0,0 +1,279 @@ +package repo + +import ( + "encoding/csv" + "fmt" + "os" + "strings" + "time" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type batchRepoResult struct { + Repo string `json:"repo" yaml:"repo"` + Action string `json:"action" yaml:"action"` + Status string `json:"status" yaml:"status"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` +} + +type batchRepoSummary struct { + Total int `json:"total" yaml:"total"` + Succeeded int `json:"succeeded" yaml:"succeeded"` + Failed int `json:"failed" yaml:"failed"` + Duration string `json:"duration" yaml:"duration"` + Results []batchRepoResult `json:"results" yaml:"results"` +} + +func newBatchDeleteShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-delete", + Description: "批量删除仓库,支持逗号分隔列表或 CSV 文件", + Flags: []common.Flag{ + {Name: "repos", Short: "r", Usage: "逗号分隔的仓库标识,格式为 owner/repo,例如: alice/proj1,bob/proj2"}, + {Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo、owner,repo 双列或 owner/repo 单列格式"}, + {Name: "dry-run", Usage: "仅预览将要删除的仓库,不实际执行", Bool: true, Default: "false"}, + }, + Run: runBatchDelete, + } +} + +func runBatchDelete(ctx *common.RuntimeContext) error { + start := time.Now() + + repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from")) + if err != nil { + return err + } + if len(repos) == 0 { + return fmt.Errorf("未提供仓库标识,请使用 --repos owner/repo1,owner/repo2 或 --from repos.csv") + } + + dryRun := parseBool(ctx.Arg("dry-run")) + dryRunVal := false + if ctx.Arg("dry-run") != "" { + dryRunVal = dryRun + } + _ = dryRunVal + + summary := batchRepoSummary{ + Total: len(repos), + Results: make([]batchRepoResult, 0, len(repos)), + } + + for _, repoID := range repos { + parts := strings.SplitN(repoID, "/", 2) + result := batchRepoResult{Repo: repoID, Action: "delete"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + path := fmt.Sprintf("/%s/%s", parts[0], parts[1]) + if _, err := ctx.CallAPI("DELETE", path, nil); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "deleted" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + summary.Duration = time.Since(start).String() + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d / %d 个仓库删除失败", summary.Failed, summary.Total) + } + return nil +} + +func newBatchForkShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-fork", + Description: "批量 Fork 仓库,支持逗号分隔列表或 CSV 文件", + Flags: []common.Flag{ + {Name: "repos", Short: "r", Usage: "逗号分隔的仓库标识,格式为 owner/repo,例如: alice/proj1,bob/proj2"}, + {Name: "from", Usage: "从 CSV 文件读取仓库标识。支持 owner/repo、owner,repo 双列或 owner/repo 单列格式"}, + {Name: "dry-run", Usage: "仅预览将要 Fork 的仓库,不实际执行", Bool: true, Default: "false"}, + }, + Run: runBatchFork, + } +} + +func runBatchFork(ctx *common.RuntimeContext) error { + start := time.Now() + + repos, err := collectRepos(ctx.Arg("repos"), ctx.Arg("from")) + if err != nil { + return err + } + if len(repos) == 0 { + return fmt.Errorf("未提供仓库标识,请使用 --repos owner/repo1,owner/repo2 或 --from repos.csv") + } + + dryRun := parseBool(ctx.Arg("dry-run")) + + summary := batchRepoSummary{ + Total: len(repos), + Results: make([]batchRepoResult, 0, len(repos)), + } + + for _, repoID := range repos { + parts := strings.SplitN(repoID, "/", 2) + result := batchRepoResult{Repo: repoID, Action: "fork"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + path := fmt.Sprintf("/%s/%s/forks", parts[0], parts[1]) + if _, err := ctx.CallAPI("POST", path, nil); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "forked" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + summary.Duration = time.Since(start).String() + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d / %d 个仓库 Fork 失败", summary.Failed, summary.Total) + } + return nil +} + +func collectRepos(reposValue, csvPath string) ([]string, error) { + repos, err := parseRepoList(reposValue) + if err != nil { + return nil, err + } + if csvPath == "" { + return repos, nil + } + + csvRepos, err := readReposFromCSV(csvPath) + if err != nil { + return nil, err + } + return mergeRepoLists(repos, csvRepos), nil +} + +func parseRepoList(value string) ([]string, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + return normalizeRepoIDs(strings.Split(value, ",")) +} + +func readReposFromCSV(path string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("读取 CSV 文件失败: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + reader.TrimLeadingSpace = true + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("解析 CSV 文件失败: %w", err) + } + if len(records) == 0 { + return nil, nil + } + + // Detect column layout: single owner/repo column, or owner+repo dual columns + singleCol, ownerCol, repoCol := -1, -1, -1 + startRow := 0 + for i, cell := range records[0] { + switch strings.ToLower(strings.TrimSpace(cell)) { + case "owner/repo", "full_name": + singleCol = i + startRow = 1 + case "owner": + ownerCol = i + startRow = 1 + case "repo", "repository", "name": + repoCol = i + startRow = 1 + } + } + + // Fallback: no header — first column is owner/repo + if singleCol == -1 && ownerCol == -1 && repoCol == -1 { + singleCol = 0 + } + + values := make([]string, 0, len(records)-startRow) + for _, record := range records[startRow:] { + var repoID string + if singleCol >= 0 && singleCol < len(record) { + repoID = record[singleCol] + } else if ownerCol >= 0 && repoCol >= 0 && ownerCol < len(record) && repoCol < len(record) { + repoID = record[ownerCol] + "/" + record[repoCol] + } else { + continue + } + values = append(values, repoID) + } + return normalizeRepoIDs(values) +} + +func normalizeRepoIDs(values []string) ([]string, error) { + repos := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + repoID := strings.TrimSpace(value) + if repoID == "" { + continue + } + parts := strings.SplitN(repoID, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return nil, fmt.Errorf("无效的仓库标识 %q: 请使用 owner/repo 格式", repoID) + } + if seen[repoID] { + continue + } + seen[repoID] = true + repos = append(repos, repoID) + } + return repos, nil +} + +func mergeRepoLists(values ...[]string) []string { + merged := []string{} + seen := map[string]bool{} + for _, repos := range values { + for _, r := range repos { + if seen[r] { + continue + } + seen[r] = true + merged = append(merged, r) + } + } + return merged +} + +func parseBool(value string) bool { + if strings.EqualFold(strings.TrimSpace(value), "true") { + return true + } + return false +} diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 843105d..d478803 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -12,6 +12,8 @@ import ( func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ + newBatchDeleteShortcut(), + newBatchForkShortcut(), { Name: "list", Description: "List repositories for a user or organization", @@ -36,7 +38,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPIWithQuery("GET", path, q) if err != nil { - return err + return fmt.Errorf("获取仓库列表失败: %w", err) } return ctx.Output(env) }, @@ -49,7 +51,10 @@ func Shortcuts() []*common.Shortcut { {Name: "dir", Short: "d", Usage: "Target directory (default: repo name)"}, }, Run: func(ctx *common.RuntimeContext) error { - rawURL, _ := ctx.RequireArg("url") + rawURL, err := ctx.RequireArg("url") + if err != nil { + return err + } targetDir := ctx.Arg("dir") var cloneURL string @@ -65,9 +70,8 @@ func Shortcuts() []*common.Shortcut { } else { parts := strings.SplitN(rawURL, "/", 2) if len(parts) != 2 { - return fmt.Errorf("invalid repository format %q: use owner/repo", rawURL) + return fmt.Errorf("无效的仓库格式 %q: 请使用 owner/repo 格式", rawURL) } - // Derive web base URL from API base URL (strip /api suffix) webBase := strings.TrimSuffix(strings.TrimSuffix(ctx.Client.BaseURL, "/"), "/api") cloneURL = fmt.Sprintf("%s/%s/%s.git", webBase, parts[0], parts[1]) repoName = parts[1] @@ -77,7 +81,7 @@ func Shortcuts() []*common.Shortcut { targetDir = repoName } - fmt.Printf("Cloning %s into %s...\n", cloneURL, targetDir) + fmt.Printf("正在克隆 %s 到 %s...\n", cloneURL, targetDir) cmd := exec.Command("git", "clone", cloneURL, targetDir) cmd.Stdout = os.Stdout @@ -85,7 +89,7 @@ func Shortcuts() []*common.Shortcut { cmd.Stdin = os.Stdin if err := cmd.Run(); err != nil { - return fmt.Errorf("clone failed: %w", err) + return fmt.Errorf("克隆失败: %w", err) } return nil }, @@ -95,11 +99,11 @@ func Shortcuts() []*common.Shortcut { Description: "Show repository details", Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) if err != nil { - return err + return fmt.Errorf("获取仓库详情失败: %w", err) } return ctx.Output(env) }, @@ -117,15 +121,14 @@ func Shortcuts() []*common.Shortcut { if err != nil { return err } - // Get current user login for the create path userEnv, err := ctx.CallAPI("GET", "/users/me", nil) if err != nil { - return fmt.Errorf("failed to get current user: %w", err) + return fmt.Errorf("获取当前用户信息失败: %w", err) } userData, _ := userEnv.Data.(map[string]interface{}) login, _ := userData["login"].(string) if login == "" { - return fmt.Errorf("cannot determine current user login") + return fmt.Errorf("无法获取当前用户名") } userID, _ := userData["user_id"].(float64) body := map[string]interface{}{ @@ -141,7 +144,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, name), body) if err != nil { - return err + return fmt.Errorf("创建仓库失败: %w", err) } return ctx.Output(env) }, @@ -151,11 +154,11 @@ func Shortcuts() []*common.Shortcut { Description: "Fork a repository", Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/forks", nil) if err != nil { - return err + return fmt.Errorf("Fork 仓库失败: %w", err) } return ctx.Output(env) }, @@ -170,27 +173,25 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } visibility := ctx.Arg("visibility") defaultBranch := ctx.Arg("default-branch") description := ctx.Arg("description") - // If no flags provided, show current settings if visibility == "" && defaultBranch == "" && description == "" { env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) if err != nil { - return err + return fmt.Errorf("获取仓库设置失败: %w", err) } return ctx.Output(env) } - // Otherwise update with provided settings body := map[string]interface{}{} if visibility != "" { if visibility != "public" && visibility != "private" { - return fmt.Errorf("invalid visibility %q: must be public or private", visibility) + return fmt.Errorf("无效的 visibility 值 %q: 必须为 public 或 private", visibility) } body["visibility"] = visibility } @@ -203,7 +204,7 @@ func Shortcuts() []*common.Shortcut { env, err := ctx.CallAPI("PATCH", ctx.RepoPath(), body) if err != nil { - return err + return fmt.Errorf("更新仓库设置失败: %w", err) } return ctx.Output(env) }, @@ -213,11 +214,11 @@ func Shortcuts() []*common.Shortcut { Description: "Delete a repository", Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil) if err != nil { - return err + return fmt.Errorf("删除仓库失败: %w", err) } return ctx.Output(env) }, diff --git a/shortcuts/search/search.go b/shortcuts/search/search.go index b0b6d43..9ce13f3 100644 --- a/shortcuts/search/search.go +++ b/shortcuts/search/search.go @@ -1,6 +1,7 @@ package search import ( + "fmt" "net/url" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -17,14 +18,17 @@ func Shortcuts() []*common.Shortcut { {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { - keyword, _ := ctx.RequireArg("keyword") + keyword, err := ctx.RequireArg("keyword") + if err != nil { + return err + } q := url.Values{} q.Set("search", keyword) q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", "/projects", q) if err != nil { - return err + return fmt.Errorf("搜索仓库失败: %w", err) } return ctx.Output(env) }, @@ -38,14 +42,17 @@ func Shortcuts() []*common.Shortcut { {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { - keyword, _ := ctx.RequireArg("keyword") + keyword, err := ctx.RequireArg("keyword") + if err != nil { + return err + } q := url.Values{} q.Set("search", keyword) q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", "/users/list", q) if err != nil { - return err + return fmt.Errorf("搜索用户失败: %w", err) } return ctx.Output(env) }, @@ -61,7 +68,10 @@ func Shortcuts() []*common.Shortcut { {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { - keyword, _ := ctx.RequireArg("keyword") + keyword, err := ctx.RequireArg("keyword") + if err != nil { + return err + } q := url.Values{} q.Set("search", keyword) q.Set("page", ctx.Arg("page")) @@ -74,7 +84,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPIWithQuery("GET", "/issues", q) if err != nil { - return err + return fmt.Errorf("搜索 Issue 失败: %w", err) } return ctx.Output(env) }, diff --git a/shortcuts/user/user.go b/shortcuts/user/user.go index cfcaa2a..f461378 100644 --- a/shortcuts/user/user.go +++ b/shortcuts/user/user.go @@ -14,7 +14,7 @@ func Shortcuts() []*common.Shortcut { Run: func(ctx *common.RuntimeContext) error { env, err := ctx.CallAPI("GET", "/users/me", nil) if err != nil { - return err + return fmt.Errorf("获取当前用户信息失败: %w", err) } return ctx.Output(env) }, @@ -32,7 +32,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil) if err != nil { - return err + return fmt.Errorf("查看用户详情失败: %w", err) } return ctx.Output(env) }, diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go index 8f704c5..ac530d2 100644 --- a/shortcuts/webhook/webhook.go +++ b/shortcuts/webhook/webhook.go @@ -18,11 +18,11 @@ func Shortcuts() []*common.Shortcut { Description: "List webhooks", Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } env, err := ctx.CallAPI("GET", v1RepoPath(ctx)+"/webhooks", nil) if err != nil { - return err + return fmt.Errorf("获取 Webhook 列表失败: %w", err) } return ctx.Output(env) }, @@ -40,7 +40,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } webhookURL, err := ctx.RequireArg("url") if err != nil { @@ -68,7 +68,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/webhooks", body) if err != nil { - return err + return fmt.Errorf("创建 Webhook 失败: %w", err) } return ctx.Output(env) }, @@ -81,7 +81,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } id, err := ctx.RequireArg("id") if err != nil { @@ -89,7 +89,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", v1RepoPath(ctx), id), nil) if err != nil { - return err + return fmt.Errorf("查看 Webhook 详情失败: %w", err) } return ctx.Output(env) }, @@ -108,7 +108,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } id, err := ctx.RequireArg("id") if err != nil { @@ -141,11 +141,11 @@ func Shortcuts() []*common.Shortcut { body["active"] = active == "true" } if len(body) == 0 { - return fmt.Errorf("at least one update field is required") + return fmt.Errorf("至少需要指定一个要更新的字段") } env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/webhooks/%s", v1RepoPath(ctx), id), body) if err != nil { - return err + return fmt.Errorf("更新 Webhook 失败: %w", err) } return ctx.Output(env) }, @@ -158,7 +158,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } id, err := ctx.RequireArg("id") if err != nil { @@ -166,7 +166,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", v1RepoPath(ctx), id), nil) if err != nil { - return err + return fmt.Errorf("删除 Webhook 失败: %w", err) } return ctx.Output(env) }, @@ -179,7 +179,7 @@ func Shortcuts() []*common.Shortcut { }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { - return err + return fmt.Errorf("解析仓库信息失败: %w", err) } id, err := ctx.RequireArg("id") if err != nil { @@ -187,7 +187,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", v1RepoPath(ctx), id), nil) if err != nil { - return err + return fmt.Errorf("测试 Webhook 失败: %w", err) } return ctx.Output(env) },