diff --git a/README.md b/README.md index 71837ae..fde8b8c 100644 --- a/README.md +++ b/README.md @@ -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.) | diff --git a/README.zh-CN.md b/README.zh-CN.md index 1661a1e..5b7d11a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 操作(构建、日志等) | diff --git a/internal/i18n/locales/en-US.json b/internal/i18n/locales/en-US.json index 0739395..9766fa8 100644 --- a/internal/i18n/locales/en-US.json +++ b/internal/i18n/locales/en-US.json @@ -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", diff --git a/internal/i18n/locales/zh-CN.json b/internal/i18n/locales/zh-CN.json index 2e6fc4d..dccd77e 100644 --- a/internal/i18n/locales/zh-CN.json +++ b/internal/i18n/locales/zh-CN.json @@ -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", diff --git a/shortcuts/invite/invite.go b/shortcuts/invite/invite.go new file mode 100644 index 0000000..dd1a10a --- /dev/null +++ b/shortcuts/invite/invite.go @@ -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) +} diff --git a/shortcuts/invite/invite_test.go b/shortcuts/invite/invite_test.go new file mode 100644 index 0000000..6eab55d --- /dev/null +++ b/shortcuts/invite/invite_test.go @@ -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) + } + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 00e2426..e6acfc0 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -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", } diff --git a/skills/README.md b/skills/README.md index d4df553..a25704a 100644 --- a/skills/README.md +++ b/skills/README.md @@ -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` | diff --git a/skills/gitlink-invite/SKILL.md b/skills/gitlink-invite/SKILL.md new file mode 100644 index 0000000..6a1b76e --- /dev/null +++ b/skills/gitlink-invite/SKILL.md @@ -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 + +# 通过邀请链接接受邀请 +gitlink-cli invite +accept --owner Gitlink --repo forgeplus --sign + +# 申请加入项目(需要项目邀请码) +gitlink-cli invite +join --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 +```