feat(member): add repository member shortcuts (#46)
Add member command group with 8 shortcuts: - +list, +add, +batch-add, +remove, +role - +invite-link, +invite-info, +accept-invite Batch add supports --dry-run, CSV file input, and partial failure reporting. 12 tests included. Co-authored-by: Jiachen Li (puygob236) <noreply@gitlink.org.cn> Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
f888dd39ed
commit
7bcba5aed8
|
|
@ -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 说明。
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 <invite_sign>
|
||||
|
||||
# 接受邀请链接
|
||||
gitlink-cli member +accept-invite --owner Gitlink --repo forgeplus --sign <invite_sign>
|
||||
```
|
||||
|
||||
## 安全规则
|
||||
|
||||
- 执行 `member +remove`、`member +role`、`member +add`、`member +batch-add` 前,确认目标仓库和用户 ID。
|
||||
- 批量添加前优先使用 `--dry-run` 预览。
|
||||
- 避免在公开日志中暴露邀请链接的完整 `sign`。
|
||||
Loading…
Reference in New Issue