From 4c23b8f1804387a94ba778a34d5f8fd8df0e91e4 Mon Sep 17 00:00:00 2001 From: wangyue789 Date: Wed, 17 Jun 2026 10:11:45 +0800 Subject: [PATCH] feat(member): add application shortcuts --- README.md | 7 ++ README.zh-CN.md | 7 ++ doc/changes/member-application-shortcuts.md | 40 ++++++++++ shortcuts/member/member.go | 86 ++++++++++++++++++++ shortcuts/member/member_test.go | 88 +++++++++++++++++++++ skills/gitlink-member/SKILL.md | 14 +++- 6 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 doc/changes/member-application-shortcuts.md diff --git a/README.md b/README.md index e5e4318..33d44df 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 6a8879d..396fdfc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 管理 diff --git a/doc/changes/member-application-shortcuts.md b/doc/changes/member-application-shortcuts.md new file mode 100644 index 0000000..c6e88f2 --- /dev/null +++ b/doc/changes/member-application-shortcuts.md @@ -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 ./... +``` diff --git a/shortcuts/member/member.go b/shortcuts/member/member.go index 3734da3..f0407e7 100644 --- a/shortcuts/member/member.go +++ b/shortcuts/member/member.go @@ -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) != "" diff --git a/shortcuts/member/member_test.go b/shortcuts/member/member_test.go index d379e82..3c56155 100644 --- a/shortcuts/member/member_test.go +++ b/shortcuts/member/member_test.go @@ -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) diff --git a/skills/gitlink-member/SKILL.md b/skills/gitlink-member/SKILL.md index 61f8c50..dfe102a 100644 --- a/skills/gitlink-member/SKILL.md +++ b/skills/gitlink-member/SKILL.md @@ -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 + +# 通过邀请码申请加入项目。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`。