feat:批量仓库管理
This commit is contained in:
parent
59c5f8f43c
commit
bb0950d082
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ import (
|
|||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newBatchCreateShortcut(),
|
||||
newBatchUpdateShortcut(),
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List repositories for a user or organization",
|
||||
|
|
|
|||
Loading…
Reference in New Issue