diff --git a/README.md b/README.md index 8ca9f72..a678faa 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,16 @@ 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 + +# List pending project membership applications +gitlink-cli member +applications --user Mengz --page 1 --per-page 20 + +# Accept or refuse a membership application by applied_projects[].id +gitlink-cli member +accept-application --user Mengz --id 42 --dry-run +gitlink-cli member +refuse-application --user Mengz --id 43 --dry-run + +# Apply to join a project by application code +gitlink-cli member +apply --code --role developer --dry-run ``` ### Issue Management diff --git a/README.zh-CN.md b/README.zh-CN.md index b844334..992b835 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -294,6 +294,16 @@ 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 +applications --user Mengz --page 1 --per-page 20 + +# 按 applied_projects[].id 接受或拒绝成员申请 +gitlink-cli member +accept-application --user Mengz --id 42 --dry-run +gitlink-cli member +refuse-application --user Mengz --id 43 --dry-run + +# 通过申请码申请加入项目 +gitlink-cli member +apply --code --role developer --dry-run ``` ### Issue 管理 diff --git a/doc/changes/member-application-shortcuts.md b/doc/changes/member-application-shortcuts.md index c6e88f2..2780b73 100644 --- a/doc/changes/member-application-shortcuts.md +++ b/doc/changes/member-application-shortcuts.md @@ -1,40 +1,33 @@ -# Member Application Shortcuts +# Member application shortcuts -## Background +This change extends `gitlink-cli member` from direct collaborator and invite-link operations to the project membership application workflow. -GitLink OpenAPI documents two repository membership lifecycle endpoints that were not exposed as high-level shortcuts: +New 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. +- `member +applications` lists project membership applications for a user inbox with `--user`, `--page`, and `--per-page`. +- `member +accept-application` accepts an application by `applied_projects[].id` and supports `--dry-run`. +- `member +refuse-application` refuses an application by `applied_projects[].id` and supports `--dry-run`. +- `member +apply` applies to join a project with an application code and requested role, also supporting `--dry-run`. -These operations are useful for community onboarding/offboarding flows and Agent-assisted repository membership workflows. +The implementation follows the documented GitLink OpenAPI endpoints: -## What Changed +- `GET /api/users/{owner}/applied_projects.json` +- `POST /api/users/{owner}/applied_projects/{id}/accept.json` +- `POST /api/users/{owner}/applied_projects/{id}/refuse.json` +- `POST /api/applied_projects.json` -Added two `member` shortcuts: +Safety details: -- `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. +- Application decisions validate positive integer IDs before calling the API. +- Application role values are normalized to `manager`, `developer`, or `reporter`. +- Dry-run output includes the method, path, and request body where applicable. +- When `--user` is omitted, the shortcut uses `--owner` first and falls back to `GET /users/me`. -## OpenAPI Coverage +Verification: -| 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 ./... -``` +- `go test ./shortcuts/member` +- `go test ./shortcuts` +- `go test ./...` +- `go build ./...` +- `go run ./internal/i18n/cmd/check --scan-code` +- `git diff --check` diff --git a/internal/client/client.go b/internal/client/client.go index 5487323..ac5c3ff 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -5,11 +5,8 @@ import ( "encoding/json" "fmt" "io" - "mime/multipart" "net/http" - "net/textproto" "net/url" - "sort" "strings" "github.com/gitlink-org/gitlink-cli/internal/auth" @@ -21,9 +18,6 @@ type Client struct { HTTP *http.Client BaseURL string Debug bool - // NoProgress suppresses per-byte transfer progress on stderr; used when - // several transfers run concurrently and interleaved lines would garble. - NoProgress bool } type APIError struct { @@ -32,13 +26,6 @@ type APIError struct { Message string } -type MultipartFile struct { - FieldName string - FileName string - ContentType string - Reader io.Reader -} - func (e *APIError) Error() string { return fmt.Sprintf("[%v] %s", e.Code, e.Message) } @@ -55,54 +42,42 @@ func New() (*Client, error) { } func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) { + path = normalizeAPIPath(c.BaseURL, path) + + // Append .json suffix if not already present (GitLink API convention) + // Handle paths that may already contain query strings (e.g., /path?key=val) + if idx := strings.Index(path, "?"); idx != -1 { + basePath := path[:idx] + queryStr := path[idx:] + if shouldAppendJSONSuffix(basePath) { + path = basePath + ".json" + queryStr + } + } else if shouldAppendJSONSuffix(path) { + path += ".json" + } + fullURL := c.BaseURL + path + if len(query) > 0 { + sep := "?" + if strings.Contains(fullURL, "?") { + sep = "&" + } + fullURL += sep + query.Encode() + } + + // Replace path params var bodyReader io.Reader - contentType := "" if body != nil { data, err := json.Marshal(body) if err != nil { return nil, err } bodyReader = bytes.NewReader(data) - contentType = "application/json" } - return c.doRequest(method, path, bodyReader, contentType, query) -} - -func (c *Client) PostMultipart(path string, fields map[string]string, files []MultipartFile) (*output.Envelope, error) { - if len(files) == 0 { - return nil, fmt.Errorf("at least one multipart file is required") - } - - reader, writer := io.Pipe() - form := multipart.NewWriter(writer) - - go func() { - writeErr := writeMultipartBody(form, fields, files) - closeErr := form.Close() - if writeErr == nil { - writeErr = closeErr - } - if writeErr != nil { - _ = writer.CloseWithError(writeErr) - return - } - _ = writer.Close() - }() - - return c.doRequest(http.MethodPost, path, reader, form.FormDataContentType(), nil) -} - -func (c *Client) doRequest(method, path string, body io.Reader, contentType string, query url.Values) (*output.Envelope, error) { - fullURL := c.apiURL(path, query) - - req, err := http.NewRequest(method, fullURL, body) + req, err := http.NewRequest(method, fullURL, bodyReader) if err != nil { return nil, err } - if contentType != "" { - req.Header.Set("Content-Type", contentType) - } if c.Debug { fmt.Printf("-> %s %s\n", method, fullURL) @@ -120,97 +95,15 @@ func (c *Client) doRequest(method, path string, body io.Reader, contentType stri } if c.Debug { - previewLen := len(respData) - if previewLen > 200 { - previewLen = 200 - } - fmt.Printf("<- %d %s\n", resp.StatusCode, string(respData[:previewLen])) + fmt.Printf("<- %d %s\n", resp.StatusCode, string(respData[:min(len(respData), 200)])) } - return parseResponseEnvelope(resp.StatusCode, respData) -} - -func writeMultipartBody(form *multipart.Writer, fields map[string]string, files []MultipartFile) error { - fieldNames := make([]string, 0, len(fields)) - for name := range fields { - fieldNames = append(fieldNames, name) - } - sort.Strings(fieldNames) - - for _, name := range fieldNames { - if err := form.WriteField(name, fields[name]); err != nil { - return err - } - } - - for _, file := range files { - if file.Reader == nil { - return fmt.Errorf("multipart file reader is required") - } - part, err := createMultipartPart(form, file) - if err != nil { - return err - } - if _, err := io.Copy(part, file.Reader); err != nil { - return err - } - } - - return nil -} - -func createMultipartPart(form *multipart.Writer, file MultipartFile) (io.Writer, error) { - fieldName := strings.TrimSpace(file.FieldName) - if fieldName == "" { - fieldName = "file" - } - fileName := strings.TrimSpace(file.FileName) - if fileName == "" { - fileName = fieldName - } - if strings.TrimSpace(file.ContentType) == "" { - return form.CreateFormFile(fieldName, fileName) - } - - header := make(textproto.MIMEHeader) - header.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, fieldName, fileName)) - header.Set("Content-Type", file.ContentType) - return form.CreatePart(header) -} - -func (c *Client) apiURL(path string, query url.Values) string { - path = normalizeAPIPath(c.BaseURL, path) - - // Append .json suffix if not already present (GitLink API convention) - // Handle paths that may already contain query strings (e.g., /path?key=val) - if idx := strings.Index(path, "?"); idx != -1 { - basePath := path[:idx] - queryStr := path[idx:] - if shouldAppendJSONSuffix(basePath) { - path = basePath + ".json" + queryStr - } - } else if shouldAppendJSONSuffix(path) { - path += ".json" - } - - fullURL := c.BaseURL + path - if len(query) > 0 { - sep := "?" - if strings.Contains(fullURL, "?") { - sep = "&" - } - fullURL += sep + query.Encode() - } - return fullURL -} - -func parseResponseEnvelope(statusCode int, respData []byte) (*output.Envelope, error) { // Check HTTP-level errors - if statusCode >= 400 { + if resp.StatusCode >= 400 { return nil, &APIError{ - StatusCode: statusCode, - Code: statusCode, - Message: fmt.Sprintf("HTTP %d: %s", statusCode, strings.TrimSpace(string(respData))), + StatusCode: resp.StatusCode, + Code: resp.StatusCode, + Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))), } } diff --git a/shortcuts/member/member.go b/shortcuts/member/member.go index 680d04c..bfe5db8 100644 --- a/shortcuts/member/member.go +++ b/shortcuts/member/member.go @@ -8,7 +8,6 @@ import ( "strconv" "strings" - "github.com/gitlink-org/gitlink-cli/internal/i18n" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) @@ -22,12 +21,11 @@ var roleAliases = map[string]string{ } // Shortcuts returns repository member management shortcuts. -func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { - tr := shortcutTranslator(translators...) +func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ { Name: "list", - Description: tr.T("cmd.member.list.short"), + Description: "List repository members", Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -41,9 +39,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "add", - Description: tr.T("cmd.member.add.short"), + Description: "Add a repository member by user ID", Flags: []common.Flag{ - {Name: "user-id", Short: "u", Usage: tr.T("flag.member.user_id"), Required: true}, + {Name: "user-id", Short: "u", Usage: "GitLink user ID to add", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -62,19 +60,19 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "batch-add", - Description: tr.T("cmd.member.batch-add.short"), + Description: "Add multiple repository members by user IDs or a CSV file", Flags: []common.Flag{ - {Name: "user-ids", Short: "u", Usage: tr.T("flag.member.user_ids")}, - {Name: "from", Usage: tr.T("flag.member.from")}, - {Name: "dry-run", Usage: tr.T("flag.member.dry_run"), Bool: true, Default: "false"}, + {Name: "user-ids", Short: "u", Usage: "Comma-separated GitLink user IDs, for example: 101,102"}, + {Name: "from", Usage: "Read user IDs from a CSV file. Supports a user_id/id column or first column without header"}, + {Name: "dry-run", Usage: "Preview members that would be added without changing them", Bool: true, Default: "false"}, }, Run: runBatchAdd, }, { Name: "remove", - Description: tr.T("cmd.member.remove.short"), + Description: "Remove a repository member by user ID", Flags: []common.Flag{ - {Name: "user-id", Short: "u", Usage: tr.T("flag.member.user_id_2"), Required: true}, + {Name: "user-id", Short: "u", Usage: "GitLink user ID to remove", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -93,10 +91,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "role", - Description: tr.T("cmd.member.role.short"), + Description: "Change a repository member role", Flags: []common.Flag{ - {Name: "user-id", Short: "u", Usage: tr.T("flag.member.user_id_2"), Required: true}, - {Name: "role", Short: "r", Usage: tr.T("flag.member.role"), Required: true}, + {Name: "user-id", Short: "u", Usage: "GitLink user ID to update", Required: true}, + {Name: "role", Short: "r", Usage: "Member role: Manager, Developer, or Reporter", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -122,10 +120,10 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "invite-link", - Description: tr.T("cmd.member.invite-link.short"), + Description: "Get or create a repository invite link", Flags: []common.Flag{ - {Name: "role", Short: "r", Usage: tr.T("flag.member.role_2"), Default: "developer"}, - {Name: "apply", Usage: tr.T("flag.member.apply"), Default: "true"}, + {Name: "role", Short: "r", Usage: "Invite role: manager, developer, or reporter", Default: "developer"}, + {Name: "apply", Usage: "Whether joining by invite requires approval: true or false", Default: "true"}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -151,9 +149,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "invite-info", - Description: tr.T("cmd.member.invite-info.short"), + Description: "Show repository invite link information", Flags: []common.Flag{ - {Name: "sign", Short: "s", Usage: tr.T("flag.member.sign"), Required: true}, + {Name: "sign", Short: "s", Usage: "Invite link sign", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -174,9 +172,9 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, { Name: "accept-invite", - Description: tr.T("cmd.member.accept-invite.short"), + Description: "Accept a repository invite link", Flags: []common.Flag{ - {Name: "sign", Short: "s", Usage: tr.T("flag.member.sign"), Required: true}, + {Name: "sign", Short: "s", Usage: "Invite link sign", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { @@ -196,79 +194,86 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut { }, }, { - Name: "apply", - Description: "Apply to join a repository by invite code", + Name: "applications", + Description: "List project membership applications for a user", 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"}, + {Name: "user", Short: "u", Usage: "User login that owns the application inbox. Defaults to --owner or current user"}, + {Name: "page", Usage: "Page number", Default: "1"}, + {Name: "per-page", Usage: "Items per page", Default: "20"}, }, - Run: runApply, + Run: runApplications, }, { - Name: "quit", - Description: "Quit the current repository membership", + Name: "accept-application", + Description: "Accept a project membership application", 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"}, + {Name: "user", Short: "u", Usage: "User login that owns the application inbox. Defaults to --owner or current user"}, + {Name: "id", Usage: "Membership application ID from applied_projects[].id", Required: true}, + {Name: "dry-run", Usage: "Preview the accept request without changing data", Bool: true, Default: "false"}, }, - Run: runQuit, + Run: func(ctx *common.RuntimeContext) error { + return runApplicationDecision(ctx, "accept") + }, + }, + { + Name: "refuse-application", + Description: "Refuse a project membership application", + Flags: []common.Flag{ + {Name: "user", Short: "u", Usage: "User login that owns the application inbox. Defaults to --owner or current user"}, + {Name: "id", Usage: "Membership application ID from applied_projects[].id", Required: true}, + {Name: "dry-run", Usage: "Preview the refuse request without changing data", Bool: true, Default: "false"}, + }, + Run: func(ctx *common.RuntimeContext) error { + return runApplicationDecision(ctx, "refuse") + }, + }, + { + Name: "apply", + Description: "Apply to join a project by application code", + Flags: []common.Flag{ + {Name: "code", Short: "c", Usage: "Project application code", Required: true}, + {Name: "role", Short: "r", Usage: "Requested role: manager, developer, or reporter", Default: "developer"}, + {Name: "dry-run", Usage: "Preview the application request without changing data", Bool: true, Default: "false"}, + }, + Run: runApply, }, } } -func runApply(ctx *common.RuntimeContext) error { - code, err := ctx.RequireArg("code") +func runApplications(ctx *common.RuntimeContext) error { + user, err := resolveApplicationUser(ctx) 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) + query := url.Values{} + setIfPresent(query, "page", ctx.Arg("page")) + setIfPresent(query, "per_page", ctx.Arg("per-page")) + env, err := ctx.CallAPIWithQuery("GET", appliedProjectsPath(user), query) 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")) +func runApplicationDecision(ctx *common.RuntimeContext, action string) error { + user, err := resolveApplicationUser(ctx) if err != nil { return err } - if !yes { - return fmt.Errorf("quitting a repository requires --yes; use --dry-run to preview") + id, err := parsePositiveID("id", ctx.Arg("id")) + if err != nil { + return err + } + path := fmt.Sprintf("%s/%d/%s", appliedProjectsPath(user), id, action) + if parseDryRun(ctx.Arg("dry-run")) { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "method": "POST", + "path": path, + "user": user, + "id": id, + "action": action, + }) } env, err := ctx.CallAPI("POST", path, nil) if err != nil { @@ -277,6 +282,36 @@ func runQuit(ctx *common.RuntimeContext) error { return ctx.Output(env) } +func runApply(ctx *common.RuntimeContext) error { + code, err := requiredTrimmed("code", ctx.Arg("code")) + if err != nil { + return err + } + role, err := normalizeApplicationRole(ctx.Arg("role")) + if err != nil { + return err + } + payload := map[string]interface{}{ + "applied_project": map[string]interface{}{ + "code": code, + "role": role, + }, + } + if parseDryRun(ctx.Arg("dry-run")) { + return ctx.OutputData(map[string]interface{}{ + "dry_run": true, + "method": "POST", + "path": "/applied_projects", + "body": payload, + }) + } + env, err := ctx.CallAPI("POST", "/applied_projects", payload) + if err != nil { + return err + } + return ctx.Output(env) +} + func runBatchAdd(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err @@ -347,6 +382,33 @@ func inviteLinkPath(ctx *common.RuntimeContext, action string) string { return fmt.Sprintf("/%s/%s/project_invite_links/%s", ctx.Owner, ctx.Repo, action) } +func appliedProjectsPath(user string) string { + return fmt.Sprintf("/users/%s/applied_projects", user) +} + +func resolveApplicationUser(ctx *common.RuntimeContext) (string, error) { + if user := strings.TrimSpace(ctx.Arg("user")); user != "" { + return user, nil + } + if user := strings.TrimSpace(ctx.Owner); user != "" { + return user, nil + } + env, err := ctx.CallAPI("GET", "/users/me", nil) + if err != nil { + return "", fmt.Errorf("failed to get current user: %w", err) + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("cannot determine current user login") + } + login, _ := data["login"].(string) + login = strings.TrimSpace(login) + if login == "" { + return "", fmt.Errorf("cannot determine current user login") + } + return login, nil +} + func parseUserID(value string) (int, error) { value = strings.TrimSpace(value) userID, err := strconv.Atoi(value) @@ -372,6 +434,37 @@ func normalizeInviteRole(value string) (string, error) { return strings.ToLower(role), nil } +func normalizeApplicationRole(value string) (string, error) { + role, err := normalizeRole(value) + if err != nil { + return "", fmt.Errorf("invalid --role value %q: use manager, developer, or reporter", value) + } + return strings.ToLower(role), nil +} + +func parsePositiveID(name, value string) (int, error) { + value = strings.TrimSpace(value) + id, err := strconv.Atoi(value) + if err != nil || id <= 0 { + return 0, fmt.Errorf("invalid --%s value %q: use a positive integer", name, value) + } + return id, nil +} + +func requiredTrimmed(name, value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", fmt.Errorf("missing required flag: %s", name) + } + return value, nil +} + +func setIfPresent(values url.Values, key, value string) { + if value := strings.TrimSpace(value); value != "" { + values.Set(key, value) + } +} + func parseBoolArg(name, value string) (bool, error) { switch strings.ToLower(strings.TrimSpace(value)) { case "", "true": @@ -383,13 +476,6 @@ 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) != "" @@ -478,10 +564,3 @@ func userIDColumn(header []string) int { } return -1 } - -func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator { - if len(translators) > 0 && translators[0] != nil { - return translators[0] - } - return i18n.Default() -} diff --git a/shortcuts/member/member_test.go b/shortcuts/member/member_test.go index 3c56155..9ecb908 100644 --- a/shortcuts/member/member_test.go +++ b/shortcuts/member/member_test.go @@ -188,28 +188,127 @@ func TestMemberAcceptInvite(t *testing.T) { } } +func TestMemberApplicationsUsesExplicitUserAndPagination(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/users/Mengz/applied_projects.json") + if r.URL.Query().Get("page") != "2" { + t.Fatalf("page query = %q, want 2", r.URL.Query().Get("page")) + } + if r.URL.Query().Get("per_page") != "50" { + t.Fatalf("per_page query = %q, want 50", r.URL.Query().Get("per_page")) + } + writeJSON(t, w, map[string]interface{}{"total_count": 1, "applied_projects": []interface{}{}}) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "applications", map[string]string{ + "user": "Mengz", + "page": "2", + "per-page": "50", + }) + if err != nil { + t.Fatalf("applications shortcut failed: %v", err) + } +} + +func TestMemberApplicationsDefaultsToCurrentUserWhenOwnerMissing(t *testing.T) { + var paths []string + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + switch r.URL.Path { + case "/users/me.json": + assertRequest(t, r, "GET", "/users/me.json") + writeJSON(t, w, map[string]interface{}{"login": "Mengz"}) + case "/users/Mengz/applied_projects.json": + assertRequest(t, r, "GET", "/users/Mengz/applied_projects.json") + writeJSON(t, w, map[string]interface{}{"total_count": 0, "applied_projects": []interface{}{}}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + }) + defer server.Close() + + if err := runMemberShortcutWithOwner(t, server, "", "applications", nil); err != nil { + t.Fatalf("applications shortcut failed: %v", err) + } + want := []string{"/users/me.json", "/users/Mengz/applied_projects.json"} + if !reflect.DeepEqual(paths, want) { + t.Fatalf("request paths = %v, want %v", paths, want) + } +} + +func TestMemberAcceptApplication(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/users/Mengz/applied_projects/42/accept.json") + writeJSON(t, w, map[string]interface{}{"id": 42, "status": "accepted"}) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "accept-application", map[string]string{ + "user": "Mengz", + "id": "42", + }) + if err != nil { + t.Fatalf("accept-application shortcut failed: %v", err) + } +} + +func TestMemberRefuseApplicationDryRunDoesNotCallAPI(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, "refuse-application", map[string]string{ + "user": "Mengz", + "id": "43", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("refuse-application dry-run failed: %v", err) + } +} + +func TestMemberApplicationRejectsInvalidIDBeforeAPI(t *testing.T) { + server := newMemberTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("invalid id should not call API, got: %s %s", r.Method, r.URL.Path) + }) + defer server.Close() + + err := runMemberShortcut(t, server, "accept-application", map[string]string{ + "user": "Mengz", + "id": "0", + }) + if err == nil { + t.Fatal("expected invalid id to return an error") + } +} + 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"}) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) }) defer server.Close() - err := runMemberShortcut(t, server, "apply", map[string]string{"code": "MPzQgH", "role": "developer"}) + err := runMemberShortcut(t, server, "apply", map[string]string{ + "code": "invite-code", + "role": "reporter", + }) if err != nil { t.Fatalf("apply shortcut failed: %v", err) } - applied, ok := payload["applied_project"].(map[string]interface{}) + appliedProject, ok := payload["applied_project"].(map[string]interface{}) if !ok { - t.Fatalf("applied_project = %T, want object", payload["applied_project"]) + t.Fatalf("applied_project payload = %v, want object", payload["applied_project"]) } - if applied["code"] != "MPzQgH" { - t.Fatalf("code = %v, want MPzQgH", applied["code"]) + if appliedProject["code"] != "invite-code" { + t.Fatalf("code = %v, want invite-code", appliedProject["code"]) } - if applied["role"] != "developer" { - t.Fatalf("role = %v, want developer", applied["role"]) + if appliedProject["role"] != "reporter" { + t.Fatalf("role = %v, want reporter", appliedProject["role"]) } } @@ -220,59 +319,27 @@ func TestMemberApplyDryRunDoesNotCallAPI(t *testing.T) { defer server.Close() err := runMemberShortcut(t, server, "apply", map[string]string{ - "code": "MPzQgH", "role": "reporter", "dry-run": "true", + "code": "invite-code", + "role": "developer", + "dry-run": "true", }) if err != nil { t.Fatalf("apply dry-run failed: %v", err) } } -func TestMemberApplyRejectsInvalidRole(t *testing.T) { +func TestMemberApplyRejectsInvalidRoleBeforeAPI(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"}) + err := runMemberShortcut(t, server, "apply", map[string]string{ + "code": "invite-code", + "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) + t.Fatal("expected invalid role to return an error") } } @@ -295,6 +362,11 @@ func TestNormalizeRoleRejectsInvalidRole(t *testing.T) { } func runMemberShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + return runMemberShortcutWithOwner(t, server, "owner", name, args) +} + +func runMemberShortcutWithOwner(t *testing.T, server *httptest.Server, owner, name string, args map[string]string) error { t.Helper() shortcut := findMemberShortcut(t, name) ctx := &common.RuntimeContext{ @@ -302,7 +374,7 @@ func runMemberShortcut(t *testing.T, server *httptest.Server, name string, args HTTP: server.Client(), BaseURL: server.URL, }, - Owner: "owner", + Owner: owner, Repo: "repo", Format: "json", Args: args, diff --git a/skills/gitlink-member/SKILL.md b/skills/gitlink-member/SKILL.md index c671bf5..273db40 100644 --- a/skills/gitlink-member/SKILL.md +++ b/skills/gitlink-member/SKILL.md @@ -1,10 +1,7 @@ --- name: gitlink-member -version: 1.0.0 -description: "仓库成员管理:列出、添加、批量添加、移除成员,调整成员角色,生成、查看和接受项目邀请链接。当用户需要管理 GitLink 仓库成员、成员角色或邀请链接时触发。" +description: "仓库成员管理:列出、添加、批量添加、移除成员,调整成员角色,处理成员申请,生成、查看和接受项目邀请链接。" metadata: - requires: - bins: ["gitlink-cli"] cliHelp: "gitlink-cli member --help" --- @@ -24,6 +21,10 @@ metadata: | `member +invite-link` | 获取或生成当前邀请链接 | | `member +invite-info` | 查看邀请链接信息 | | `member +accept-invite` | 接受邀请链接 | +| `member +applications` | 查看用户待办中的项目成员申请 | +| `member +accept-application` | 接受成员申请 | +| `member +refuse-application` | 拒绝成员申请 | +| `member +apply` | 通过申请码申请加入项目 | ## 示例 @@ -51,10 +52,22 @@ gitlink-cli member +invite-info --owner Gitlink --repo forgeplus --sign + +# 查看待处理的项目成员申请。未传 --user 时优先使用 --owner,否则读取当前登录用户。 +gitlink-cli member +applications --user Mengz --page 1 --per-page 20 + +# 接受或拒绝成员申请。--id 是 applied_projects[].id,不是项目 ID。 +gitlink-cli member +accept-application --user Mengz --id 42 --dry-run +gitlink-cli member +refuse-application --user Mengz --id 43 --dry-run + +# 通过申请码申请加入项目。角色支持 manager、developer、reporter。 +gitlink-cli member +apply --code --role developer --dry-run ``` ## 安全规则 - 执行 `member +remove`、`member +role`、`member +add`、`member +batch-add` 前,确认目标仓库和用户 ID。 - 批量添加前优先使用 `--dry-run` 预览。 +- 接受或拒绝成员申请前先运行 `member +applications`,确认 `applied_projects[].id` 和申请人、目标项目一致。 +- 申请加入项目或处理申请时优先使用 `--dry-run` 预览请求路径与请求体。 - 避免在公开日志中暴露邀请链接的完整 `sign`。