gitlink-cli/shortcuts/collaborator/batch.go

316 lines
9.2 KiB
Go

package collaborator
import (
"fmt"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// userRole holds a username and the role to assign.
type userRole struct {
User string
Role string
}
func newBatchAddShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-add",
Description: "Add multiple collaborators to the repository",
Long: `Add multiple collaborators to the repository in a single operation.
Provide collaborators via --users (comma-separated usernames) or --from (CSV
file with user_id and role columns). When --from supplies a role column that
value is used; otherwise the --role flag (default: write) applies.
Use --dry-run to preview which collaborators would be added without making
any changes.
Available roles: admin, write, read.`,
Example: ` # Add two collaborators with the default write role
gitlink collaborator +batch-add --users zhangsan,lisi
# Add collaborators with admin role
gitlink collaborator +batch-add --users zhangsan,lisi --role admin
# Dry-run to preview additions from a CSV file
gitlink collaborator +batch-add --from collaborators.csv --dry-run
# Combine both sources
gitlink collaborator +batch-add --users zhangsan --from collaborators.csv`,
Flags: []common.Flag{
{Name: "users", Short: "u", Usage: "Comma-separated usernames to add"},
{Name: "from", Usage: "CSV file path (columns: user_id, role)"},
{Name: "role", Short: "r", Usage: "Default role for --users: admin, write, read", Default: "write"},
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
},
Run: runBatchAdd,
}
}
func newBatchRemoveShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-remove",
Description: "Remove multiple collaborators from the repository",
Long: `Remove multiple collaborators from the repository in a single operation.
Provide collaborators via --users (comma-separated usernames) or --from (CSV
file with a user_id column).
This action is irreversible. A confirmation prompt is shown before removal
unless --yes is set.
Use --dry-run to preview which collaborators would be removed without making
any changes.`,
Example: ` # Remove two collaborators
gitlink collaborator +batch-remove --users zhangsan,lisi
# Dry-run to preview removals
gitlink collaborator +batch-remove --users zhangsan,lisi --dry-run
# Remove collaborators listed in a CSV file
gitlink collaborator +batch-remove --from users.csv`,
Flags: []common.Flag{
{Name: "users", Short: "u", Usage: "Comma-separated usernames to remove"},
{Name: "from", Usage: "CSV file path (column: user_id)"},
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
},
Run: runBatchRemove,
}
}
func newBatchRoleShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "batch-role",
Description: "Change role for multiple collaborators",
Long: `Change the role of multiple collaborators in a single operation.
Provide collaborators via --users (comma-separated usernames) combined with
--role, or via --from (CSV file with user_id and role columns).
Use --dry-run to preview which role changes would be applied without making
any changes.
Available roles: admin, write, read.`,
Example: ` # Change two collaborators to admin
gitlink collaborator +batch-role --users zhangsan,lisi --role admin
# Dry-run to preview role changes from a CSV file
gitlink collaborator +batch-role --from roles.csv --dry-run
# Combine both sources
gitlink collaborator +batch-role --users zhangsan --role read --from roles.csv`,
Flags: []common.Flag{
{Name: "users", Short: "u", Usage: "Comma-separated usernames"},
{Name: "from", Usage: "CSV file path (columns: user_id, role)"},
{Name: "role", Short: "r", Usage: "New role for --users: admin, write, read"},
{Name: "dry-run", Usage: "Preview changes without applying them", Bool: true, Default: "false"},
},
Run: runBatchRole,
}
}
// --- batch-add ---
func runBatchAdd(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
pairs, err := collectUserRoles(ctx.Arg("users"), ctx.Arg("from"), ctx.Arg("role"))
if err != nil {
return err
}
if len(pairs) == 0 {
return fmt.Errorf("no users provided; use --users user1,user2 or --from collaborators.csv")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
items := make([]string, 0, len(pairs))
for _, p := range pairs {
items = append(items, p.User)
}
summary := common.ProcessBatch(items, dryRun, "add", func(user string) error {
role := roleForUser(pairs, user)
payload := map[string]interface{}{
"user": map[string]string{
"user_id": user,
"role_name": role,
},
}
_, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo), payload)
return err
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d collaborator(s) failed to add", summary.Failed, summary.Total)
}
return nil
}
// --- batch-remove ---
func runBatchRemove(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
users, err := collectUsers(ctx.Arg("users"), ctx.Arg("from"))
if err != nil {
return err
}
if len(users) == 0 {
return fmt.Errorf("no users provided; use --users user1,user2 or --from users.csv")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
if !dryRun {
if err := common.ConfirmAction(
fmt.Sprintf("remove %d collaborator(s) from %s/%s", len(users), ctx.Owner, ctx.Repo),
); err != nil {
return err
}
}
summary := common.ProcessBatch(users, dryRun, "remove", func(user string) error {
payload := map[string]interface{}{
"user": map[string]string{
"user_id": user,
},
}
_, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/collaborators/remove", ctx.Owner, ctx.Repo), payload)
return err
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d collaborator(s) failed to remove", summary.Failed, summary.Total)
}
return nil
}
// --- batch-role ---
func runBatchRole(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
usersValue := ctx.Arg("users")
fromValue := ctx.Arg("from")
roleValue := ctx.Arg("role")
// When --users is used without --from, --role is required.
if usersValue != "" && fromValue == "" && roleValue == "" {
return fmt.Errorf("--role is required when using --users without --from")
}
pairs, err := collectUserRoles(usersValue, fromValue, roleValue)
if err != nil {
return err
}
if len(pairs) == 0 {
return fmt.Errorf("no users provided; use --users user1,user2 --role admin or --from roles.csv")
}
dryRun := common.ParseBool(ctx.Arg("dry-run"))
items := make([]string, 0, len(pairs))
for _, p := range pairs {
items = append(items, p.User)
}
summary := common.ProcessBatch(items, dryRun, "change-role", func(user string) error {
role := roleForUser(pairs, user)
payload := map[string]interface{}{
"user": map[string]string{
"user_id": user,
"role_name": role,
},
}
_, err := ctx.CallAPI("PUT", fmt.Sprintf("/%s/%s/collaborators/change_role", ctx.Owner, ctx.Repo), payload)
return err
})
summary.Repository = fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo)
if err := ctx.OutputData(summary); err != nil {
return err
}
if summary.Failed > 0 {
return fmt.Errorf("%d of %d collaborator(s) failed to change role", summary.Failed, summary.Total)
}
return nil
}
// --- helpers ---
// collectUserRoles builds a deduplicated slice of userRole pairs from
// --users (combined with defaultRole) and/or --from CSV.
func collectUserRoles(usersValue, fromPath, defaultRole string) ([]userRole, error) {
var pairs []userRole
// From --users flag
for _, u := range common.ParseStringList(usersValue) {
pairs = append(pairs, userRole{User: u, Role: defaultRole})
}
// From CSV
if fromPath != "" {
rows, err := common.ReadRowsFromCSV(fromPath, []string{"user_id", "role"})
if err != nil {
return nil, err
}
for _, row := range rows {
role := row["role"]
if role == "" {
role = defaultRole
}
pairs = append(pairs, userRole{User: row["user_id"], Role: role})
}
}
// Deduplicate by user
seen := map[string]bool{}
deduped := make([]userRole, 0, len(pairs))
for _, p := range pairs {
if seen[p.User] {
continue
}
seen[p.User] = true
deduped = append(deduped, p)
}
return deduped, nil
}
// collectUsers builds a deduplicated slice of usernames from --users and/or --from CSV.
func collectUsers(usersValue, fromPath string) ([]string, error) {
users := common.ParseStringList(usersValue)
if fromPath != "" {
csvUsers, err := common.ReadColumnFromCSV(fromPath, []string{"user_id"})
if err != nil {
return nil, err
}
users = append(users, csvUsers...)
}
return common.DedupeStrings(users), nil
}
// roleForUser looks up the role assigned to a user in the pairs slice.
func roleForUser(pairs []userRole, user string) string {
for _, p := range pairs {
if p.User == user {
return p.Role
}
}
return "write"
}