feat(member): add repository member shortcuts

This commit is contained in:
Jiachen Li 2026-05-25 13:42:41 +08:00
parent a46e06b78a
commit 1feb6a85fc
8 changed files with 778 additions and 9 deletions

View File

@ -5,7 +5,7 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, CI/CD, and AI-powered workflows, with 40+ commands and 13 AI Agent [Skills](./skills/).
The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, webhooks, member collaboration, CI/CD, and AI-powered workflows, with 40+ commands and AI Agent [Skills](./skills/).
**[中文文档](./README.zh-CN.md)**
@ -27,8 +27,8 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
## Why gitlink-cli?
- **Agent-Native Design**13 structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
- **Wide Coverage** — Repository, Issue, PR, Webhook, Branch, Release, CI, Org, Search, User — all core domains covered
- **Agent-Native Design**Structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup
- **Wide Coverage** — Repository, Issue, PR, Webhook, Member, Branch, Release, CI, Org, Search, and User workflows are covered by high-level commands
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output
- **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded
- **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install`
@ -43,6 +43,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| 📦 Repo | List, create, fork, delete repositories, view repo info |
| 🐛 Issue | Create, update, close, batch close, comment on issues |
| 🔀 PR | Create, merge, review pull requests, view changed files |
| 👥 Member | List, add, remove repository members, change roles, create and accept invite links |
| 🌿 Branch | Create, delete, list, protect, unprotect branches |
| 🏷️ Release | Create, view, delete releases |
| 🏢 Org | Manage organizations, members, teams |
@ -70,7 +71,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
**From npm (recommended):**
```bash
# One command: installs CLI binary + all 13 AI Agent Skills
# One command: installs CLI binary + AI Agent Skills
npm install -g @gitlink-ai/cli
```
@ -175,6 +176,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
### Member Management
```bash
# List repository members
gitlink-cli member +list --owner Gitlink --repo forgeplus
# Add a member
gitlink-cli member +add --owner Gitlink --repo forgeplus --user-id 101
# Preview batch add without changing data
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --user-ids 101,102 --dry-run
# Batch add members from a CSV file
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv
# Change a member role
gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer
# Create an invite link
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
```
### Issue Management
```bash
@ -338,7 +361,7 @@ git push gitlink
## AI Agent Skills
The `skills/` directory contains 12 Agent Skill files for AI-automated GitLink operations.
The `skills/` directory contains Agent Skill files for AI-automated GitLink operations.
See [skills/README.md](skills/README.md) for details.
@ -348,6 +371,7 @@ See [skills/README.md](skills/README.md) for details.
| `gitlink-repo` | Repository operations (create, view, delete, fork, etc.) |
| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) |
| `gitlink-pr` | Pull request operations (create, merge, review, etc.) |
| `gitlink-member` | Repository member and invite link management |
| `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) |
| `gitlink-release` | Release management (create, view, delete, etc.) |
| `gitlink-ci` | CI/CD operations (builds, logs, etc.) |
@ -378,6 +402,7 @@ gitlink-cli/
│ ├── repo/ # Repository shortcuts
│ ├── issue/ # Issue shortcuts
│ ├── pr/ # PR shortcuts
│ ├── member/ # Repository member shortcuts
│ ├── branch/ # Branch shortcuts
│ ├── release/ # Release shortcuts
│ ├── org/ # Organization shortcuts

View File

@ -5,7 +5,7 @@
[![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org)
[![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli)
[GitLink确实开源](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**覆盖仓库管理、Issue 追踪、Pull Request、Webhook、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 13 个 AI Agent [Skills](./skills/)。
[GitLink确实开源](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**覆盖仓库管理、Issue 追踪、Pull Request、Webhook、成员协作、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 AI Agent [Skills](./skills/)。
**[English](./README.md)**
@ -27,8 +27,8 @@
## 为什么选择 gitlink-cli
- **Agent-Native 设计** — 开箱即用 13 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
- **广泛覆盖** — 仓库、Issue、PR、Webhook、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖
- **Agent-Native 设计** — 开箱即用结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink
- **广泛覆盖** — 仓库、Issue、PR、Webhook、成员、分支、Release、CI、组织、搜索、用户等常用工作流均提供高层命令
- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出
- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装
- **开源零门槛** — 木兰宽松许可证第2版MulanPSL-2.0`npm install` 即用
@ -43,6 +43,7 @@
| 📦 仓库 | 列出、创建、Fork、删除仓库查看仓库信息 |
| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue |
| 🔀 PR | 创建、合并、Review Pull Request查看变更文件 |
| 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 |
| 🌿 分支 | 创建、删除、保护分支 |
| 🏷️ 发布 | 创建、查看、删除 Release |
| 🏢 组织 | 管理组织、成员、团队 |
@ -186,6 +187,28 @@ gitlink-cli webhook +test --owner Gitlink --repo forgeplus --id 68
gitlink-cli webhook +tasks --owner Gitlink --repo forgeplus --id 68
```
### 成员管理
```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 文件批量添加成员
gitlink-cli member +batch-add --owner Gitlink --repo forgeplus --from members.csv
# 调整成员权限
gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role Developer
# 生成邀请链接
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
```
### Issue 管理
```bash
@ -317,7 +340,7 @@ git push gitlink
## AI Agent Skills
`skills/` 目录包含 13 个 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
`skills/` 目录包含 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。
详见 [skills/README.md](skills/README.md)
@ -327,6 +350,7 @@ git push gitlink
| `gitlink-repo` | 仓库操作创建、查看、删除、Fork 等) |
| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) |
| `gitlink-pr` | Pull Request 操作创建、合并、Review 等) |
| `gitlink-member` | 仓库成员与邀请链接管理 |
| `gitlink-release` | 发布管理(创建、查看、删除等) |
| `gitlink-org` | 组织管理(成员、团队等) |
| `gitlink-ci` | CI/CD 操作(构建、日志等) |
@ -356,6 +380,7 @@ gitlink-cli/
│ ├── repo/ # 仓库 shortcuts
│ ├── issue/ # Issue shortcuts
│ ├── pr/ # PR shortcuts
│ ├── member/ # 仓库成员 shortcuts
│ ├── branch/ # 分支 shortcuts
│ ├── release/ # Release shortcuts
│ ├── org/ # 组织 shortcuts

View File

@ -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 说明。

376
shortcuts/member/member.go Normal file
View File

@ -0,0 +1,376 @@
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))
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()
} else {
result["ok"] = env.OK
result["data"] = env.Data
}
results = append(results, result)
}
return ctx.OutputData(map[string]interface{}{
"count": len(userIDs),
"results": results,
})
}
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
}

View File

@ -0,0 +1,266 @@
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 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
}

View File

@ -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"
@ -21,6 +22,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(),
@ -34,6 +36,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",

View File

@ -83,6 +83,8 @@ skills/
│ ├── REFERENCE.md # PR API 参考
│ └── examples/
│ └── pr-workflow.md # PR 工作流
├── gitlink-member/ # 仓库成员管理
│ └── SKILL.md # 成员与邀请链接操作指南
├── gitlink-branch/ # 分支管理
│ ├── SKILL.md # 分支操作指南
│ └── examples/
@ -124,6 +126,7 @@ skills/
| **gitlink-repo** | 仓库管理 | `repo +list`, `repo +create`, `repo +info`, `repo +fork` |
| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close` |
| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +versions`, `pr +version-diff`, `pr +reviews`, `pr +review` |
| **gitlink-member** | 仓库成员管理 | `member +list`, `member +add`, `member +batch-add`, `member +role`, `member +invite-link` |
| **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` |
| **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +view` |

View File

@ -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、reporterapply 表示是否需要审核。
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`