diff --git a/doc/changes/member-shortcut.md b/doc/changes/member-shortcut.md new file mode 100644 index 0000000..b3278b0 --- /dev/null +++ b/doc/changes/member-shortcut.md @@ -0,0 +1,14 @@ +# Member Shortcut + +新增 `member` Shortcut 组,支持仓库成员管理和项目邀请链接操作: + +- `member +list` +- `member +add` +- `member +batch-add` +- `member +remove` +- `member +role` +- `member +invite-link` +- `member +invite-info` +- `member +accept-invite` + +同时补充了单元测试、README 示例和 `gitlink-member` Skill 说明。 diff --git a/shortcuts/member/member.go b/shortcuts/member/member.go new file mode 100644 index 0000000..3734da3 --- /dev/null +++ b/shortcuts/member/member.go @@ -0,0 +1,392 @@ +package member + +import ( + "encoding/csv" + "fmt" + "net/url" + "os" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +var roleAliases = map[string]string{ + "manager": "Manager", + "developer": "Developer", + "reporter": "Reporter", + "Manager": "Manager", + "Developer": "Developer", + "Reporter": "Reporter", +} + +// Shortcuts returns repository member management shortcuts. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List repository members", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + env, err := ctx.CallAPI("GET", collaboratorsPath(ctx), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "add", + Description: "Add a repository member by user ID", + Flags: []common.Flag{ + {Name: "user-id", Short: "u", Usage: "GitLink user ID to add", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userID, err := parseUserID(ctx.Arg("user-id")) + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", collaboratorsPath(ctx), map[string]interface{}{"user_id": userID}) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "batch-add", + Description: "Add multiple repository members by user IDs or a CSV file", + Flags: []common.Flag{ + {Name: "user-ids", Short: "u", Usage: "Comma-separated GitLink user IDs, for example: 101,102"}, + {Name: "from", Usage: "Read user IDs from a CSV file. Supports a user_id/id column or first column without header"}, + {Name: "dry-run", Usage: "Preview members that would be added without changing them", Bool: true, Default: "false"}, + }, + Run: runBatchAdd, + }, + { + Name: "remove", + Description: "Remove a repository member by user ID", + Flags: []common.Flag{ + {Name: "user-id", Short: "u", Usage: "GitLink user ID to remove", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userID, err := parseUserID(ctx.Arg("user-id")) + if err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", collaboratorsRemovePath(ctx), map[string]interface{}{"user_id": userID}) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "role", + Description: "Change a repository member role", + Flags: []common.Flag{ + {Name: "user-id", Short: "u", Usage: "GitLink user ID to update", Required: true}, + {Name: "role", Short: "r", Usage: "Member role: Manager, Developer, or Reporter", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userID, err := parseUserID(ctx.Arg("user-id")) + if err != nil { + return err + } + role, err := normalizeRole(ctx.Arg("role")) + if err != nil { + return err + } + env, err := ctx.CallAPI("PUT", collaboratorsRolePath(ctx), map[string]interface{}{ + "user_id": userID, + "role": role, + }) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "invite-link", + Description: "Get or create a repository invite link", + Flags: []common.Flag{ + {Name: "role", Short: "r", Usage: "Invite role: manager, developer, or reporter", Default: "developer"}, + {Name: "apply", Usage: "Whether joining by invite requires approval: true or false", Default: "true"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + role, err := normalizeInviteRole(ctx.Arg("role")) + if err != nil { + return err + } + apply, err := parseBoolArg("apply", ctx.Arg("apply")) + if err != nil { + return err + } + query := url.Values{} + query.Set("role", role) + query.Set("is_apply", strconv.FormatBool(apply)) + env, err := ctx.CallAPIWithQuery("GET", inviteLinkPath(ctx, "current_link"), query) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "invite-info", + Description: "Show repository invite link information", + Flags: []common.Flag{ + {Name: "sign", Short: "s", Usage: "Invite link sign", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sign, err := ctx.RequireArg("sign") + if err != nil { + return err + } + query := url.Values{} + query.Set("invite_sign", sign) + env, err := ctx.CallAPIWithQuery("GET", inviteLinkPath(ctx, "show_link"), query) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "accept-invite", + Description: "Accept a repository invite link", + Flags: []common.Flag{ + {Name: "sign", Short: "s", Usage: "Invite link sign", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + sign, err := ctx.RequireArg("sign") + if err != nil { + return err + } + query := url.Values{} + query.Set("invite_sign", sign) + env, err := ctx.CallAPIWithQuery("POST", inviteLinkPath(ctx, "redirect_link"), query) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +func runBatchAdd(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userIDs, err := collectUserIDs(ctx.Arg("user-ids"), ctx.Arg("from")) + if err != nil { + return err + } + if len(userIDs) == 0 { + return fmt.Errorf("provide --user-ids or --from") + } + if parseDryRun(ctx.Arg("dry-run")) { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "user_ids": userIDs, + "count": len(userIDs), + }) + } + + results := make([]map[string]interface{}, 0, len(userIDs)) + succeeded := 0 + failed := 0 + for _, userID := range userIDs { + env, err := ctx.CallAPI("POST", collaboratorsPath(ctx), map[string]interface{}{"user_id": userID}) + result := map[string]interface{}{"user_id": userID} + if err != nil { + result["ok"] = false + result["error"] = err.Error() + failed++ + } else { + result["ok"] = env.OK + result["data"] = env.Data + if env.OK { + succeeded++ + } else { + failed++ + } + } + results = append(results, result) + } + if err := ctx.OutputData(map[string]interface{}{ + "count": len(userIDs), + "succeeded": succeeded, + "failed": failed, + "results": results, + }); err != nil { + return err + } + if failed > 0 { + return fmt.Errorf("%d of %d member(s) failed to add", failed, len(userIDs)) + } + return nil +} + +func collaboratorsPath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/%s/%s/collaborators", ctx.Owner, ctx.Repo) +} + +func collaboratorsRemovePath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("%s/remove", collaboratorsPath(ctx)) +} + +func collaboratorsRolePath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("%s/change_role", collaboratorsPath(ctx)) +} + +func inviteLinkPath(ctx *common.RuntimeContext, action string) string { + return fmt.Sprintf("/%s/%s/project_invite_links/%s", ctx.Owner, ctx.Repo, action) +} + +func parseUserID(value string) (int, error) { + value = strings.TrimSpace(value) + userID, err := strconv.Atoi(value) + if err != nil || userID <= 0 { + return 0, fmt.Errorf("invalid user ID %q", value) + } + return userID, nil +} + +func normalizeRole(value string) (string, error) { + role, ok := roleAliases[strings.TrimSpace(value)] + if !ok { + return "", fmt.Errorf("invalid --role value %q: use Manager, Developer, or Reporter", value) + } + return role, nil +} + +func normalizeInviteRole(value string) (string, error) { + role, err := normalizeRole(value) + if err != nil { + return "", fmt.Errorf("invalid --role value %q: use manager, developer, or reporter", value) + } + return strings.ToLower(role), nil +} + +func parseBoolArg(name, value string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "true": + return true, nil + case "false": + return false, nil + default: + return false, fmt.Errorf("invalid --%s value %q: use true or false", name, value) + } +} + +func parseDryRun(value string) bool { + ok, _ := parseBoolArg("dry-run", value) + return ok && strings.TrimSpace(value) != "" +} + +func collectUserIDs(inline, csvPath string) ([]int, error) { + seen := map[int]bool{} + var ids []int + add := func(raw string) error { + if strings.TrimSpace(raw) == "" { + return nil + } + userID, err := parseUserID(raw) + if err != nil { + return err + } + if !seen[userID] { + seen[userID] = true + ids = append(ids, userID) + } + return nil + } + + for _, part := range strings.Split(inline, ",") { + if err := add(part); err != nil { + return nil, err + } + } + if csvPath != "" { + csvIDs, err := readUserIDsFromCSV(csvPath) + if err != nil { + return nil, err + } + for _, userID := range csvIDs { + if !seen[userID] { + seen[userID] = true + ids = append(ids, userID) + } + } + } + return ids, nil +} + +func readUserIDsFromCSV(path string) ([]int, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + rows, err := csv.NewReader(file).ReadAll() + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, nil + } + + column := 0 + start := 0 + if idx := userIDColumn(rows[0]); idx >= 0 { + column = idx + start = 1 + } + + var ids []int + for _, row := range rows[start:] { + if column >= len(row) { + continue + } + userID, err := parseUserID(row[column]) + if err != nil { + return nil, err + } + ids = append(ids, userID) + } + return ids, nil +} + +func userIDColumn(header []string) int { + for i, name := range header { + switch strings.ToLower(strings.TrimSpace(name)) { + case "user_id", "userid", "id": + return i + } + } + return -1 +} diff --git a/shortcuts/member/member_test.go b/shortcuts/member/member_test.go new file mode 100644 index 0000000..d379e82 --- /dev/null +++ b/shortcuts/member/member_test.go @@ -0,0 +1,286 @@ +package member + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestMemberList(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/collaborators.json") + writeJSON(t, w, map[string]interface{}{"total_count": 1, "members": []interface{}{}}) + }) + defer server.Close() + + if err := runMemberShortcut(t, server, "list", nil); err != nil { + t.Fatalf("list shortcut failed: %v", err) + } +} + +func TestMemberAdd(t *testing.T) { + var payload map[string]interface{} + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/collaborators.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + if err := runMemberShortcut(t, server, "add", map[string]string{"user-id": "101"}); err != nil { + t.Fatalf("add shortcut failed: %v", err) + } + assertNumber(t, payload["user_id"], 101) +} + +func TestMemberBatchAdd(t *testing.T) { + var seen []int + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/collaborators.json") + payload := decodeJSON(t, r) + seen = append(seen, int(payload["user_id"].(float64))) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + csvPath := writeTempCSV(t, "user_id\n102\n103\n") + err := runMemberShortcut(t, server, "batch-add", map[string]string{ + "user-ids": "101,102", + "from": csvPath, + }) + if err != nil { + t.Fatalf("batch-add shortcut failed: %v", err) + } + want := []int{101, 102, 103} + if !reflect.DeepEqual(seen, want) { + t.Fatalf("batch-add user IDs = %v, want %v", seen, want) + } +} + +func TestMemberBatchAddDryRunDoesNotCallAPI(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("dry-run should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "batch-add", map[string]string{ + "user-ids": "101,102", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("batch-add dry-run failed: %v", err) + } +} + +func TestMemberBatchAddReturnsErrorWhenAnyRequestFails(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/collaborators.json") + payload := decodeJSON(t, r) + if int(payload["user_id"].(float64)) == 102 { + http.Error(w, "member add failed", http.StatusBadRequest) + return + } + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "batch-add", map[string]string{ + "user-ids": "101,102", + }) + if err == nil { + t.Fatal("expected batch-add to return an error when one request fails") + } +} + +func TestMemberRemove(t *testing.T) { + var payload map[string]interface{} + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "DELETE", "/owner/repo/collaborators/remove.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + if err := runMemberShortcut(t, server, "remove", map[string]string{"user-id": "101"}); err != nil { + t.Fatalf("remove shortcut failed: %v", err) + } + assertNumber(t, payload["user_id"], 101) +} + +func TestMemberRole(t *testing.T) { + var payload map[string]interface{} + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "PUT", "/owner/repo/collaborators/change_role.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "role", map[string]string{ + "user-id": "101", + "role": "developer", + }) + if err != nil { + t.Fatalf("role shortcut failed: %v", err) + } + assertNumber(t, payload["user_id"], 101) + if payload["role"] != "Developer" { + t.Fatalf("role = %v, want Developer", payload["role"]) + } +} + +func TestMemberInviteLink(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/project_invite_links/current_link.json") + if r.URL.Query().Get("role") != "developer" { + t.Fatalf("role query = %q, want developer", r.URL.Query().Get("role")) + } + if r.URL.Query().Get("is_apply") != "false" { + t.Fatalf("is_apply query = %q, want false", r.URL.Query().Get("is_apply")) + } + writeJSON(t, w, map[string]interface{}{"sign": "abc"}) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "invite-link", map[string]string{ + "role": "developer", + "apply": "false", + }) + if err != nil { + t.Fatalf("invite-link shortcut failed: %v", err) + } +} + +func TestMemberInviteInfo(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/owner/repo/project_invite_links/show_link.json") + if r.URL.Query().Get("invite_sign") != "abc" { + t.Fatalf("invite_sign query = %q, want abc", r.URL.Query().Get("invite_sign")) + } + writeJSON(t, w, map[string]interface{}{"sign": "abc"}) + }) + defer server.Close() + + if err := runMemberShortcut(t, server, "invite-info", map[string]string{"sign": "abc"}); err != nil { + t.Fatalf("invite-info shortcut failed: %v", err) + } +} + +func TestMemberAcceptInvite(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/project_invite_links/redirect_link.json") + if r.URL.Query().Get("invite_sign") != "abc" { + t.Fatalf("invite_sign query = %q, want abc", r.URL.Query().Get("invite_sign")) + } + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + }) + defer server.Close() + + if err := runMemberShortcut(t, server, "accept-invite", map[string]string{"sign": "abc"}); err != nil { + t.Fatalf("accept-invite shortcut failed: %v", err) + } +} + +func TestCollectUserIDs(t *testing.T) { + csvPath := writeTempCSV(t, "name,id\nfirst,102\nsecond,103\n") + got, err := collectUserIDs("101,102", csvPath) + if err != nil { + t.Fatalf("collectUserIDs returned error: %v", err) + } + want := []int{101, 102, 103} + if !reflect.DeepEqual(got, want) { + t.Fatalf("collectUserIDs() = %v, want %v", got, want) + } +} + +func TestNormalizeRoleRejectsInvalidRole(t *testing.T) { + if _, err := normalizeRole("owner"); err == nil { + t.Fatal("expected invalid role to return an error") + } +} + +func runMemberShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findMemberShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return shortcut.Run(ctx) +} + +func findMemberShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func newMemberTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func assertRequest(t *testing.T, r *http.Request, method, path string) { + t.Helper() + if r.Method != method || r.URL.Path != path { + t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) + } +} + +func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} + +func assertNumber(t *testing.T, got interface{}, want int) { + t.Helper() + value, ok := got.(float64) + if !ok { + t.Fatalf("got %v (%T), want JSON number", got, got) + } + if int(value) != want { + t.Fatalf("got %v, want %d", got, want) + } +} + +func writeTempCSV(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "members.csv") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write temp csv: %v", err) + } + return path +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 943b6dd..91cf569 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -7,6 +7,7 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" + "github.com/gitlink-org/gitlink-cli/shortcuts/member" "github.com/gitlink-org/gitlink-cli/shortcuts/org" "github.com/gitlink-org/gitlink-cli/shortcuts/pr" "github.com/gitlink-org/gitlink-cli/shortcuts/release" @@ -22,6 +23,7 @@ func RegisterAll(root *cobra.Command) { groups := map[string][]*common.Shortcut{ "repo": repo.Shortcuts(), "issue": issue.Shortcuts(), + "member": member.Shortcuts(), "pr": pr.Shortcuts(), "release": release.Shortcuts(), "branch": branch.Shortcuts(), @@ -36,6 +38,7 @@ func RegisterAll(root *cobra.Command) { descriptions := map[string]string{ "repo": "Repository operations", "issue": "Issue operations", + "member": "Repository member operations", "pr": "Pull request operations", "release": "Release operations", "branch": "Branch operations", diff --git a/skills/gitlink-member/SKILL.md b/skills/gitlink-member/SKILL.md new file mode 100644 index 0000000..61f8c50 --- /dev/null +++ b/skills/gitlink-member/SKILL.md @@ -0,0 +1,57 @@ +--- +name: gitlink-member +description: "仓库成员管理:列出、添加、批量添加、移除成员,调整成员角色,生成、查看和接受项目邀请链接。" +metadata: + cliHelp: "gitlink-cli member --help" +--- + +# gitlink-member(仓库成员管理) + +当用户需要管理 GitLink 仓库成员、成员角色或邀请链接时使用本 Skill。 + +## 常用命令 + +| 命令 | 用途 | +|------|------| +| `member +list` | 列出仓库成员 | +| `member +add` | 通过用户 ID 添加仓库成员 | +| `member +batch-add` | 通过用户 ID 列表或 CSV 批量添加成员 | +| `member +remove` | 通过用户 ID 移除仓库成员 | +| `member +role` | 修改成员角色 | +| `member +invite-link` | 获取或生成当前邀请链接 | +| `member +invite-info` | 查看邀请链接信息 | +| `member +accept-invite` | 接受邀请链接 | + +## 示例 + +```bash +# 列出仓库成员 +gitlink-cli member +list --owner Gitlink --repo forgeplus + +# 添加成员 +gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101 + +# 批量添加前预览 +gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run + +# 从 CSV 批量添加。CSV 支持 user_id、userid、id 列;无表头时读取第一列。 +gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv + +# 修改角色。角色支持 Manager、Developer、Reporter,也支持小写别名。 +gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer + +# 获取或生成当前邀请链接。role 支持 manager、developer、reporter;apply 表示是否需要审核。 +gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true + +# 查看邀请链接信息 +gitlink-cli member +invite-info --owner Gitlink --repo forgeplus --sign + +# 接受邀请链接 +gitlink-cli member +accept-invite --owner Gitlink --repo forgeplus --sign +``` + +## 安全规则 + +- 执行 `member +remove`、`member +role`、`member +add`、`member +batch-add` 前,确认目标仓库和用户 ID。 +- 批量添加前优先使用 `--dry-run` 预览。 +- 避免在公开日志中暴露邀请链接的完整 `sign`。