This commit is contained in:
chroe 2026-06-03 16:54:16 +08:00
commit d072d54995
21 changed files with 881 additions and 219 deletions

View File

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

View File

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

View File

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

61
internal/errors/errors.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

214
shortcuts/org/batch.go Normal file
View File

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

View File

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

View File

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

View File

@ -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": "删除成功",

279
shortcuts/repo/batch.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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