diff --git a/README.md b/README.md index 2d0b398..e9b2f25 100644 --- a/README.md +++ b/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 diff --git a/doc/changes/org-team-shortcuts.md b/doc/changes/org-team-shortcuts.md index 05e3c78..c0d7413 100644 --- a/doc/changes/org-team-shortcuts.md +++ b/doc/changes/org-team-shortcuts.md @@ -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 ./... ``` diff --git a/shortcuts/org/org.go b/shortcuts/org/org.go index 645700f..c953573 100644 --- a/shortcuts/org/org.go +++ b/shortcuts/org/org.go @@ -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] diff --git a/shortcuts/org/org_test.go b/shortcuts/org/org_test.go index fc03085..f398d77 100644 --- a/shortcuts/org/org_test.go +++ b/shortcuts/org/org_test.go @@ -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") } }