From bb0950d082c6b1ab3e52d6f3ca849f86695aa673 Mon Sep 17 00:00:00 2001 From: camelliamc <16583354+camelliamc@user.noreply.gitee.com> Date: Thu, 28 May 2026 16:04:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=89=B9=E9=87=8F=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shortcuts/repo/batch_create.go | 196 +++++++++++++++++++++++++++++++++ shortcuts/repo/batch_update.go | 165 +++++++++++++++++++++++++++ shortcuts/repo/repo.go | 2 + 3 files changed, 363 insertions(+) create mode 100644 shortcuts/repo/batch_create.go create mode 100644 shortcuts/repo/batch_update.go diff --git a/shortcuts/repo/batch_create.go b/shortcuts/repo/batch_create.go new file mode 100644 index 0000000..cf72a36 --- /dev/null +++ b/shortcuts/repo/batch_create.go @@ -0,0 +1,196 @@ +package repo + +import ( + "encoding/csv" + "fmt" + "os" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type repoCreateInput struct { + Name string + Description string + Private bool +} + +type repoBatchResult struct { + Name string `json:"name" yaml:"name"` + Status string `json:"status" yaml:"status"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` +} + +type repoBatchSummary struct { + Owner string `json:"owner" yaml:"owner"` + Action string `json:"action" yaml:"action"` + DryRun bool `json:"dry_run" yaml:"dry_run"` + Total int `json:"total" yaml:"total"` + Succeeded int `json:"succeeded" yaml:"succeeded"` + Failed int `json:"failed" yaml:"failed"` + Results []repoBatchResult `json:"results" yaml:"results"` +} + +func newBatchCreateShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-create", + Description: "Create multiple repositories from CLI flags or a CSV file", + Flags: []common.Flag{ + {Name: "names", Short: "n", Usage: "Comma-separated repository names, e.g. repo-a,repo-b"}, + {Name: "from", Usage: "CSV file path"}, + {Name: "description", Short: "d", Usage: "Shared description for all repos (inline mode)"}, + {Name: "private", Usage: "Make repos private", Bool: true, Default: "false"}, + {Name: "dry-run", Usage: "Preview without creating", Bool: true, Default: "false"}, + }, + Run: runBatchCreate, + } +} + +func runBatchCreate(ctx *common.RuntimeContext) error { + var inputs []repoCreateInput + + if namesStr := ctx.Arg("names"); namesStr != "" { + for _, name := range strings.Split(namesStr, ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + inputs = append(inputs, repoCreateInput{ + Name: name, + Description: ctx.Arg("description"), + Private: ctx.Arg("private") == "true", + }) + } + } + + if csvPath := ctx.Arg("from"); csvPath != "" { + csvInputs, err := readRepoInputsFromCSV(csvPath) + if err != nil { + return err + } + inputs = append(inputs, csvInputs...) + } + + if len(inputs) == 0 { + return fmt.Errorf("no repository names provided; use -n repo-a,repo-b or --from repos.csv") + } + + dryRun := ctx.Arg("dry-run") == "true" + + var login string + var userID int + if !dryRun { + userEnv, err := ctx.CallAPI("GET", "/users/me", nil) + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + userData, _ := userEnv.Data.(map[string]interface{}) + login, _ = userData["login"].(string) + if login == "" { + return fmt.Errorf("cannot determine current user login") + } + if uid, ok := userData["user_id"].(float64); ok { + userID = int(uid) + } + } + + summary := repoBatchSummary{ + Owner: login, + Action: "create", + DryRun: dryRun, + Total: len(inputs), + Results: make([]repoBatchResult, 0, len(inputs)), + } + + for _, input := range inputs { + result := repoBatchResult{Name: input.Name} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + body := map[string]interface{}{ + "name": input.Name, + "repository_name": input.Name, + "user_id": userID, + } + if input.Description != "" { + body["description"] = input.Description + } + if input.Private { + body["private"] = true + } + + if _, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, input.Name), body); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "created" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d of %d repo(s) failed to create", summary.Failed, summary.Total) + } + return nil +} + +func readRepoInputsFromCSV(path string) ([]repoCreateInput, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("read CSV: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + reader.TrimLeadingSpace = true + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("parse CSV: %w", err) + } + if len(records) < 2 { + return nil, fmt.Errorf("CSV must have a header row and at least one data row") + } + + header := records[0] + col := make(map[string]int) + for i, h := range header { + col[strings.ToLower(strings.TrimSpace(h))] = i + } + if _, ok := col["name"]; !ok { + return nil, fmt.Errorf("CSV must have a 'name' column") + } + + var inputs []repoCreateInput + for _, record := range records[1:] { + name := getCol(record, col, "name") + if name == "" { + continue + } + private := false + if p := strings.ToLower(getCol(record, col, "private")); p == "true" || p == "1" { + private = true + } + inputs = append(inputs, repoCreateInput{ + Name: name, + Description: getCol(record, col, "description"), + Private: private, + }) + } + return inputs, nil +} + +func getCol(record []string, col map[string]int, name string) string { + if idx, ok := col[name]; ok && idx < len(record) { + return strings.TrimSpace(record[idx]) + } + return "" +} diff --git a/shortcuts/repo/batch_update.go b/shortcuts/repo/batch_update.go new file mode 100644 index 0000000..98c7469 --- /dev/null +++ b/shortcuts/repo/batch_update.go @@ -0,0 +1,165 @@ +package repo + +import ( + "encoding/csv" + "fmt" + "os" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func newBatchUpdateShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-update", + Description: "Update settings for multiple repositories", + Flags: []common.Flag{ + {Name: "names", Short: "n", Usage: "Comma-separated repository names, e.g. repo-a,repo-b"}, + {Name: "from", Usage: "CSV file path"}, + {Name: "description", Short: "d", Usage: "Shared description for all repos"}, + {Name: "private", Usage: "Set repos to private", Bool: true, Default: "false"}, + {Name: "public", Usage: "Set repos to public", Bool: true, Default: "false"}, + {Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"}, + }, + Run: runBatchUpdate, + } +} + +func runBatchUpdate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + var repoNames []string + if namesStr := ctx.Arg("names"); namesStr != "" { + for _, name := range strings.Split(namesStr, ",") { + name = strings.TrimSpace(name) + if name != "" { + repoNames = append(repoNames, name) + } + } + } + if csvPath := ctx.Arg("from"); csvPath != "" { + csvNames, err := readNamesFromCSV(csvPath) + if err != nil { + return err + } + repoNames = append(repoNames, csvNames...) + } + if len(repoNames) == 0 { + return fmt.Errorf("no repository names provided; use -n repo-a,repo-b or --from repos.csv") + } + + setPrivate := ctx.Arg("private") == "true" + setPublic := ctx.Arg("public") == "true" + if setPrivate && setPublic { + return fmt.Errorf("cannot use both --private and --public") + } + changeVisibility := setPrivate || setPublic + + dryRun := ctx.Arg("dry-run") == "true" + desc := ctx.Arg("description") + + summary := repoBatchSummary{ + Owner: ctx.Owner, + Action: "update", + DryRun: dryRun, + Total: len(repoNames), + Results: make([]repoBatchResult, 0, len(repoNames)), + } + + for _, name := range repoNames { + result := repoBatchResult{Name: name} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + // Fetch current repo info to get required fields for PATCH + current, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s", ctx.Owner, name), nil) + if err != nil { + result.Status = "failed" + result.Error = fmt.Sprintf("fetch repo: %v", err) + summary.Failed++ + summary.Results = append(summary.Results, result) + continue + } + + curData, _ := current.Data.(map[string]interface{}) + curName, _ := curData["name"].(string) + identifier, _ := curData["identifier"].(string) + + body := map[string]interface{}{ + "name": curName, + "identifier": identifier, + } + if desc != "" { + body["description"] = desc + } + if changeVisibility { + body["private"] = setPrivate + } + + if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("/%s/%s", ctx.Owner, name), body); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "updated" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d of %d repo(s) failed to update", summary.Failed, summary.Total) + } + return nil +} + +func readNamesFromCSV(path string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("read CSV: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + reader.TrimLeadingSpace = true + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("parse CSV: %w", err) + } + if len(records) < 2 { + return nil, fmt.Errorf("CSV must have a header row and at least one data row") + } + + header := records[0] + nameCol := -1 + for i, h := range header { + if strings.ToLower(strings.TrimSpace(h)) == "name" { + nameCol = i + break + } + } + if nameCol == -1 { + nameCol = 0 + } + + var names []string + for _, record := range records[1:] { + if nameCol >= len(record) { + continue + } + name := strings.TrimSpace(record[nameCol]) + if name != "" { + names = append(names, name) + } + } + return names, nil +} diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 75091a4..1fa9d29 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -9,6 +9,8 @@ import ( func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ + newBatchCreateShortcut(), + newBatchUpdateShortcut(), { Name: "list", Description: "List repositories for a user or organization",