forked from Gitlink/gitlink-cli
Merge PR #204: feat(org): 增强组织团队与成员管理快捷命令
# Conflicts: # doc/changes/org-team-shortcuts.md # shortcuts/org/org.go # shortcuts/org/org_test.go
This commit is contained in:
commit
bac3a6bb16
25
README.md
25
README.md
|
|
@ -285,6 +285,31 @@ 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
|
||||
```
|
||||
|
||||
### Organization Operations
|
||||
|
||||
```bash
|
||||
# List organizations
|
||||
gitlink-cli org +list --page 1 --limit 20
|
||||
|
||||
# Show organization details
|
||||
gitlink-cli org +info --id Gitlink
|
||||
|
||||
# List organization members with team or keyword filters
|
||||
gitlink-cli org +members --id Gitlink --team Platform --format json
|
||||
gitlink-cli org +members --id Gitlink --keyword alice --limit 50 --format json
|
||||
|
||||
# List organization teams with normalized user details
|
||||
gitlink-cli org +teams --id Gitlink --authorize write --include-users --format json
|
||||
|
||||
# Preview and create an organization team
|
||||
gitlink-cli org +team-create --id Gitlink --name platform --nickname "Platform Team" --dry-run
|
||||
gitlink-cli org +team-create --id Gitlink --name platform --nickname "Platform Team"
|
||||
|
||||
# Preview and remove an organization member
|
||||
gitlink-cli org +member-remove --id Gitlink --login alice --dry-run
|
||||
gitlink-cli org +member-remove --id Gitlink --user-id 101
|
||||
```
|
||||
|
||||
### Issue Management
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -1,33 +1,23 @@
|
|||
# Organization Team Shortcuts
|
||||
# org team shortcuts
|
||||
|
||||
## Summary
|
||||
This change expands the `org` shortcut group from basic organization lookup into an operational workflow for team and membership management.
|
||||
|
||||
Adds a read-only organization team shortcut so users and agents can inspect teams without dropping down to Raw API calls.
|
||||
The updated command set keeps `org +list`, `org +info`, and `org +create`, and adds stronger organization administration coverage:
|
||||
|
||||
## Commands
|
||||
- `org +members` now supports `--all`, `--team`, `--login`, and `--keyword`, and automatically fetches all pages before applying filters.
|
||||
- `org +teams` lists organization teams with `--authorize`, `--unit`, `--keyword`, and `--include-users` support, returning normalized team summaries.
|
||||
- `org +team-create` creates a team and supports `--dry-run` for request preview.
|
||||
- `org +member-remove` removes an organization member by `--user-id` or resolves `--login` automatically, with `--dry-run` support.
|
||||
|
||||
The output shape is normalized for automation use. Member results now include matched counts, team summaries, and consistent user fields. Team results expose permission-level aggregation, unit information, and optional normalized user details.
|
||||
|
||||
The command examples in `README.md` were extended so the new team and member management flows are discoverable from the main project documentation.
|
||||
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
gitlink-cli org +teams --id Gitlink
|
||||
gitlink-cli org +teams --id Gitlink --page 1 --limit 50
|
||||
gitlink-cli org +teams --id Gitlink --format json
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- Calls `GET /organizations/{id}/teams`.
|
||||
- Supports `--page` and `--limit`, matching `org +list` and `org +members` pagination behavior.
|
||||
- Requires `--id` to avoid accidental ambiguous organization lookup.
|
||||
- Leaves team creation and destructive team management in Raw API because only the read-only list endpoint is currently documented by the GitLink org Skill.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Updates the `gitlink-org` Skill index to include `org +teams`.
|
||||
- Adds a dedicated `gitlink-org-teams` reference page for Agent usage.
|
||||
- Updates README feature wording to state that organization teams can be inspected through shortcuts.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
go test ./shortcuts/org ./shortcuts
|
||||
go test ./shortcuts/org/...
|
||||
go test ./shortcuts/...
|
||||
go test ./...
|
||||
go build ./...
|
||||
```
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ package org
|
|||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
|
|
@ -19,9 +22,17 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
page, err := parsePositiveInt("page", ctx.Arg("page"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limit, err := parsePositiveInt("limit", ctx.Arg("limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
env, err := ctx.CallAPIWithQuery("GET", "/organizations", q)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -40,7 +51,7 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil)
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", url.PathEscape(id)), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -49,48 +60,75 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
},
|
||||
{
|
||||
Name: "members",
|
||||
Description: tr.T("cmd.org.members.short"),
|
||||
Description: "List organization members with team and keyword filters",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true},
|
||||
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||
{Name: "all", Usage: "Fetch all pages before filtering", Bool: true, Default: "false"},
|
||||
{Name: "team", Usage: "Filter by team name"},
|
||||
{Name: "login", Usage: "Filter by member login"},
|
||||
{Name: "keyword", Usage: "Keyword filter against login, name, mail, and teams"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/organization_users", id), q)
|
||||
page, err := parsePositiveInt("page", ctx.Arg("page"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
limit, err := parsePositiveInt("limit", ctx.Arg("limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filters := memberFilters{
|
||||
Team: strings.TrimSpace(ctx.Arg("team")),
|
||||
Login: strings.TrimSpace(ctx.Arg("login")),
|
||||
Keyword: strings.TrimSpace(ctx.Arg("keyword")),
|
||||
}
|
||||
fetchAll := parseBoolArg(ctx.Arg("all")) || filters.enabled()
|
||||
items, totalCount, err := fetchOrganizationMembers(ctx, id, page, limit, fetchAll)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := normalizeOrganizationMembers(id, page, limit, totalCount, fetchAll, filters, items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.OutputData(data)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "teams",
|
||||
Description: tr.T("cmd.org.teams.short"),
|
||||
Description: "List organization teams with permission and keyword filters",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true},
|
||||
{Name: "page", Short: "p", Usage: tr.T("flag.page"), Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: tr.T("flag.limit"), Default: "20"},
|
||||
{Name: "authorize", Usage: "Filter by authorize level: owner, admin, write, read"},
|
||||
{Name: "unit", Usage: "Filter teams containing a specific unit"},
|
||||
{Name: "keyword", Usage: "Keyword filter against team name, nickname, and description"},
|
||||
{Name: "include-users", Usage: "Include normalized member details for each team", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("page", ctx.Arg("page"))
|
||||
q.Set("limit", ctx.Arg("limit"))
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/teams", id), q)
|
||||
env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s/teams", url.PathEscape(id)), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
filters := teamFilters{
|
||||
Authorize: strings.TrimSpace(ctx.Arg("authorize")),
|
||||
Unit: strings.TrimSpace(ctx.Arg("unit")),
|
||||
Keyword: strings.TrimSpace(ctx.Arg("keyword")),
|
||||
}
|
||||
data, err := normalizeTeams(id, parseBoolArg(ctx.Arg("include-users")), filters, env.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.OutputData(data)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -118,9 +156,523 @@ func Shortcuts(translators ...*i18n.Translator) []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "team-create",
|
||||
Description: "Create an organization team with dry-run preview",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true},
|
||||
{Name: "name", Short: "n", Usage: "Team name", Required: true},
|
||||
{Name: "nickname", Usage: "Team display name"},
|
||||
{Name: "description", Short: "d", Usage: tr.T("flag.description")},
|
||||
{Name: "dry-run", Usage: "Preview the request without creating the team", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := ctx.RequireArg("name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"name": name,
|
||||
}
|
||||
if nickname := strings.TrimSpace(ctx.Arg("nickname")); nickname != "" {
|
||||
payload["nickname"] = nickname
|
||||
}
|
||||
if description := strings.TrimSpace(ctx.Arg("description")); description != "" {
|
||||
payload["description"] = description
|
||||
}
|
||||
path := fmt.Sprintf("/organizations/%s/teams", url.PathEscape(id))
|
||||
if parseBoolArg(ctx.Arg("dry-run")) {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"method": "POST",
|
||||
"path": path,
|
||||
"payload": payload,
|
||||
})
|
||||
}
|
||||
env, err := ctx.CallAPI("POST", path, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "member-remove",
|
||||
Description: "Remove an organization member by user ID or login with dry-run preview",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: tr.T("flag.org.id"), Required: true},
|
||||
{Name: "user-id", Usage: "Organization member user ID"},
|
||||
{Name: "login", Usage: "Organization member login name"},
|
||||
{Name: "dry-run", Usage: "Preview the request without removing the member", Bool: true, Default: "false"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
id, err := ctx.RequireArg("id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userID, member, err := resolveOrganizationMember(ctx, id, ctx.Arg("user-id"), ctx.Arg("login"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := fmt.Sprintf("/organizations/%s/organization_users/%d", url.PathEscape(id), userID)
|
||||
if parseBoolArg(ctx.Arg("dry-run")) {
|
||||
return ctx.OutputData(map[string]interface{}{
|
||||
"dry_run": true,
|
||||
"method": "DELETE",
|
||||
"path": path,
|
||||
"member": member,
|
||||
})
|
||||
}
|
||||
env, err := ctx.CallAPI("DELETE", path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type memberFilters struct {
|
||||
Team string
|
||||
Login string
|
||||
Keyword string
|
||||
}
|
||||
|
||||
func (f memberFilters) enabled() bool {
|
||||
return f.Team != "" || f.Login != "" || f.Keyword != ""
|
||||
}
|
||||
|
||||
type teamFilters struct {
|
||||
Authorize string
|
||||
Unit string
|
||||
Keyword string
|
||||
}
|
||||
|
||||
func parsePositiveInt(flagName, value string) (int, error) {
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil || parsed <= 0 {
|
||||
return 0, fmt.Errorf("--%s must be a positive integer", flagName)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseBoolArg(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "y", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func fetchOrganizationMembers(ctx *common.RuntimeContext, orgID string, page, limit int, fetchAll bool) ([]interface{}, int, error) {
|
||||
if !fetchAll {
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/organization_users", url.PathEscape(orgID)), url.Values{
|
||||
"page": []string{strconv.Itoa(page)},
|
||||
"limit": []string{strconv.Itoa(limit)},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return unwrapOrganizationUsers(env.Data)
|
||||
}
|
||||
|
||||
var all []interface{}
|
||||
totalCount := 0
|
||||
currentPage := 1
|
||||
|
||||
for {
|
||||
env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/organization_users", url.PathEscape(orgID)), url.Values{
|
||||
"page": []string{strconv.Itoa(currentPage)},
|
||||
"limit": []string{strconv.Itoa(limit)},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items, count, err := unwrapOrganizationUsers(env.Data)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if totalCount == 0 {
|
||||
totalCount = count
|
||||
}
|
||||
if len(items) == 0 {
|
||||
break
|
||||
}
|
||||
all = append(all, items...)
|
||||
if len(items) < limit || (totalCount > 0 && len(all) >= totalCount) {
|
||||
break
|
||||
}
|
||||
currentPage++
|
||||
}
|
||||
|
||||
return all, totalCount, nil
|
||||
}
|
||||
|
||||
func unwrapOrganizationUsers(data interface{}) ([]interface{}, int, error) {
|
||||
m, err := asMap(data)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items, err := asSlice(m["organization_users"])
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, asInt(m["total_count"]), nil
|
||||
}
|
||||
|
||||
func normalizeOrganizationMembers(orgID string, page, limit, totalCount int, fetchedAll bool, filters memberFilters, items []interface{}) (map[string]interface{}, error) {
|
||||
members := make([]map[string]interface{}, 0, len(items))
|
||||
countsByTeam := map[string]int{}
|
||||
|
||||
for _, item := range items {
|
||||
member, err := normalizeMember(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !matchesMemberFilters(member, filters) {
|
||||
continue
|
||||
}
|
||||
for _, teamName := range member["team_names"].([]string) {
|
||||
countsByTeam[teamName]++
|
||||
}
|
||||
members = append(members, member)
|
||||
}
|
||||
|
||||
sort.Slice(members, func(i, j int) bool {
|
||||
return members[i]["login"].(string) < members[j]["login"].(string)
|
||||
})
|
||||
|
||||
result := map[string]interface{}{
|
||||
"organization": orgID,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"fetched_all": fetchedAll,
|
||||
"total_count": totalCount,
|
||||
"matched_count": len(members),
|
||||
"counts_by_team": countsByTeam,
|
||||
"organization_users": members,
|
||||
}
|
||||
if filters.enabled() {
|
||||
filterMap := map[string]interface{}{}
|
||||
if filters.Team != "" {
|
||||
filterMap["team"] = filters.Team
|
||||
}
|
||||
if filters.Login != "" {
|
||||
filterMap["login"] = filters.Login
|
||||
}
|
||||
if filters.Keyword != "" {
|
||||
filterMap["keyword"] = filters.Keyword
|
||||
}
|
||||
result["filters"] = filterMap
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeTeams(orgID string, includeUsers bool, filters teamFilters, data interface{}) (map[string]interface{}, error) {
|
||||
m, err := asMap(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := asSlice(m["teams"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
teams := make([]map[string]interface{}, 0, len(items))
|
||||
countsByAuthorize := map[string]int{}
|
||||
|
||||
for _, item := range items {
|
||||
team, err := normalizeTeam(item, includeUsers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !matchesTeamFilters(team, filters) {
|
||||
continue
|
||||
}
|
||||
countsByAuthorize[team["authorize"].(string)]++
|
||||
teams = append(teams, team)
|
||||
}
|
||||
|
||||
sort.Slice(teams, func(i, j int) bool {
|
||||
return teams[i]["name"].(string) < teams[j]["name"].(string)
|
||||
})
|
||||
|
||||
result := map[string]interface{}{
|
||||
"organization": orgID,
|
||||
"total_count": asInt(m["total_count"]),
|
||||
"matched_count": len(teams),
|
||||
"include_users": includeUsers,
|
||||
"counts_by_authorize": countsByAuthorize,
|
||||
"teams": teams,
|
||||
}
|
||||
if filters.Authorize != "" || filters.Unit != "" || filters.Keyword != "" {
|
||||
filterMap := map[string]interface{}{}
|
||||
if filters.Authorize != "" {
|
||||
filterMap["authorize"] = filters.Authorize
|
||||
}
|
||||
if filters.Unit != "" {
|
||||
filterMap["unit"] = filters.Unit
|
||||
}
|
||||
if filters.Keyword != "" {
|
||||
filterMap["keyword"] = filters.Keyword
|
||||
}
|
||||
result["filters"] = filterMap
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeTeam(value interface{}, includeUsers bool) (map[string]interface{}, error) {
|
||||
m, err := asMap(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
units := asStringSlice(m["units"])
|
||||
users, err := asSlice(m["users"])
|
||||
if err != nil && m["users"] != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
memberLogins := make([]string, 0, len(users))
|
||||
normalizedUsers := make([]map[string]interface{}, 0, len(users))
|
||||
|
||||
for _, user := range users {
|
||||
userMap, err := normalizeTeamUser(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
memberLogins = append(memberLogins, userMap["login"].(string))
|
||||
if includeUsers {
|
||||
normalizedUsers = append(normalizedUsers, userMap)
|
||||
}
|
||||
}
|
||||
sort.Strings(memberLogins)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"id": asInt(m["id"]),
|
||||
"name": strings.TrimSpace(asString(m["name"])),
|
||||
"nickname": strings.TrimSpace(asString(m["nickname"])),
|
||||
"description": strings.TrimSpace(asString(m["description"])),
|
||||
"authorize": strings.TrimSpace(asString(m["authorize"])),
|
||||
"includes_all_projects": asBool(m["includes_all_project"]),
|
||||
"can_create_org_project": asBool(m["can_create_org_project"]),
|
||||
"num_projects": asInt(m["num_projects"]),
|
||||
"num_users": asInt(m["num_users"]),
|
||||
"units": units,
|
||||
"member_logins": memberLogins,
|
||||
}
|
||||
if includeUsers {
|
||||
sort.Slice(normalizedUsers, func(i, j int) bool {
|
||||
return normalizedUsers[i]["login"].(string) < normalizedUsers[j]["login"].(string)
|
||||
})
|
||||
result["users"] = normalizedUsers
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeMember(value interface{}) (map[string]interface{}, error) {
|
||||
m, err := asMap(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err := asMap(m["user"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
teamNames := asStringSlice(m["team_names"])
|
||||
sort.Strings(teamNames)
|
||||
return map[string]interface{}{
|
||||
"id": asInt(m["id"]),
|
||||
"login": strings.TrimSpace(asString(user["login"])),
|
||||
"name": strings.TrimSpace(asString(user["name"])),
|
||||
"identity": strings.TrimSpace(asString(user["identity"])),
|
||||
"mail": strings.TrimSpace(asString(user["mail"])),
|
||||
"joined_at": strings.TrimSpace(asString(m["created_at"])),
|
||||
"team_names": teamNames,
|
||||
"team_count": len(teamNames),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeTeamUser(value interface{}) (map[string]interface{}, error) {
|
||||
m, err := asMap(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"user_id": asInt(m["user_id"]),
|
||||
"login": strings.TrimSpace(asString(m["login"])),
|
||||
"name": strings.TrimSpace(asString(m["name"])),
|
||||
"identity": strings.TrimSpace(asString(m["identity"])),
|
||||
"mail": strings.TrimSpace(asString(m["mail"])),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func matchesMemberFilters(member map[string]interface{}, filters memberFilters) bool {
|
||||
if filters.Team != "" {
|
||||
matchedTeam := false
|
||||
for _, teamName := range member["team_names"].([]string) {
|
||||
if strings.EqualFold(teamName, filters.Team) {
|
||||
matchedTeam = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matchedTeam {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if filters.Login != "" && !strings.EqualFold(member["login"].(string), filters.Login) {
|
||||
return false
|
||||
}
|
||||
if filters.Keyword != "" {
|
||||
haystack := strings.ToLower(strings.Join([]string{
|
||||
member["login"].(string),
|
||||
member["name"].(string),
|
||||
member["mail"].(string),
|
||||
strings.Join(member["team_names"].([]string), " "),
|
||||
}, " "))
|
||||
if !strings.Contains(haystack, strings.ToLower(filters.Keyword)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func matchesTeamFilters(team map[string]interface{}, filters teamFilters) bool {
|
||||
if filters.Authorize != "" && !strings.EqualFold(team["authorize"].(string), filters.Authorize) {
|
||||
return false
|
||||
}
|
||||
if filters.Unit != "" {
|
||||
found := false
|
||||
for _, unit := range team["units"].([]string) {
|
||||
if strings.EqualFold(unit, filters.Unit) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if filters.Keyword != "" {
|
||||
haystack := strings.ToLower(strings.Join([]string{
|
||||
team["name"].(string),
|
||||
team["nickname"].(string),
|
||||
team["description"].(string),
|
||||
}, " "))
|
||||
if !strings.Contains(haystack, strings.ToLower(filters.Keyword)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func resolveOrganizationMember(ctx *common.RuntimeContext, orgID, userIDValue, login string) (int, map[string]interface{}, error) {
|
||||
if strings.TrimSpace(userIDValue) != "" {
|
||||
userID, err := parsePositiveInt("user-id", userIDValue)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
member := map[string]interface{}{
|
||||
"id": userID,
|
||||
"login": strings.TrimSpace(login),
|
||||
}
|
||||
return userID, member, nil
|
||||
}
|
||||
if strings.TrimSpace(login) == "" {
|
||||
return 0, nil, fmt.Errorf("one of --user-id or --login is required")
|
||||
}
|
||||
items, _, err := fetchOrganizationMembers(ctx, orgID, 1, 100, true)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
for _, item := range items {
|
||||
member, err := normalizeMember(item)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if strings.EqualFold(member["login"].(string), strings.TrimSpace(login)) {
|
||||
return member["id"].(int), member, nil
|
||||
}
|
||||
}
|
||||
return 0, nil, fmt.Errorf("organization member %q was not found", login)
|
||||
}
|
||||
|
||||
func asMap(value interface{}) (map[string]interface{}, error) {
|
||||
m, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected object response, got %T", value)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func asSlice(value interface{}) ([]interface{}, error) {
|
||||
s, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected array response, got %T", value)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func asString(value interface{}) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return fmt.Sprintf("%v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func asInt(value interface{}) int {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int32:
|
||||
return int(v)
|
||||
case int64:
|
||||
return int(v)
|
||||
case float32:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
parsed, _ := strconv.Atoi(strings.TrimSpace(v))
|
||||
return parsed
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func asBool(value interface{}) bool {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return parseBoolArg(v)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func asStringSlice(value interface{}) []string {
|
||||
items, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, strings.TrimSpace(asString(item)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func shortcutTranslator(translators ...*i18n.Translator) *i18n.Translator {
|
||||
if len(translators) > 0 && translators[0] != nil {
|
||||
return translators[0]
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@ package org
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
|
|
@ -11,6 +14,12 @@ import (
|
|||
)
|
||||
|
||||
func runShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
|
||||
t.Helper()
|
||||
_, err := runShortcutOutput(t, server, name, args)
|
||||
return err
|
||||
}
|
||||
|
||||
func runShortcutOutput(t *testing.T, server *httptest.Server, name string, args map[string]string) (string, error) {
|
||||
t.Helper()
|
||||
shortcut := findShortcut(t, name)
|
||||
ctx := &common.RuntimeContext{
|
||||
|
|
@ -20,7 +29,25 @@ func runShortcut(t *testing.T, server *httptest.Server, name string, args map[st
|
|||
Format: "json",
|
||||
Args: args,
|
||||
}
|
||||
return shortcut.Run(ctx)
|
||||
|
||||
oldStdout := os.Stdout
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("pipe stdout: %v", err)
|
||||
}
|
||||
|
||||
os.Stdout = writer
|
||||
runErr := shortcut.Run(ctx)
|
||||
writer.Close()
|
||||
os.Stdout = oldStdout
|
||||
|
||||
output, readErr := io.ReadAll(reader)
|
||||
reader.Close()
|
||||
if readErr != nil {
|
||||
t.Fatalf("read stdout: %v", readErr)
|
||||
}
|
||||
|
||||
return string(output), runErr
|
||||
}
|
||||
|
||||
func findShortcut(t *testing.T, name string) *common.Shortcut {
|
||||
|
|
@ -36,16 +63,93 @@ func findShortcut(t *testing.T, name string) *common.Shortcut {
|
|||
|
||||
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- list ---
|
||||
func parseEnvelope(t *testing.T, raw string) map[string]interface{} {
|
||||
t.Helper()
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &env); err != nil {
|
||||
t.Fatalf("parse envelope: %v\nraw=%s", err, raw)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func decodeJSONBody(t *testing.T, r *http.Request) map[string]interface{} {
|
||||
t.Helper()
|
||||
defer r.Body.Close()
|
||||
var payload map[string]interface{}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode body: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func fixtureOrganizationUser(id int, login, name, mail string, teamNames ...string) map[string]interface{} {
|
||||
teams := make([]interface{}, 0, len(teamNames))
|
||||
for _, teamName := range teamNames {
|
||||
teams = append(teams, teamName)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": float64(id),
|
||||
"user": map[string]interface{}{
|
||||
"login": login,
|
||||
"name": name,
|
||||
"identity": login + "-identity",
|
||||
"mail": mail,
|
||||
},
|
||||
"team_names": teams,
|
||||
"created_at": "2026-06-10T00:00:00Z",
|
||||
}
|
||||
}
|
||||
|
||||
func fixtureTeam(id int, name, nickname, description, authorize string, units []string, users ...map[string]interface{}) map[string]interface{} {
|
||||
unitValues := make([]interface{}, 0, len(units))
|
||||
for _, unit := range units {
|
||||
unitValues = append(unitValues, unit)
|
||||
}
|
||||
userValues := make([]interface{}, 0, len(users))
|
||||
for _, user := range users {
|
||||
userValues = append(userValues, user)
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": float64(id),
|
||||
"name": name,
|
||||
"nickname": nickname,
|
||||
"description": description,
|
||||
"authorize": authorize,
|
||||
"includes_all_project": false,
|
||||
"can_create_org_project": true,
|
||||
"num_projects": float64(3),
|
||||
"num_users": float64(len(users)),
|
||||
"units": unitValues,
|
||||
"users": userValues,
|
||||
}
|
||||
}
|
||||
|
||||
func fixtureTeamUser(userID int, login, name, mail string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"user_id": float64(userID),
|
||||
"login": login,
|
||||
"name": name,
|
||||
"identity": login + "-identity",
|
||||
"mail": mail,
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgList(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("expected GET, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/organizations.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("page") != "2" || r.URL.Query().Get("limit") != "50" {
|
||||
t.Fatalf("unexpected query: %s", r.URL.RawQuery)
|
||||
}
|
||||
writeJSON(w, []interface{}{
|
||||
map[string]interface{}{"login": "org1"},
|
||||
map[string]interface{}{"login": "org2"},
|
||||
|
|
@ -53,16 +157,16 @@ func TestOrgList(t *testing.T) {
|
|||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"})
|
||||
if err != nil {
|
||||
if err := runShortcut(t, server, "list", map[string]string{"page": "2", "limit": "50"}); err != nil {
|
||||
t.Fatalf("list failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- info ---
|
||||
|
||||
func TestOrgInfo(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("expected GET, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/organizations/myorg.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
|
|
@ -70,137 +174,356 @@ func TestOrgInfo(t *testing.T) {
|
|||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "info", map[string]string{"id": "myorg"})
|
||||
if err != nil {
|
||||
if err := runShortcut(t, server, "info", map[string]string{"id": "myorg"}); err != nil {
|
||||
t.Fatalf("info failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgInfoRequiresID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("unexpected API request: %s %s", r.Method, r.URL.String())
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "info", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing organization id")
|
||||
}
|
||||
}
|
||||
|
||||
// --- members ---
|
||||
|
||||
func TestOrgMembers(t *testing.T) {
|
||||
func TestOrgMembersUsesRequestedPageWithoutFilters(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("expected GET, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/organizations/myorg/organization_users.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
writeJSON(w, []interface{}{
|
||||
map[string]interface{}{"login": "user1"},
|
||||
if r.URL.Query().Get("page") != "3" || r.URL.Query().Get("limit") != "2" {
|
||||
t.Fatalf("unexpected query: %s", r.URL.RawQuery)
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"organization_users": []interface{}{
|
||||
fixtureOrganizationUser(12, "alice", "Alice", "alice@example.com", "Platform"),
|
||||
},
|
||||
"total_count": float64(9),
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "members", map[string]string{"id": "myorg", "page": "1", "limit": "20"})
|
||||
output, err := runShortcutOutput(t, server, "members", map[string]string{
|
||||
"id": "myorg",
|
||||
"page": "3",
|
||||
"limit": "2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("members failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgMembersRequiresID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("unexpected API request: %s %s", r.Method, r.URL.String())
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "members", map[string]string{"page": "1", "limit": "20"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing organization id")
|
||||
env := parseEnvelope(t, output)
|
||||
data := env["data"].(map[string]interface{})
|
||||
if data["fetched_all"] != false {
|
||||
t.Fatalf("fetched_all = %v, want false", data["fetched_all"])
|
||||
}
|
||||
if data["total_count"].(float64) != 9 {
|
||||
t.Fatalf("total_count = %v, want 9", data["total_count"])
|
||||
}
|
||||
users := data["organization_users"].([]interface{})
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("organization_users len = %d, want 1", len(users))
|
||||
}
|
||||
user := users[0].(map[string]interface{})
|
||||
if user["login"] != "alice" {
|
||||
t.Fatalf("login = %v, want alice", user["login"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- teams ---
|
||||
|
||||
func TestOrgTeams(t *testing.T) {
|
||||
func TestOrgMembersFiltersFetchAllPages(t *testing.T) {
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if r.URL.Path != "/organizations/myorg/organization_users.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
switch r.URL.Query().Get("page") {
|
||||
case "1":
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"organization_users": []interface{}{
|
||||
fixtureOrganizationUser(11, "zoe", "Zoe", "zoe@example.com", "Ops"),
|
||||
},
|
||||
"total_count": float64(2),
|
||||
})
|
||||
case "2":
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"organization_users": []interface{}{
|
||||
fixtureOrganizationUser(12, "alice", "Alice", "alice@example.com", "Platform"),
|
||||
},
|
||||
"total_count": float64(2),
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected page: %s", r.URL.Query().Get("page"))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
output, err := runShortcutOutput(t, server, "members", map[string]string{
|
||||
"id": "myorg",
|
||||
"page": "1",
|
||||
"limit": "1",
|
||||
"keyword": "alice",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("members with filters failed: %v", err)
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Fatalf("callCount = %d, want 2", callCount)
|
||||
}
|
||||
|
||||
env := parseEnvelope(t, output)
|
||||
data := env["data"].(map[string]interface{})
|
||||
if data["fetched_all"] != true {
|
||||
t.Fatalf("fetched_all = %v, want true", data["fetched_all"])
|
||||
}
|
||||
if data["matched_count"].(float64) != 1 {
|
||||
t.Fatalf("matched_count = %v, want 1", data["matched_count"])
|
||||
}
|
||||
filters := data["filters"].(map[string]interface{})
|
||||
if filters["keyword"] != "alice" {
|
||||
t.Fatalf("keyword filter = %v, want alice", filters["keyword"])
|
||||
}
|
||||
counts := data["counts_by_team"].(map[string]interface{})
|
||||
if counts["Platform"].(float64) != 1 {
|
||||
t.Fatalf("counts_by_team[Platform] = %v, want 1", counts["Platform"])
|
||||
}
|
||||
users := data["organization_users"].([]interface{})
|
||||
user := users[0].(map[string]interface{})
|
||||
if user["login"] != "alice" {
|
||||
t.Fatalf("login = %v, want alice", user["login"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgTeamsFiltersAndIncludeUsers(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("expected GET, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/organizations/myorg/teams.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("page"); got != "2" {
|
||||
t.Fatalf("page query = %q, want 2", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("limit"); got != "50" {
|
||||
t.Fatalf("limit query = %q, want 50", got)
|
||||
}
|
||||
writeJSON(w, []interface{}{
|
||||
map[string]interface{}{"id": 1, "name": "maintainers"},
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"total_count": float64(2),
|
||||
"teams": []interface{}{
|
||||
fixtureTeam(1, "platform", "Platform", "Platform engineering team", "write", []string{"R&D"},
|
||||
fixtureTeamUser(102, "zoe", "Zoe", "zoe@example.com"),
|
||||
fixtureTeamUser(101, "alice", "Alice", "alice@example.com"),
|
||||
),
|
||||
fixtureTeam(2, "ops", "Ops", "Operations team", "read", []string{"Ops"},
|
||||
fixtureTeamUser(103, "bob", "Bob", "bob@example.com"),
|
||||
),
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "teams", map[string]string{"id": "myorg", "page": "2", "limit": "50"})
|
||||
output, err := runShortcutOutput(t, server, "teams", map[string]string{
|
||||
"id": "myorg",
|
||||
"authorize": "write",
|
||||
"keyword": "platform",
|
||||
"include-users": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("teams failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgTeamsRequiresID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("unexpected API request: %s %s", r.Method, r.URL.String())
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "teams", map[string]string{"page": "1", "limit": "20"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing organization id")
|
||||
env := parseEnvelope(t, output)
|
||||
data := env["data"].(map[string]interface{})
|
||||
if data["matched_count"].(float64) != 1 {
|
||||
t.Fatalf("matched_count = %v, want 1", data["matched_count"])
|
||||
}
|
||||
if data["include_users"] != true {
|
||||
t.Fatalf("include_users = %v, want true", data["include_users"])
|
||||
}
|
||||
counts := data["counts_by_authorize"].(map[string]interface{})
|
||||
if counts["write"].(float64) != 1 {
|
||||
t.Fatalf("counts_by_authorize[write] = %v, want 1", counts["write"])
|
||||
}
|
||||
teams := data["teams"].([]interface{})
|
||||
if len(teams) != 1 {
|
||||
t.Fatalf("teams len = %d, want 1", len(teams))
|
||||
}
|
||||
team := teams[0].(map[string]interface{})
|
||||
memberLogins := team["member_logins"].([]interface{})
|
||||
if memberLogins[0] != "alice" || memberLogins[1] != "zoe" {
|
||||
t.Fatalf("member_logins = %v, want sorted [alice zoe]", memberLogins)
|
||||
}
|
||||
users := team["users"].([]interface{})
|
||||
if users[0].(map[string]interface{})["login"] != "alice" {
|
||||
t.Fatalf("users not sorted by login: %v", users)
|
||||
}
|
||||
}
|
||||
|
||||
// --- create ---
|
||||
|
||||
func TestOrgCreate(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/organizations.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.Method != "POST" {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
payload := decodeJSONBody(t, r)
|
||||
if payload["name"] != "neworg" {
|
||||
t.Fatalf("name = %v, want neworg", payload["name"])
|
||||
}
|
||||
if payload["description"] != "A new org" {
|
||||
t.Fatalf("description = %v, want A new org", payload["description"])
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"login": "neworg"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "create", map[string]string{"name": "neworg", "description": "A new org"})
|
||||
if err != nil {
|
||||
if err := runShortcut(t, server, "create", map[string]string{"name": "neworg", "description": "A new org"}); err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgCreateNoDescription(t *testing.T) {
|
||||
func TestOrgTeamCreateDryRun(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]interface{}{"login": "neworg"})
|
||||
t.Fatal("no API call expected for dry-run")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "create", map[string]string{"name": "neworg"})
|
||||
output, err := runShortcutOutput(t, server, "team-create", map[string]string{
|
||||
"id": "myorg",
|
||||
"name": "platform",
|
||||
"nickname": "Platform",
|
||||
"description": "Core team",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
t.Fatalf("team-create dry-run failed: %v", err)
|
||||
}
|
||||
|
||||
env := parseEnvelope(t, output)
|
||||
data := env["data"].(map[string]interface{})
|
||||
if data["method"] != "POST" {
|
||||
t.Fatalf("method = %v, want POST", data["method"])
|
||||
}
|
||||
if data["path"] != "/organizations/myorg/teams" {
|
||||
t.Fatalf("path = %v, want /organizations/myorg/teams", data["path"])
|
||||
}
|
||||
payload := data["payload"].(map[string]interface{})
|
||||
if payload["name"] != "platform" || payload["nickname"] != "Platform" || payload["description"] != "Core team" {
|
||||
t.Fatalf("unexpected payload: %v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgCreateRequiresName(t *testing.T) {
|
||||
func TestOrgTeamCreatePostsPayload(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("unexpected API request: %s %s", r.Method, r.URL.String())
|
||||
if r.Method != http.MethodPost {
|
||||
t.Fatalf("expected POST, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/organizations/myorg/teams.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
payload := decodeJSONBody(t, r)
|
||||
if payload["name"] != "platform" {
|
||||
t.Fatalf("name = %v, want platform", payload["name"])
|
||||
}
|
||||
if payload["nickname"] != "Platform" {
|
||||
t.Fatalf("nickname = %v, want Platform", payload["nickname"])
|
||||
}
|
||||
if payload["description"] != "Core team" {
|
||||
t.Fatalf("description = %v, want Core team", payload["description"])
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"id": float64(7), "name": "platform"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "create", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing organization name")
|
||||
if err := runShortcut(t, server, "team-create", map[string]string{
|
||||
"id": "myorg",
|
||||
"name": "platform",
|
||||
"nickname": "Platform",
|
||||
"description": "Core team",
|
||||
}); err != nil {
|
||||
t.Fatalf("team-create failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTTP error paths ---
|
||||
func TestOrgMemberRemoveDryRunByLogin(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
t.Fatalf("expected GET during login resolution, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/organizations/myorg/organization_users.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"organization_users": []interface{}{
|
||||
fixtureOrganizationUser(9, "alice", "Alice", "alice@example.com", "Platform"),
|
||||
},
|
||||
"total_count": float64(1),
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
output, err := runShortcutOutput(t, server, "member-remove", map[string]string{
|
||||
"id": "myorg",
|
||||
"login": "alice",
|
||||
"dry-run": "true",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("member-remove dry-run failed: %v", err)
|
||||
}
|
||||
|
||||
env := parseEnvelope(t, output)
|
||||
data := env["data"].(map[string]interface{})
|
||||
if data["method"] != "DELETE" {
|
||||
t.Fatalf("method = %v, want DELETE", data["method"])
|
||||
}
|
||||
if data["path"] != "/organizations/myorg/organization_users/9" {
|
||||
t.Fatalf("path = %v, want /organizations/myorg/organization_users/9", data["path"])
|
||||
}
|
||||
member := data["member"].(map[string]interface{})
|
||||
if member["login"] != "alice" {
|
||||
t.Fatalf("member.login = %v, want alice", member["login"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgMemberRemoveByUserID(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
t.Fatalf("expected DELETE, got %s", r.Method)
|
||||
}
|
||||
if r.URL.Path != "/organizations/myorg/organization_users/9.json" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{"status": 0, "message": "removed"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runShortcut(t, server, "member-remove", map[string]string{
|
||||
"id": "myorg",
|
||||
"user-id": "9",
|
||||
}); err != nil {
|
||||
t.Fatalf("member-remove failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgMembersInvalidPage(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call expected")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "members", map[string]string{
|
||||
"id": "myorg",
|
||||
"page": "0",
|
||||
"limit": "20",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "--page must be a positive integer") {
|
||||
t.Fatalf("expected positive integer error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgMemberRemoveRequiresTarget(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("no API call expected")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "member-remove", map[string]string{"id": "myorg"})
|
||||
if err == nil || !strings.Contains(err.Error(), "one of --user-id or --login is required") {
|
||||
t.Fatalf("expected missing target error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgListHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -209,34 +532,7 @@ func TestOrgListHTTPError(t *testing.T) {
|
|||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 500")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgInfoHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("server error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "info", map[string]string{"id": "myorg"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 500")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgMembersHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("server error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "members", map[string]string{"id": "myorg", "page": "1", "limit": "20"})
|
||||
if err == nil {
|
||||
if err := runShortcut(t, server, "list", map[string]string{"page": "1", "limit": "20"}); err == nil {
|
||||
t.Fatal("expected error for HTTP 500")
|
||||
}
|
||||
}
|
||||
|
|
@ -248,21 +544,7 @@ func TestOrgTeamsHTTPError(t *testing.T) {
|
|||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "teams", map[string]string{"id": "myorg", "page": "1", "limit": "20"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 500")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrgCreateHTTPError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("server error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runShortcut(t, server, "create", map[string]string{"name": "neworg"})
|
||||
if err == nil {
|
||||
if err := runShortcut(t, server, "teams", map[string]string{"id": "myorg"}); err == nil {
|
||||
t.Fatal("expected error for HTTP 500")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue