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:
parent
982f2cb336
commit
805c7eba5e
21
README.md
21
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.) |
|
||||
|
|
|
|||
|
|
@ -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 操作(构建、日志等) |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
Loading…
Reference in New Issue