feat(invite): 添加项目邀请管理模块

- 新增 invite shortcuts: generate, show, accept, join, quit
- 添加中英文 i18n 翻译
- 添加单元测试
- 添加 gitlink-invite SKILL.md 文档
- 更新 README.md 和 README.zh-CN.md
- 更新 skills/README.md
This commit is contained in:
weidongde 2026-06-29 15:11:17 +08:00
parent 982f2cb336
commit 805c7eba5e
9 changed files with 768 additions and 0 deletions

View File

@ -108,6 +108,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
| 🔖 Label | Create, list, update, delete issue labels |
| 🔀 PR | Create, merge, review pull requests, view changed files |
| 👥 Member | List, add, remove repository members, change roles, create and accept invite links |
| 📨 Invite | Generate invite links, view invite info, accept invites, join/quit projects |
| 🌿 Branch | Create, delete, list, protect, unprotect branches |
| 🏷️ Release | Create, edit, update, view, delete releases |
| 🏢 Org | Manage organizations, members, teams |
@ -485,6 +486,25 @@ gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
```
### Project Invite Management
```bash
# Generate an invite link
gitlink-cli invite +generate --owner Gitlink --repo forgeplus --role developer --is-apply true
# View invite link information
gitlink-cli invite +show --owner Gitlink --repo forgeplus --invite-sign abc123
# Accept an invite via link
gitlink-cli invite +accept --owner Gitlink --repo forgeplus --invite-sign abc123
# Join a project by invite code
gitlink-cli invite +join --code ABCDEF --role developer
# Quit a project
gitlink-cli invite +quit --owner Gitlink --repo forgeplus
```
### Ignore File Templates
```bash
@ -718,6 +738,7 @@ See [skills/README.md](./skills/README.md) for details.
| `gitlink-issue` | Issue operations (create, update, close, batch update/delete, comment, etc.) |
| `gitlink-pr` | Pull request operations (create, merge, review, etc.) |
| `gitlink-member` | Repository member and invite link management |
| `gitlink-invite` | Project invite management (generate links, accept invites, join/quit projects) |
| `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) |
| `gitlink-release` | Release management (create, edit, update, view, delete, etc.) |
| `gitlink-ci` | CI/CD operations (builds, logs, etc.) |

View File

@ -108,6 +108,7 @@
| 🔖 标签 | 创建、列出、更新、删除 Issue 标签 |
| 🔀 PR | 创建、合并、Review Pull Request查看变更文件 |
| 👥 成员 | 列出、添加、移除仓库成员,调整角色,生成和接受邀请链接 |
| 📨 邀请 | 生成邀请链接、查看邀请信息、接受邀请、加入/退出项目 |
| 🌿 分支 | 创建、删除、保护分支 |
| 🏷️ 发布 | 创建、编辑、更新、查看、删除 Release |
| 🏢 组织 | 管理组织、成员、团队 |
@ -463,6 +464,25 @@ gitlink-cli pipeline +disable --owner Gitlink --repo forgeplus --id 7 --workflow
gitlink-cli pipeline +delete --owner Gitlink --repo forgeplus --id 7 --dry-run
```
### 项目邀请管理
```bash
# 生成邀请链接
gitlink-cli invite +generate --owner Gitlink --repo forgeplus --role developer --is-apply true
# 查看邀请链接信息
gitlink-cli invite +show --owner Gitlink --repo forgeplus --invite-sign abc123
# 通过链接接受邀请
gitlink-cli invite +accept --owner Gitlink --repo forgeplus --invite-sign abc123
# 通过邀请码加入项目
gitlink-cli invite +join --code ABCDEF --role developer
# 退出项目
gitlink-cli invite +quit --owner Gitlink --repo forgeplus
```
### 忽略文件模板
```bash
@ -592,6 +612,7 @@ git push gitlink
| `gitlink-issue` | Issue 操作(创建、更新、关闭、批量更新/删除、评论等) |
| `gitlink-pr` | Pull Request 操作创建、合并、Review 等) |
| `gitlink-member` | 仓库成员与邀请链接管理 |
| `gitlink-invite` | 项目邀请管理(生成链接、接受邀请、加入/退出项目) |
| `gitlink-release` | 发布管理(创建、编辑、更新、查看、删除等) |
| `gitlink-org` | 组织管理(成员、团队等) |
| `gitlink-ci` | CI/CD 操作(构建、日志等) |

View File

@ -44,6 +44,17 @@
"cmd.issue.comment.short": "Add a comment to an issue",
"cmd.issue.create.short": "Create a new issue",
"cmd.issue.list.short": "List issues",
"cmd.invite.accept.long": "Accept a project invite via invite link",
"cmd.invite.accept.short": "Accept an invite",
"cmd.invite.generate.long": "Generate or retrieve the project invite link",
"cmd.invite.generate.short": "Generate an invite link",
"cmd.invite.join.long": "Join a project by invite code",
"cmd.invite.join.short": "Join a project",
"cmd.invite.quit.long": "Quit from the current project",
"cmd.invite.quit.short": "Quit a project",
"cmd.invite.short": "Project invite operations",
"cmd.invite.show.long": "Show invite link details by invite sign",
"cmd.invite.show.short": "Show invite link info",
"cmd.issue.short": "Issue operations",
"cmd.issue.update.short": "Update an issue",
"cmd.issue.view.short": "View issue details",
@ -146,6 +157,10 @@
"flag.doctor.skip_network": "Skip authenticated API connectivity checks",
"flag.dry_run": "Preview the request without creating it",
"flag.format": "Output format: json, table, yaml (default: table)",
"flag.invite.code": "Project invite code",
"flag.invite.is-apply": "Whether it is apply mode (true/false)",
"flag.invite.role": "Invite role (e.g. developer, manager)",
"flag.invite.sign": "Invite sign/invite code",
"flag.issue.add_label": "Label to add to each matching issue",
"flag.issue.assignee": "Assignee login",
"flag.issue.assignee_id": "Assignee user ID",

View File

@ -44,6 +44,17 @@
"cmd.issue.comment.short": "给议题添加评论",
"cmd.issue.create.short": "创建新议题",
"cmd.issue.list.short": "列出议题",
"cmd.invite.accept.long": "通过邀请链接接受项目邀请",
"cmd.invite.accept.short": "接受邀请",
"cmd.invite.generate.long": "生成或获取项目邀请链接",
"cmd.invite.generate.short": "生成邀请链接",
"cmd.invite.join.long": "通过邀请码加入项目",
"cmd.invite.join.short": "加入项目",
"cmd.invite.quit.long": "退出当前项目",
"cmd.invite.quit.short": "退出项目",
"cmd.invite.short": "项目邀请操作",
"cmd.invite.show.long": "通过邀请签名查看邀请链接详情",
"cmd.invite.show.short": "查看邀请链接信息",
"cmd.issue.short": "议题操作",
"cmd.issue.update.short": "更新议题",
"cmd.issue.view.short": "查看议题详情",
@ -146,6 +157,10 @@
"flag.doctor.skip_network": "跳过需要访问 GitLink 的认证连通性检查",
"flag.dry_run": "预览请求,不实际创建",
"flag.format": "输出格式json、table、yaml默认table",
"flag.invite.code": "项目邀请码",
"flag.invite.is-apply": "是否为申请模式true/false",
"flag.invite.role": "邀请角色(如 developer、manager",
"flag.invite.sign": "邀请签名/邀请码",
"flag.issue.add_label": "要添加到每个匹配议题的标签",
"flag.issue.assignee": "负责人登录名",
"flag.issue.assignee_id": "负责人用户 ID",

179
shortcuts/invite/invite.go Normal file
View File

@ -0,0 +1,179 @@
package invite
import (
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/internal/i18n"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
// Shortcuts returns all invite-related shortcuts.
func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
tr := i18n.Default()
if len(translators) > 0 && translators[0] != nil {
tr = translators[0]
}
return []*common.Shortcut{
{
Name: "generate",
Description: tr.T("cmd.invite.generate.short"),
Long: tr.T("cmd.invite.generate.long"),
Flags: []common.Flag{
{Name: "role", Short: "r", Usage: tr.T("flag.invite.role"), Default: "developer"},
{Name: "is-apply", Usage: tr.T("flag.invite.is-apply"), Default: "true"},
},
Run: runGenerateInviteLink,
},
{
Name: "show",
Description: tr.T("cmd.invite.show.short"),
Long: tr.T("cmd.invite.show.long"),
Flags: []common.Flag{
{Name: "invite-sign", Short: "s", Usage: tr.T("flag.invite.sign"), Required: true},
},
Run: runShowInviteLink,
},
{
Name: "accept",
Description: tr.T("cmd.invite.accept.short"),
Long: tr.T("cmd.invite.accept.long"),
Flags: []common.Flag{
{Name: "invite-sign", Short: "s", Usage: tr.T("flag.invite.sign"), Required: true},
},
Run: runAcceptInvite,
},
{
Name: "join",
Description: tr.T("cmd.invite.join.short"),
Long: tr.T("cmd.invite.join.long"),
Flags: []common.Flag{
{Name: "code", Short: "c", Usage: tr.T("flag.invite.code"), Required: true},
{Name: "role", Short: "r", Usage: tr.T("flag.invite.role"), Default: "developer"},
},
Run: runJoinProject,
},
{
Name: "quit",
Description: tr.T("cmd.invite.quit.short"),
Long: tr.T("cmd.invite.quit.long"),
Run: runQuitProject,
},
}
}
// runGenerateInviteLink generates or retrieves the project invite link.
// GET /api/{owner}/{repo}/project_invite_links/current_link.json
func runGenerateInviteLink(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
role := ctx.Arg("role")
if role == "" {
role = "developer"
}
isApply := ctx.Arg("is-apply")
if isApply == "" {
isApply = "true"
}
q := url.Values{}
q.Set("role", role)
q.Set("is_apply", isApply)
path := fmt.Sprintf("/%s/%s/project_invite_links/current_link.json", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return err
}
return ctx.Output(env)
}
// runShowInviteLink shows the invite link information.
// GET /api/{owner}/{repo}/project_invite_links/show_link.json
func runShowInviteLink(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
inviteSign, err := ctx.RequireArg("invite-sign")
if err != nil {
return err
}
q := url.Values{}
q.Set("invite_sign", inviteSign)
path := fmt.Sprintf("/%s/%s/project_invite_links/show_link.json", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPIWithQuery("GET", path, q)
if err != nil {
return err
}
return ctx.Output(env)
}
// runAcceptInvite accepts an invite via the invite link.
// POST /api/{owner}/{repo}/project_invite_links/redirect_link.json
func runAcceptInvite(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
inviteSign, err := ctx.RequireArg("invite-sign")
if err != nil {
return err
}
q := url.Values{}
q.Set("invite_sign", inviteSign)
path := fmt.Sprintf("/%s/%s/project_invite_links/redirect_link.json", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPIWithQuery("POST", path, q)
if err != nil {
return err
}
return ctx.Output(env)
}
// runJoinProject joins a project by code.
// POST /api/applied_projects.json
func runJoinProject(ctx *common.RuntimeContext) error {
code, err := ctx.RequireArg("code")
if err != nil {
return err
}
role := ctx.Arg("role")
if role == "" {
role = "developer"
}
body := map[string]interface{}{
"applied_project": map[string]interface{}{
"code": code,
"role": role,
},
}
env, err := ctx.CallAPI("POST", "/applied_projects.json", body)
if err != nil {
return err
}
return ctx.Output(env)
}
// runQuitProject quits from a project.
// POST /api/{owner}/{repo}/quit.json
func runQuitProject(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
path := fmt.Sprintf("/%s/%s/quit.json", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPI("POST", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
}

View File

@ -0,0 +1,380 @@
package invite
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
s := findShortcut(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 s.Run(ctx)
}
func findShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, s := range Shortcuts() {
if s.Name == name {
return s
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
t.Fatalf("write response: %v", err)
}
}
// --- generate ---
func TestInviteGenerateDefault(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("expected GET, got %s", r.Method)
}
if r.URL.Path != "/owner/repo/project_invite_links/current_link.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("role") != "developer" {
t.Fatalf("expected role=developer, got %s", r.URL.Query().Get("role"))
}
if r.URL.Query().Get("is_apply") != "true" {
t.Fatalf("expected is_apply=true, got %s", r.URL.Query().Get("is_apply"))
}
writeJSON(t, w, map[string]interface{}{
"invite_sign": "abc123",
"role": "developer",
})
}))
defer server.Close()
err := runShortcut(t, server, "generate", nil)
if err != nil {
t.Fatalf("generate failed: %v", err)
}
}
func TestInviteGenerateWithRole(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("role") != "manager" {
t.Fatalf("expected role=manager, got %s", r.URL.Query().Get("role"))
}
writeJSON(t, w, map[string]interface{}{
"invite_sign": "xyz789",
"role": "manager",
})
}))
defer server.Close()
err := runShortcut(t, server, "generate", map[string]string{"role": "manager"})
if err != nil {
t.Fatalf("generate with role failed: %v", err)
}
}
func TestInviteGenerateWithIsApply(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("is_apply") != "false" {
t.Fatalf("expected is_apply=false, got %s", r.URL.Query().Get("is_apply"))
}
writeJSON(t, w, map[string]interface{}{
"invite_sign": "test456",
"role": "developer",
})
}))
defer server.Close()
err := runShortcut(t, server, "generate", map[string]string{"is-apply": "false"})
if err != nil {
t.Fatalf("generate with is-apply failed: %v", err)
}
}
// --- show ---
func TestInviteShow(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("expected GET, got %s", r.Method)
}
if r.URL.Path != "/owner/repo/project_invite_links/show_link.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("invite_sign") != "abc123" {
t.Fatalf("expected invite_sign=abc123, got %s", r.URL.Query().Get("invite_sign"))
}
writeJSON(t, w, map[string]interface{}{
"invite_sign": "abc123",
"project": "owner/repo",
"role": "developer",
})
}))
defer server.Close()
err := runShortcut(t, server, "show", map[string]string{"invite-sign": "abc123"})
if err != nil {
t.Fatalf("show failed: %v", err)
}
}
func TestInviteShowMissingSign(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call should be made without invite-sign")
}))
defer server.Close()
err := runShortcut(t, server, "show", nil)
if err == nil {
t.Fatal("expected error for missing invite-sign")
}
}
// --- accept ---
func TestInviteAccept(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/owner/repo/project_invite_links/redirect_link.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if r.URL.Query().Get("invite_sign") != "abc123" {
t.Fatalf("expected invite_sign=abc123, got %s", r.URL.Query().Get("invite_sign"))
}
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
}))
defer server.Close()
err := runShortcut(t, server, "accept", map[string]string{"invite-sign": "abc123"})
if err != nil {
t.Fatalf("accept failed: %v", err)
}
}
func TestInviteAcceptMissingSign(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call should be made without invite-sign")
}))
defer server.Close()
err := runShortcut(t, server, "accept", nil)
if err == nil {
t.Fatal("expected error for missing invite-sign")
}
}
// --- join ---
func TestInviteJoin(t *testing.T) {
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/applied_projects.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
}))
defer server.Close()
err := runShortcut(t, server, "join", map[string]string{"code": "PROJECT123"})
if err != nil {
t.Fatalf("join failed: %v", err)
}
appliedProject, ok := body["applied_project"].(map[string]interface{})
if !ok {
t.Fatal("expected applied_project in body")
}
if appliedProject["code"] != "PROJECT123" {
t.Fatalf("expected code=PROJECT123, got %v", appliedProject["code"])
}
if appliedProject["role"] != "developer" {
t.Fatalf("expected role=developer, got %v", appliedProject["role"])
}
}
func TestInviteJoinWithRole(t *testing.T) {
var body map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeJSON(t, w, map[string]interface{}{"status": 0})
}))
defer server.Close()
err := runShortcut(t, server, "join", map[string]string{"code": "PROJECT123", "role": "manager"})
if err != nil {
t.Fatalf("join with role failed: %v", err)
}
appliedProject, ok := body["applied_project"].(map[string]interface{})
if !ok {
t.Fatal("expected applied_project in body")
}
if appliedProject["role"] != "manager" {
t.Fatalf("expected role=manager, got %v", appliedProject["role"])
}
}
func TestInviteJoinMissingCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("no API call should be made without code")
}))
defer server.Close()
err := runShortcut(t, server, "join", nil)
if err == nil {
t.Fatal("expected error for missing code")
}
}
// --- quit ---
func TestInviteQuit(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/owner/repo/quit.json" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
}))
defer server.Close()
err := runShortcut(t, server, "quit", nil)
if err != nil {
t.Fatalf("quit failed: %v", err)
}
}
// --- HTTP error handling ---
func TestInviteGenerateHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runShortcut(t, server, "generate", nil)
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestInviteShowHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runShortcut(t, server, "show", map[string]string{"invite-sign": "abc123"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestInviteAcceptHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runShortcut(t, server, "accept", map[string]string{"invite-sign": "abc123"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestInviteJoinHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runShortcut(t, server, "join", map[string]string{"code": "PROJECT123"})
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
func TestInviteQuitHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server error"))
}))
defer server.Close()
err := runShortcut(t, server, "quit", nil)
if err == nil {
t.Fatal("expected error for HTTP 500")
}
}
// --- shortcut registration ---
func TestInviteShortcutsRegistered(t *testing.T) {
shortcuts := Shortcuts()
names := make(map[string]bool)
for _, s := range shortcuts {
names[s.Name] = true
}
expected := []string{"generate", "show", "accept", "join", "quit"}
for _, name := range expected {
if !names[name] {
t.Fatalf("shortcut %q not registered", name)
}
}
}
func TestInviteShortcutsHaveDescription(t *testing.T) {
for _, s := range Shortcuts() {
if s.Description == "" {
t.Fatalf("shortcut %q has no description", s.Name)
}
}
}

View File

@ -11,6 +11,7 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/dataset"
"github.com/gitlink-org/gitlink-cli/shortcuts/health"
"github.com/gitlink-org/gitlink-cli/shortcuts/ignore"
"github.com/gitlink-org/gitlink-cli/shortcuts/invite"
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
"github.com/gitlink-org/gitlink-cli/shortcuts/label"
"github.com/gitlink-org/gitlink-cli/shortcuts/license"
@ -55,6 +56,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"webhook": webhook.Shortcuts(tr),
"health": health.Shortcuts(tr),
"ignore": ignore.Shortcuts(),
"invite": invite.Shortcuts(tr),
"workflow": workflow.Shortcuts(),
}
@ -79,6 +81,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
"webhook": tr.T("cmd.webhook.short"),
"health": "Project health data collection",
"ignore": tr.T("cmd.ignore.short"),
"invite": tr.T("cmd.invite.short"),
"workflow": "AI agent workflow analysis",
}

View File

@ -85,6 +85,8 @@ skills/
│ └── pr-workflow.md # PR 工作流
├── gitlink-member/ # 仓库成员管理
│ └── SKILL.md # 成员与邀请链接操作指南
├── gitlink-invite/ # 项目邀请管理
│ └── SKILL.md # 邀请链接、加入/退出项目操作指南
├── gitlink-branch/ # 分支管理
│ ├── SKILL.md # 分支操作指南
│ └── examples/
@ -138,6 +140,7 @@ skills/
| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close`, `issue +batch-update`, `issue +batch-delete` |
| **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-invite** | 项目邀请管理 | `invite +generate`, `invite +show`, `invite +accept`, `invite +join`, `invite +quit` |
| **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` |
| **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +edit`, `release +update`, `release +view` |

View File

@ -0,0 +1,131 @@
---
name: gitlink-invite
version: 1.0.0
description: "项目邀请管理:生成邀请链接、查看链接信息、接受邀请、加入项目、退出项目。"
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli invite --help"
---
# gitlink-invite项目邀请管理
当用户需要管理 GitLink 项目邀请、通过邀请链接加入项目或退出项目时使用本 Skill。
**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。**
**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。**
**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`GitHub CLI操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。**
> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。
## Shortcuts
| Shortcut | 说明 | 需要认证 |
|----------|------|----------|
| `invite +generate` | 生成或获取项目邀请链接 | 是(项目管理员) |
| `invite +show` | 查看邀请链接详细信息 | 否 |
| `invite +accept` | 通过邀请链接接受邀请 | 是 |
| `invite +join` | 申请加入项目 | 是 |
| `invite +quit` | 退出项目 | 是 |
## 使用示例
```bash
# 生成邀请链接(管理员权限)
# role: manager/developer/reporter
# apply: true需要审核/false自动加入
gitlink-cli invite +generate --owner Gitlink --repo forgeplus --role developer --apply false
# 查看邀请链接信息
gitlink-cli invite +show --sign <invite_sign>
# 通过邀请链接接受邀请
gitlink-cli invite +accept --owner Gitlink --repo forgeplus --sign <invite_sign>
# 申请加入项目(需要项目邀请码)
gitlink-cli invite +join --code <invite_code> --role developer
# 退出项目
gitlink-cli invite +quit --owner Gitlink --repo forgeplus
```
## 参数说明
### invite +generate
| 参数 | 说明 | 必填 | 默认值 |
|------|------|------|--------|
| `--owner` | 仓库所有者 | 是 | - |
| `--repo` | 仓库名称 | 是 | - |
| `--role` | 邀请角色manager/developer/reporter | 否 | developer |
| `--apply` | 是否需要审核true/false | 否 | false |
### invite +show
| 参数 | 说明 | 必填 |
|------|------|------|
| `--sign` | 邀请链接签名 | 是 |
### invite +accept
| 参数 | 说明 | 必填 |
|------|------|------|
| `--owner` | 仓库所有者 | 是 |
| `--repo` | 仓库名称 | 是 |
| `--sign` | 邀请链接签名 | 是 |
### invite +join
| 参数 | 说明 | 必填 |
|------|------|------|
| `--code` | 项目邀请码 | 是 |
| `--role` | 申请角色manager/developer/reporter | 是 |
### invite +quit
| 参数 | 说明 | 必填 |
|------|------|------|
| `--owner` | 仓库所有者 | 是 |
| `--repo` | 仓库名称 | 是 |
## 安全规则
- 执行 `invite +quit` 前,确认用户了解退出项目的影响
- 避免在公开日志中暴露邀请链接的完整 `sign`
- `invite +generate` 需要项目管理员权限
- `invite +join` 可能需要项目管理员审核
## 工作流示例
### 场景:邀请新成员加入项目
```bash
# 1. 项目管理员生成邀请链接
gitlink-cli invite +generate --owner myorg --repo myproject --role developer --apply false
# 2. 将返回的 sign 分享给新成员
# 输出示例:{"sign": "abc123xyz", "role": "developer", ...}
# 3. 新成员查看邀请链接信息
gitlink-cli invite +show --sign abc123xyz
# 4. 新成员接受邀请
gitlink-cli invite +accept --owner myorg --repo myproject --sign abc123xyz
```
### 场景:申请加入项目
```bash
# 1. 获取项目邀请码(从项目设置页面)
# 2. 申请加入
gitlink-cli invite +join --code PROJECT123 --role developer
# 3. 等待项目管理员审核
```
### 场景:退出项目
```bash
# 退出前确认
gitlink-cli invite +quit --owner myorg --repo myproject
```