Merge PR #159: Add member application workflow shortcuts

# Conflicts:
#	doc/changes/member-application-shortcuts.md
#	internal/client/client.go
#	shortcuts/member/member.go
#	shortcuts/member/member_test.go
#	skills/gitlink-member/SKILL.md
This commit is contained in:
wbtiger 2026-07-14 22:50:11 +08:00
commit a0046babb8
7 changed files with 378 additions and 308 deletions

View File

@ -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 <application_code> --role developer --dry-run
```
### Issue Management

View File

@ -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 <application_code> --role developer --dry-run
```
### Issue 管理

View File

@ -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`

View File

@ -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))),
}
}

View File

@ -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()
}

View File

@ -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,

View File

@ -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 <invite_
# 接受邀请链接
gitlink-cli member +accept-invite --owner Gitlink --repo forgeplus --sign <invite_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 <application_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`