forked from Gitlink/gitlink-cli
215 lines
5.2 KiB
Go
215 lines
5.2 KiB
Go
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
|
||
}
|