feat(member): add application shortcuts
This commit is contained in:
parent
71ca2bb683
commit
4c23b8f180
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 管理
|
||||
|
|
|
|||
|
|
@ -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 ./...
|
||||
```
|
||||
|
|
@ -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) != ""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`。
|
||||
|
|
|
|||
Loading…
Reference in New Issue