feat(member): add application shortcuts

This commit is contained in:
wangyue789 2026-06-17 10:11:45 +08:00
parent 71ca2bb683
commit 4c23b8f180
6 changed files with 241 additions and 1 deletions

View File

@ -316,6 +316,13 @@ gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role D
# Create an invite link
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
# Apply to join a project by invite code
gitlink-cli member +apply --code MPzQgH --role developer --dry-run
# Quit a repository membership
gitlink-cli member +quit --owner Gitlink --repo forgeplus --dry-run
gitlink-cli member +quit --owner Gitlink --repo forgeplus --yes
```
### Issue Management

View File

@ -327,6 +327,13 @@ gitlink-cli member +role --owner Gitlink --repo forgeplus --user-id 101 --role D
# 生成邀请链接
gitlink-cli member +invite-link --owner Gitlink --repo forgeplus --role developer --apply true
# 通过邀请码申请加入项目
gitlink-cli member +apply --code MPzQgH --role developer --dry-run
# 退出仓库成员关系
gitlink-cli member +quit --owner Gitlink --repo forgeplus --dry-run
gitlink-cli member +quit --owner Gitlink --repo forgeplus --yes
```
### Issue 管理

View File

@ -0,0 +1,40 @@
# Member Application Shortcuts
## Background
GitLink OpenAPI documents two repository membership lifecycle endpoints that were not exposed as high-level shortcuts:
- `POST /api/applied_projects.json` for applying to join a project with an invite code.
- `POST /api/{owner}/{repo}/quit.json` for leaving a repository.
These operations are useful for community onboarding/offboarding flows and Agent-assisted repository membership workflows.
## What Changed
Added two `member` shortcuts:
- `member +apply --code --role [--dry-run]`
- Builds the documented body shape: `{"applied_project":{"code":"...","role":"..."}}`.
- Validates role as `manager`, `developer`, or `reporter`.
- `member +quit --owner --repo [--dry-run|--yes]`
- Previews the quit request with `--dry-run`.
- Requires explicit `--yes` before leaving the repository.
## OpenAPI Coverage
| Command | Method | Endpoint |
|---|---|---|
| `member +apply` | `POST` | `/api/applied_projects.json` |
| `member +quit` | `POST` | `/api/{owner}/{repo}/quit.json` |
## Validation
```bash
git diff --check
GOPROXY=https://goproxy.cn,direct go test ./shortcuts/member ./shortcuts
go vet ./shortcuts/member ./shortcuts
go run . member +apply --help
go run . member +quit --help
GOPROXY=https://goproxy.cn,direct go test ./...
go vet ./...
```

View File

@ -193,9 +193,88 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "apply",
Description: "Apply to join a repository by invite code",
Flags: []common.Flag{
{Name: "code", Short: "c", Usage: "Project invite code", Required: true},
{Name: "role", Short: "r", Usage: "Requested role: manager, developer, or reporter", Required: true},
{Name: "dry-run", Usage: "Preview the join application without submitting it", Bool: true, Default: "false"},
},
Run: runApply,
},
{
Name: "quit",
Description: "Quit the current repository membership",
Flags: []common.Flag{
{Name: "yes", Usage: "Confirm quitting the repository", Bool: true, Default: "false"},
{Name: "dry-run", Usage: "Preview the quit request without leaving the repository", Bool: true, Default: "false"},
},
Run: runQuit,
},
}
}
func runApply(ctx *common.RuntimeContext) error {
code, err := ctx.RequireArg("code")
if err != nil {
return err
}
role, err := normalizeInviteRole(ctx.Arg("role"))
if err != nil {
return err
}
body := map[string]interface{}{
"applied_project": map[string]interface{}{
"code": code,
"role": role,
},
}
path := "/applied_projects"
if parseDryRun(ctx.Arg("dry-run")) {
return ctx.OutputData(map[string]interface{}{
"dry_run": true,
"action": "apply_project",
"method": "POST",
"path": path,
"body": body,
})
}
env, err := ctx.CallAPI("POST", path, body)
if err != nil {
return err
}
return ctx.Output(env)
}
func runQuit(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
path := ctx.RepoPath() + "/quit"
if parseDryRun(ctx.Arg("dry-run")) {
return ctx.OutputData(map[string]interface{}{
"dry_run": true,
"action": "quit_project",
"method": "POST",
"path": path,
"repository": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
})
}
yes, err := parseBoolArgDefaultFalse("yes", ctx.Arg("yes"))
if err != nil {
return err
}
if !yes {
return fmt.Errorf("quitting a repository requires --yes; use --dry-run to preview")
}
env, err := ctx.CallAPI("POST", path, nil)
if err != nil {
return err
}
return ctx.Output(env)
}
func runBatchAdd(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
@ -302,6 +381,13 @@ func parseBoolArg(name, value string) (bool, error) {
}
}
func parseBoolArgDefaultFalse(name, value string) (bool, error) {
if strings.TrimSpace(value) == "" {
return false, nil
}
return parseBoolArg(name, value)
}
func parseDryRun(value string) bool {
ok, _ := parseBoolArg("dry-run", value)
return ok && strings.TrimSpace(value) != ""

View File

@ -188,6 +188,94 @@ func TestMemberAcceptInvite(t *testing.T) {
}
}
func TestMemberApply(t *testing.T) {
var payload map[string]interface{}
server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "POST", "/applied_projects.json")
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"id": 1, "status": "common", "role": "developer"})
})
defer server.Close()
err := runMemberShortcut(t, server, "apply", map[string]string{"code": "MPzQgH", "role": "developer"})
if err != nil {
t.Fatalf("apply shortcut failed: %v", err)
}
applied, ok := payload["applied_project"].(map[string]interface{})
if !ok {
t.Fatalf("applied_project = %T, want object", payload["applied_project"])
}
if applied["code"] != "MPzQgH" {
t.Fatalf("code = %v, want MPzQgH", applied["code"])
}
if applied["role"] != "developer" {
t.Fatalf("role = %v, want developer", applied["role"])
}
}
func TestMemberApplyDryRunDoesNotCallAPI(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, "apply", map[string]string{
"code": "MPzQgH", "role": "reporter", "dry-run": "true",
})
if err != nil {
t.Fatalf("apply dry-run failed: %v", err)
}
}
func TestMemberApplyRejectsInvalidRole(t *testing.T) {
server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("invalid role should not call API, got: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runMemberShortcut(t, server, "apply", map[string]string{"code": "MPzQgH", "role": "owner"})
if err == nil {
t.Fatal("expected invalid role error")
}
}
func TestMemberQuitRequiresConfirmation(t *testing.T) {
server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("quit without --yes should not call API, got: %s %s", r.Method, r.URL.Path)
})
defer server.Close()
err := runMemberShortcut(t, server, "quit", nil)
if err == nil {
t.Fatal("expected confirmation error")
}
}
func TestMemberQuitDryRunDoesNotCallAPI(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, "quit", map[string]string{"dry-run": "true"})
if err != nil {
t.Fatalf("quit dry-run failed: %v", err)
}
}
func TestMemberQuit(t *testing.T) {
server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "POST", "/owner/repo/quit.json")
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
})
defer server.Close()
err := runMemberShortcut(t, server, "quit", map[string]string{"yes": "true"})
if err != nil {
t.Fatalf("quit 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)

View File

@ -1,6 +1,6 @@
---
name: gitlink-member
description: "仓库成员管理:列出、添加、批量添加、移除成员,调整成员角色,生成、查看和接受项目邀请链接。"
description: "仓库成员管理:列出、添加、批量添加、移除成员,调整成员角色,生成/查看/接受邀请链接,通过邀请码申请加入项目,退出仓库成员关系。"
metadata:
cliHelp: "gitlink-cli member --help"
---
@ -21,6 +21,8 @@ metadata:
| `member +invite-link` | 获取或生成当前邀请链接 |
| `member +invite-info` | 查看邀请链接信息 |
| `member +accept-invite` | 接受邀请链接 |
| `member +apply` | 通过项目邀请码申请加入项目 |
| `member +quit` | 退出当前仓库成员关系 |
## 示例
@ -48,10 +50,20 @@ gitlink-cli member +invite-info --owner Gitlink --repo forgeplus --sign <invite_
# 接受邀请链接
gitlink-cli member +accept-invite --owner Gitlink --repo forgeplus --sign <invite_sign>
# 通过邀请码申请加入项目。role 支持 manager、developer、reporter建议先 dry-run。
gitlink-cli member +apply --code MPzQgH --role developer --dry-run
gitlink-cli member +apply --code MPzQgH --role developer
# 退出仓库成员关系。真实执行必须显式 --yes。
gitlink-cli member +quit --owner Gitlink --repo forgeplus --dry-run
gitlink-cli member +quit --owner Gitlink --repo forgeplus --yes
```
## 安全规则
- 执行 `member +remove`、`member +role`、`member +add`、`member +batch-add` 前,确认目标仓库和用户 ID。
- 执行 `member +apply`、`member +quit` 前,确认邀请码、目标仓库和期望角色;优先使用 `--dry-run` 预览。
- `member +quit` 会让当前用户离开仓库,真实执行必须带 `--yes`
- 批量添加前优先使用 `--dry-run` 预览。
- 避免在公开日志中暴露邀请链接的完整 `sign`