diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 1ceb602..b95e7a6 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -1341,6 +1341,22 @@ Aliases ls, l ``` +### `azdo team show [ORGANIZATION/]PROJECT/TEAM [flags]` + +Show details of a team. + +``` +-q, --jq expression Filter JSON output using a jq expression + --json fields[=*] Output JSON with the specified fields. Prefix a field with '-' to exclude it. +-t, --template string Format JSON output using a Go template; see "azdo help formatting" +``` + +Aliases + +``` +s +``` + ### See also diff --git a/docs/azdo_team.md b/docs/azdo_team.md index fc5c157..a99a8f5 100644 --- a/docs/azdo_team.md +++ b/docs/azdo_team.md @@ -6,6 +6,7 @@ Manage Azure DevOps teams. * [azdo team create](./azdo_team_create.md) * [azdo team list](./azdo_team_list.md) +* [azdo team show](./azdo_team_show.md) ### ALIASES diff --git a/docs/azdo_team_show.md b/docs/azdo_team_show.md new file mode 100644 index 0000000..fc93fae --- /dev/null +++ b/docs/azdo_team_show.md @@ -0,0 +1,48 @@ +## Command `azdo team show` + +``` +azdo team show [ORGANIZATION/]PROJECT/TEAM [flags] +``` + +Show details of a single team in a project. The team is identified by its +name or GUID inside the project. The organization falls back to the +configured default when omitted. + + +### Options + + +* `-q`, `--jq` `expression` + + Filter JSON output using a jq expression + +* `--json` `fields` + + Output JSON with the specified fields. Prefix a field with '-' to exclude it. + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + + +### ALIASES + +- `s` + +### JSON Fields + +`description`, `id`, `identity`, `identityUrl`, `name`, `projectId`, `projectName`, `url` + +### Examples + +```bash +# Show a team by name in the default organization +azdo team show Fabrikam/"Fabrikam Engineering" + +# Show a team by ID in a specific organization +azdo team show MyOrg/Fabrikam/00000002-0000-0000-0000-000000000000 +``` + +### See also + +* [azdo team](./azdo_team.md) diff --git a/internal/cmd/team/show/show.go b/internal/cmd/team/show/show.go new file mode 100644 index 0000000..fe9aaba --- /dev/null +++ b/internal/cmd/team/show/show.go @@ -0,0 +1,154 @@ +package show + +import ( + _ "embed" + "fmt" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" + "github.com/spf13/cobra" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/iostreams" + "github.com/tmeckel/azdo-cli/internal/template" + "github.com/tmeckel/azdo-cli/internal/types" + "go.uber.org/zap" +) + +type showOptions struct { + targetArg string + exporter util.Exporter +} + +//go:embed show.tpl +var showTpl string + +type teamTemplateData struct { + Id string + Name string + Description string + ProjectId string + ProjectName string + Url string + Identity string +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &showOptions{} + + cmd := &cobra.Command{ + Use: "show [ORGANIZATION/]PROJECT/TEAM", + Short: "Show details of a team.", + Long: heredoc.Doc(` + Show details of a single team in a project. The team is identified by its + name or GUID inside the project. The organization falls back to the + configured default when omitted. + `), + Example: heredoc.Doc(` + # Show a team by name in the default organization + azdo team show Fabrikam/"Fabrikam Engineering" + + # Show a team by ID in a specific organization + azdo team show MyOrg/Fabrikam/00000002-0000-0000-0000-000000000000 + `), + Aliases: []string{"s"}, + Args: util.ExactArgs(1, "team argument required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetArg = args[0] + return runShow(ctx, opts) + }, + } + + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "id", "name", "description", "url", + "identity", "identityUrl", "projectId", "projectName", + }) + + return cmd +} + +func runShow(ctx util.CmdContext, opts *showOptions) error { + ios, err := ctx.IOStreams() + if err != nil { + return err + } + + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + scope, err := util.ParseProjectTargetWithDefaultOrganization(ctx, opts.targetArg) + if err != nil { + return util.FlagErrorWrap(err) + } + + zap.L().Debug("show team", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.String("teamId", scope.Targets[0]), + ) + + client, err := ctx.ClientFactory().Core(ctx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create Core client: %w", err) + } + + team, err := client.GetTeam(ctx.Context(), core.GetTeamArgs{ + ProjectId: &scope.Project, + TeamId: &scope.Targets[0], + }) + if err != nil { + return fmt.Errorf("failed to get team: %w", err) + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, team) + } + + return renderTeam(ctx, ios, team) +} + +func renderTeam(ctx util.CmdContext, ios *iostreams.IOStreams, team *core.WebApiTeam) error { + data := teamTemplateData{ + Id: types.GetValue(team.Id, uuid.UUID{}).String(), + Name: types.GetValue(team.Name, ""), + Description: types.GetValue(team.Description, ""), + ProjectId: types.GetValue(team.ProjectId, uuid.UUID{}).String(), + ProjectName: types.GetValue(team.ProjectName, ""), + Url: types.GetValue(team.Url, ""), + Identity: formatIdentity(team.Identity), + } + + t := template.New( + ios.Out, + ios.TerminalWidth(), + ios.ColorEnabled()). + WithTheme(ios.TerminalTheme()). + WithFuncs(map[string]any{ + "s": template.StringOrEmpty, + "hasText": template.HasText, + }) + + if err := t.Parse(showTpl); err != nil { + return err + } + + return t.ExecuteData(data) +} + +func formatIdentity(ident *identity.Identity) string { + if ident == nil { + return "" + } + displayName := types.GetValue(ident.ProviderDisplayName, "") + descriptor := types.GetValue(ident.Descriptor, "") + if displayName != "" && descriptor != "" { + return fmt.Sprintf("%s (%s)", displayName, descriptor) + } + if displayName != "" { + return displayName + } + return descriptor +} diff --git a/internal/cmd/team/show/show.tpl b/internal/cmd/team/show/show.tpl new file mode 100644 index 0000000..34f6ea1 --- /dev/null +++ b/internal/cmd/team/show/show.tpl @@ -0,0 +1,18 @@ +{{- if hasText .Url}} +{{bold "url:"}} {{hyperlink .Url .Url}} +{{- end}} +{{- if hasText .Id}} +{{bold "id:"}} {{.Id}} +{{- end}} +{{- if hasText .Name}} +{{bold "name:"}} {{s .Name}} +{{- end}} +{{- if hasText .Description}} +{{bold "description:"}} {{s .Description}} +{{- end}} +{{- if hasText .ProjectName}} +{{bold "project:"}} {{s .ProjectName}} {{if .ProjectId}}({{.ProjectId}}){{end}} +{{- end}} +{{- if hasText .Identity}} +{{bold "identity:"}} {{s .Identity}} +{{- end}} \ No newline at end of file diff --git a/internal/cmd/team/show/show_test.go b/internal/cmd/team/show/show_test.go new file mode 100644 index 0000000..dc7c85b --- /dev/null +++ b/internal/cmd/team/show/show_test.go @@ -0,0 +1,247 @@ +package show + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tmeckel/azdo-cli/internal/iostreams" + "github.com/tmeckel/azdo-cli/internal/mocks" + "github.com/tmeckel/azdo-cli/internal/types" + "go.uber.org/mock/gomock" +) + +type fakeShowDeps struct { + cmd *mocks.MockCmdContext + cfg *mocks.MockConfig + authCfg *mocks.MockAuthConfig + clientF *mocks.MockClientFactory + core *mocks.MockCoreClient + stdout *bytes.Buffer + ctrl *gomock.Controller +} + +func setupFakeDeps(t *testing.T, organization string) *fakeShowDeps { + t.Helper() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + deps := &fakeShowDeps{ + cmd: mocks.NewMockCmdContext(ctrl), + cfg: mocks.NewMockConfig(ctrl), + authCfg: mocks.NewMockAuthConfig(ctrl), + clientF: mocks.NewMockClientFactory(ctrl), + core: mocks.NewMockCoreClient(ctrl), + stdout: out, + ctrl: ctrl, + } + + deps.cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + deps.cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + deps.cmd.EXPECT().ClientFactory().Return(deps.clientF).AnyTimes() + deps.cmd.EXPECT().Config().Return(deps.cfg, nil).AnyTimes() + deps.cfg.EXPECT().Authentication().Return(deps.authCfg).AnyTimes() + deps.authCfg.EXPECT().GetDefaultOrganization().Return(organization, nil).AnyTimes() + + return deps +} + +func TestShow_MissingTeamArg(t *testing.T) { + deps := setupFakeDeps(t, "defaultOrg") + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{}) + err := cmd.Execute() + require.Error(t, err) + assert.ErrorContains(t, err, "team argument required") +} + +func TestShow_TargetArg_ParsesOrgSlashProjectSlashTeam(t *testing.T) { + deps := setupFakeDeps(t, "defaultOrg") + + teamUUID := uuid.MustParse("00000001-0000-0000-0000-000000000001") + deps.clientF.EXPECT().Core(gomock.Any(), "myOrg").Return(deps.core, nil) + deps.core.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.GetTeamArgs) (*core.WebApiTeam, error) { + assert.Equal(t, "myProject", *args.ProjectId) + assert.Equal(t, "My Team", *args.TeamId) + return &core.WebApiTeam{ + Id: &teamUUID, + Name: types.ToPtr("My Team"), + }, nil + }) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/My Team"}) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestShow_DefaultsToConfiguredOrganization(t *testing.T) { + deps := setupFakeDeps(t, "defaultOrg") + + teamUUID := uuid.MustParse("00000001-0000-0000-0000-000000000001") + deps.clientF.EXPECT().Core(gomock.Any(), "defaultOrg").Return(deps.core, nil) + deps.core.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.GetTeamArgs) (*core.WebApiTeam, error) { + assert.Equal(t, "myProject", *args.ProjectId) + assert.Equal(t, "My Team", *args.TeamId) + return &core.WebApiTeam{ + Id: &teamUUID, + Name: types.ToPtr("My Team"), + }, nil + }) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myProject/My Team"}) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestShow_TeamByGUID(t *testing.T) { + deps := setupFakeDeps(t, "myOrg") + + teamUUID := uuid.MustParse("00000002-0000-0000-0000-000000000000") + deps.clientF.EXPECT().Core(gomock.Any(), "myOrg").Return(deps.core, nil) + deps.core.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args core.GetTeamArgs) (*core.WebApiTeam, error) { + assert.Equal(t, "myProject", *args.ProjectId) + assert.Equal(t, "00000002-0000-0000-0000-000000000000", *args.TeamId) + return &core.WebApiTeam{ + Id: &teamUUID, + Name: types.ToPtr("My Team"), + }, nil + }) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/00000002-0000-0000-0000-000000000000"}) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestShow_JSONOutput(t *testing.T) { + deps := setupFakeDeps(t, "myOrg") + + teamUUID := uuid.MustParse("00000001-0000-0000-0000-000000000001") + deps.clientF.EXPECT().Core(gomock.Any(), "myOrg").Return(deps.core, nil) + deps.core.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + Return(&core.WebApiTeam{ + Id: &teamUUID, + Name: types.ToPtr("My Team"), + Description: types.ToPtr("A test team"), + Url: types.ToPtr("https://dev.azure.com/myOrg/_apis/teams/00000001-0000-0000-0000-000000000001"), + ProjectName: types.ToPtr("myProject"), + ProjectId: &teamUUID, + }, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/My Team", "--json"}) + err := cmd.Execute() + require.NoError(t, err) + + assert.Contains(t, deps.stdout.String(), `"id"`) + assert.Contains(t, deps.stdout.String(), `"name"`) + assert.Contains(t, deps.stdout.String(), `"description"`) + assert.Contains(t, deps.stdout.String(), `"My Team"`) +} + +func TestShow_TemplateOutput_BasicFields(t *testing.T) { + deps := setupFakeDeps(t, "myOrg") + + teamUUID := uuid.MustParse("00000001-0000-0000-0000-000000000001") + id := &identity.Identity{ + ProviderDisplayName: types.ToPtr("John Doe"), + Descriptor: types.ToPtr("john.doe@example.com"), + } + deps.clientF.EXPECT().Core(gomock.Any(), "myOrg").Return(deps.core, nil) + deps.core.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + Return(&core.WebApiTeam{ + Id: &teamUUID, + Name: types.ToPtr("My Team"), + Description: types.ToPtr("A test team"), + Url: types.ToPtr("https://dev.azure.com/myOrg/_apis/teams/00000001-0000-0000-0000-000000000001"), + ProjectName: types.ToPtr("myProject"), + ProjectId: &teamUUID, + Identity: id, + }, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/My Team"}) + err := cmd.Execute() + require.NoError(t, err) + + output := deps.stdout.String() + assert.Contains(t, output, "My Team") + assert.Contains(t, output, "A test team") + assert.Contains(t, output, "myProject") + assert.Contains(t, output, "John Doe") + assert.Contains(t, output, "john.doe@example.com") + + lines := splitLines(output) + assert.GreaterOrEqual(t, len(lines), 6, "expected at least 6 non-empty lines for 6 populated fields") +} + +func TestShow_TemplateOutput_OnlyPresentFields(t *testing.T) { + deps := setupFakeDeps(t, "myOrg") + + teamUUID := uuid.MustParse("00000001-0000-0000-0000-000000000001") + deps.clientF.EXPECT().Core(gomock.Any(), "myOrg").Return(deps.core, nil) + deps.core.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + Return(&core.WebApiTeam{ + Id: &teamUUID, + Name: types.ToPtr("Minimal Team"), + Url: types.ToPtr("https://dev.azure.com/myOrg/_apis/teams/00000001-0000-0000-0000-000000000001"), + }, nil) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/Minimal Team"}) + err := cmd.Execute() + require.NoError(t, err) + + output := deps.stdout.String() + assert.Contains(t, output, "Minimal Team") + assert.NotContains(t, output, "description:") + assert.NotContains(t, output, "project:") + assert.NotContains(t, output, "identity:") + + lines := splitLines(output) + assert.GreaterOrEqual(t, len(lines), 3, "expected at least 3 non-empty lines for 3 populated fields") + for _, line := range lines { + assert.True(t, strings.Contains(line, "url:") || strings.Contains(line, "id:") || strings.Contains(line, "name:"), + "unexpected label line: %q", line) + } +} + +func splitLines(s string) []string { + var lines []string + for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") { + if trimmed := strings.TrimSpace(line); trimmed != "" { + lines = append(lines, trimmed) + } + } + return lines +} + +func TestShow_PropagatesSDKError(t *testing.T) { + deps := setupFakeDeps(t, "myOrg") + + deps.clientF.EXPECT().Core(gomock.Any(), "myOrg").Return(deps.core, nil) + deps.core.EXPECT().GetTeam(gomock.Any(), gomock.Any()). + Return(nil, assert.AnError) + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{"myOrg/myProject/My Team"}) + err := cmd.Execute() + require.Error(t, err) + assert.ErrorContains(t, err, "failed to get team") +} diff --git a/internal/cmd/team/team.go b/internal/cmd/team/team.go index fbef998..64f5a33 100644 --- a/internal/cmd/team/team.go +++ b/internal/cmd/team/team.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/tmeckel/azdo-cli/internal/cmd/team/create" "github.com/tmeckel/azdo-cli/internal/cmd/team/list" + "github.com/tmeckel/azdo-cli/internal/cmd/team/show" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -19,6 +20,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.AddCommand(create.NewCmd(ctx)) cmd.AddCommand(list.NewCmd(ctx)) + cmd.AddCommand(show.NewCmd(ctx)) return cmd }