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:
commit
a0046babb8
10
README.md
10
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 <application_code> --role developer --dry-run
|
||||
```
|
||||
|
||||
### Issue Management
|
||||
|
|
|
|||
|
|
@ -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 管理
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`。
|
||||
|
|
|
|||
Loading…
Reference in New Issue