diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 1d04e557..2c9f3467 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -1039,6 +1039,22 @@ Configure default repository for this directory -v, --view view the current default repository ``` +### `azdo repo show [ORGANIZATION/]PROJECT/REPO_ID_OR_NAME [flags]` + +Show repository details + +``` +-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 + +``` +view, status +``` + ## `azdo security [flags]` Work with Azure DevOps security. diff --git a/docs/azdo_repo.md b/docs/azdo_repo.md index dca417f2..55e42712 100644 --- a/docs/azdo_repo.md +++ b/docs/azdo_repo.md @@ -11,6 +11,7 @@ Work with Azure DevOps Git repositories. * [azdo repo list](./azdo_repo_list.md) * [azdo repo restore](./azdo_repo_restore.md) * [azdo repo set-default](./azdo_repo_set-default.md) +* [azdo repo show](./azdo_repo_show.md) ### ALIASES diff --git a/docs/azdo_repo_show.md b/docs/azdo_repo_show.md new file mode 100644 index 00000000..ed185a77 --- /dev/null +++ b/docs/azdo_repo_show.md @@ -0,0 +1,50 @@ +## Command `azdo repo show` + +``` +azdo repo show [ORGANIZATION/]PROJECT/REPO_ID_OR_NAME [flags] +``` + +Display the details of a single Azure DevOps Git repository. + +The repository is identified by name or ID. The organization segment is optional when a +default organization is configured. + + +### 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 + +- `view` +- `status` + +### JSON Fields + +`_links`, `defaultBranch`, `id`, `isDisabled`, `isFork`, `isInMaintenance`, `name`, `parentRepository`, `project`, `properties`, `remoteUrl`, `size`, `sshUrl`, `url`, `validRemoteUrls`, `webUrl` + +### Examples + +```bash +# Show a repository by name +azdo repo show Fabrikam/my-repo + +# Show a repository by ID +azdo repo show myorg/Fabrikam/00000000-0000-0000-0000-000000000000 +``` + +### See also + +* [azdo repo](./azdo_repo.md) diff --git a/internal/cmd/repo/repo.go b/internal/cmd/repo/repo.go index abb5aa38..90ab370d 100644 --- a/internal/cmd/repo/repo.go +++ b/internal/cmd/repo/repo.go @@ -10,6 +10,7 @@ import ( "github.com/tmeckel/azdo-cli/internal/cmd/repo/list" "github.com/tmeckel/azdo-cli/internal/cmd/repo/restore" "github.com/tmeckel/azdo-cli/internal/cmd/repo/setdefault" + "github.com/tmeckel/azdo-cli/internal/cmd/repo/show" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -44,5 +45,6 @@ func NewCmdRepo(ctx util.CmdContext) *cobra.Command { cmd.AddCommand(create.NewCmd(ctx)) cmd.AddCommand(delete.NewCmd(ctx)) cmd.AddCommand(edit.NewCmd(ctx)) + cmd.AddCommand(show.NewCmd(ctx)) return cmd } diff --git a/internal/cmd/repo/show/show.go b/internal/cmd/repo/show/show.go new file mode 100644 index 00000000..6dbf8531 --- /dev/null +++ b/internal/cmd/repo/show/show.go @@ -0,0 +1,174 @@ +package show + +import ( + _ "embed" + "fmt" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git" + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/template" +) + +type showOptions struct { + targetArg string + exporter util.Exporter +} + +//go:embed show.tpl +var showTpl string + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &showOptions{} + + cmd := &cobra.Command{ + Use: "show [ORGANIZATION/]PROJECT/REPO_ID_OR_NAME", + Short: "Show repository details", + Long: heredoc.Doc(` + Display the details of a single Azure DevOps Git repository. + + The repository is identified by name or ID. The organization segment is optional when a + default organization is configured. + `), + Example: heredoc.Doc(` + # Show a repository by name + azdo repo show Fabrikam/my-repo + + # Show a repository by ID + azdo repo show myorg/Fabrikam/00000000-0000-0000-0000-000000000000 + `), + Aliases: []string{"view", "status"}, + Args: util.ExactArgs(1, "target argument is required and must be in the form [ORGANIZATION/]PROJECT/REPO_ID_OR_NAME"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetArg = args[0] + return runShow(ctx, opts) + }, + } + + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "id", + "name", + "defaultBranch", + "remoteUrl", + "sshUrl", + "webUrl", + "url", + "project", + "parentRepository", + "size", + "isDisabled", + "isFork", + "isInMaintenance", + "validRemoteUrls", + "properties", + "_links", + }) + + return cmd +} + +func runShow(cmdCtx util.CmdContext, opts *showOptions) error { + ios, err := cmdCtx.IOStreams() + if err != nil { + return err + } + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + scope, err := util.ParseProjectTargetWithDefaultOrganization(cmdCtx, opts.targetArg) + if err != nil { + return util.FlagErrorWrap(err) + } + if len(scope.Targets) == 0 { + return util.FlagErrorf("repository target is required") + } + repoIDOrName := scope.Targets[0] + if repoIDOrName == "" { + return util.FlagErrorf("repository target is required") + } + + gitClient, err := cmdCtx.ClientFactory().Git(cmdCtx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create git client: %w", err) + } + + zap.L().Debug( + "fetching repository", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.String("repository", repoIDOrName), + ) + + repo, err := gitClient.GetRepository(cmdCtx.Context(), git.GetRepositoryArgs{ + RepositoryId: &repoIDOrName, + Project: &scope.Project, + }) + if err != nil { + return fmt.Errorf("failed to get repository: %w", err) + } + if repo == nil { + return fmt.Errorf("repository %q not found", repoIDOrName) + } + + if opts.exporter != nil { + ios.StopProgressIndicator() + return opts.exporter.Write(ios, repo) + } + + ios.StopProgressIndicator() + + t := template.New( + ios.Out, + ios.TerminalWidth(), + ios.ColorEnabled(), + ). + WithTheme(ios.TerminalTheme()). + WithFuncs(map[string]any{ + "hasBool": func(v *bool) bool { return v != nil }, + "hasText": template.HasText, + "parent": func(repository *git.GitRepository) string { + if repository == nil || repository.IsFork == nil || !*repository.IsFork || repository.ParentRepository == nil { + return "" + } + parent := repository.ParentRepository + name := template.StringOrEmpty(parent.Name) + if parent.Id == nil { + return name + } + if name == "" { + return parent.Id.String() + } + return fmt.Sprintf("%s (%s)", name, parent.Id.String()) + }, + "s": template.StringOrEmpty, + "b": template.BoolString, + "u": template.UUIDString, + "size": func(size *uint64) string { + if size == nil { + return "" + } + + const unit = 1024 + value := float64(*size) + units := []string{"B", "KB", "MB", "GB"} + unitIndex := 0 + for value >= unit && unitIndex < len(units)-1 { + value /= unit + unitIndex++ + } + if unitIndex == 0 { + return fmt.Sprintf("%d B", *size) + } + return fmt.Sprintf("%.1f %s", value, units[unitIndex]) + }, + }) + + if err := t.Parse(showTpl); err != nil { + return err + } + + return t.ExecuteData(repo) +} diff --git a/internal/cmd/repo/show/show.tpl b/internal/cmd/repo/show/show.tpl new file mode 100644 index 00000000..87a194a6 --- /dev/null +++ b/internal/cmd/repo/show/show.tpl @@ -0,0 +1,13 @@ +{{bold "url:"}} {{hyperlink (s .Url) (s .Url)}} +{{bold "id:"}} {{u .Id}} +{{bold "name:"}} {{s .Name}} +{{bold "project:"}} {{s .Project.Name}}{{if hasText (u .Project.Id)}} ({{u .Project.Id}}){{end}} +{{if hasText (s .DefaultBranch)}}{{bold "default branch:"}} {{s .DefaultBranch}}{{end}} +{{bold "remote url:"}} {{s .RemoteUrl}} +{{bold "ssh url:"}} {{s .SshUrl}} +{{bold "web url:"}} {{s .WebUrl}} +{{bold "is fork:"}} {{b .IsFork}} +{{if hasText (parent .)}}{{bold " parent:"}} {{parent .}}{{end}} +{{if hasBool .IsDisabled}}{{bold "is disabled:"}} {{b .IsDisabled}}{{end}} +{{if hasBool .IsInMaintenance}}{{bold "is in maintenance:"}} {{b .IsInMaintenance}}{{end}} +{{if .Size}}{{bold "size:"}} {{size .Size}}{{end}} diff --git a/internal/cmd/repo/show/show_test.go b/internal/cmd/repo/show/show_test.go new file mode 100644 index 00000000..f33eeb9e --- /dev/null +++ b/internal/cmd/repo/show/show_test.go @@ -0,0 +1,345 @@ +package show + +import ( + "bytes" + "context" + "errors" + "regexp" + "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/git" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/iostreams" + "github.com/tmeckel/azdo-cli/internal/mocks" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type dependencies struct { + ctrl *gomock.Controller + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + gitClient *mocks.MockAzDOGitClient + config *mocks.MockConfig + auth *mocks.MockAuthConfig + ios *iostreams.IOStreams + stdout *bytes.Buffer + stderr *bytes.Buffer +} + +var ( + ansiRegexp = regexp.MustCompile(`\x1b\[[0-9;]*m`) + osc8Regexp = regexp.MustCompile(`\x1b]8;;[^\x1b]*\x1b\\`) +) + +func cleanOutput(out *bytes.Buffer) string { + cleaned := ansiRegexp.ReplaceAllString(out.String(), "") + return osc8Regexp.ReplaceAllString(cleaned, "") +} + +func newDependencies(t *testing.T) *dependencies { + t.Helper() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, errOut := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStderrTTY(true) + + deps := &dependencies{ + ctrl: ctrl, + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + gitClient: mocks.NewMockAzDOGitClient(ctrl), + config: mocks.NewMockConfig(ctrl), + auth: mocks.NewMockAuthConfig(ctrl), + ios: io, + stdout: out, + stderr: errOut, + } + + deps.cmd.EXPECT().IOStreams().Return(deps.ios, nil).AnyTimes() + deps.cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + deps.cmd.EXPECT().ClientFactory().Return(deps.clientFact).AnyTimes() + + return deps +} + +func (d *dependencies) setupDefaultOrg(org string) { + d.cmd.EXPECT().Config().Return(d.config, nil).AnyTimes() + d.config.EXPECT().Authentication().Return(d.auth).AnyTimes() + d.auth.EXPECT().GetDefaultOrganization().Return(org, nil).AnyTimes() +} + +func sampleRepo() *git.GitRepository { + projectID := uuid.MustParse("11111111-1111-1111-1111-111111111111") + repoID := uuid.MustParse("22222222-2222-2222-2222-222222222222") + parentID := uuid.MustParse("33333333-3333-3333-3333-333333333333") + + return &git.GitRepository{ + Id: &repoID, + Name: types.ToPtr("demo-repo"), + DefaultBranch: types.ToPtr("refs/heads/main"), + RemoteUrl: types.ToPtr("https://dev.azure.com/myorg/Fabrikam/_git/demo-repo"), + SshUrl: types.ToPtr("git@ssh.dev.azure.com:v3/myorg/Fabrikam/demo-repo"), + WebUrl: types.ToPtr("https://dev.azure.com/myorg/Fabrikam/_git/demo-repo"), + Url: types.ToPtr("https://dev.azure.com/myorg/_apis/git/repositories/22222222-2222-2222-2222-222222222222"), + Project: &core.TeamProjectReference{ + Id: &projectID, + Name: types.ToPtr("Fabrikam"), + }, + ParentRepository: &git.GitRepositoryRef{ + Id: &parentID, + Name: types.ToPtr("upstream-repo"), + }, + IsDisabled: types.ToPtr(false), + IsFork: types.ToPtr(true), + IsInMaintenance: types.ToPtr(false), + Size: types.ToPtr(uint64(1258291)), + ValidRemoteUrls: &[]string{ + "https://dev.azure.com/myorg/Fabrikam/_git/demo-repo", + }, + Links: map[string]any{ + "web": map[string]any{ + "href": "https://dev.azure.com/myorg/Fabrikam/_git/demo-repo", + }, + }, + } +} + +func TestNewCmd_RegistersAsShowLeaf(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + assert.Equal(t, "show", cmd.Name()) + assert.Contains(t, cmd.Aliases, "view") + assert.Contains(t, cmd.Aliases, "status") + assert.True(t, strings.HasPrefix(cmd.Use, "show [ORGANIZATION/]PROJECT/REPO_ID_OR_NAME")) + assert.NotNil(t, cmd.RunE) + assert.NotNil(t, cmd.Flag("json")) +} + +func TestNewCmd_RequiresOneArg(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + deps.setupDefaultOrg("myorg") + + cmd := NewCmd(deps.cmd) + cmd.SetArgs([]string{}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "target argument is required") +} + +func TestRunShow_TemplateOutput_BasicFields(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + deps.clientFact.EXPECT().Git(gomock.Any(), "myorg").Return(deps.gitClient, nil) + deps.gitClient.EXPECT().GetRepository(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args git.GetRepositoryArgs) (*git.GitRepository, error) { + assert.Equal(t, "demo-repo", *args.RepositoryId) + assert.Equal(t, "Fabrikam", *args.Project) + return sampleRepo(), nil + }) + + opts := &showOptions{targetArg: "myorg/Fabrikam/demo-repo"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.Contains(t, output, "url:") + assert.Contains(t, output, "https://dev.azure.com/myorg/_apis/git/repositories/22222222-2222-2222-2222-222222222222") + assert.Contains(t, output, "id: 22222222-2222-2222-2222-222222222222") + assert.Contains(t, output, "name: demo-repo") + assert.Contains(t, output, "project: Fabrikam (11111111-1111-1111-1111-111111111111)") + assert.Contains(t, output, "default branch: refs/heads/main") + assert.Contains(t, output, "remote url: https://dev.azure.com/myorg/Fabrikam/_git/demo-repo") + assert.Contains(t, output, "ssh url: git@ssh.dev.azure.com:v3/myorg/Fabrikam/demo-repo") + assert.Contains(t, output, "is fork: true") + assert.Contains(t, output, "parent: upstream-repo (33333333-3333-3333-3333-333333333333)") + assert.Contains(t, output, "is disabled: false") + assert.Contains(t, output, "is in maintenance: false") + assert.Contains(t, output, "size: 1.2 MB") +} + +func TestRunShow_TemplateOutput_OptionalFieldsOmitted(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + deps.clientFact.EXPECT().Git(gomock.Any(), "myorg").Return(deps.gitClient, nil) + repo := sampleRepo() + repo.DefaultBranch = nil + repo.ParentRepository = nil + repo.IsFork = types.ToPtr(false) + repo.IsDisabled = nil + repo.IsInMaintenance = nil + repo.Size = nil + deps.gitClient.EXPECT().GetRepository(gomock.Any(), gomock.Any()).Return(repo, nil) + + opts := &showOptions{targetArg: "myorg/Fabrikam/demo-repo"} + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.NotContains(t, output, "default branch:") + assert.NotContains(t, output, "parent:") + assert.NotContains(t, output, "is disabled:") + assert.NotContains(t, output, "is in maintenance:") + assert.NotContains(t, output, "size:") +} + +func TestRunShow_ProjectScopeParsing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + targetArg string + defaultOrg string + wantOrg string + wantErr string + }{ + { + name: "explicit org", + targetArg: "myorg/Fabrikam/demo-repo", + wantOrg: "myorg", + }, + { + name: "implicit org from config", + targetArg: "Fabrikam/demo-repo", + defaultOrg: "default-org", + wantOrg: "default-org", + }, + { + name: "missing project segment", + targetArg: "demo-repo", + wantErr: "invalid input", + }, + { + name: "too many segments", + targetArg: "org/proj/repo/extra", + wantErr: "invalid input", + }, + { + name: "empty repo segment", + targetArg: "org/Fabrikam/", + wantErr: "input", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := newDependencies(t) + if tt.defaultOrg != "" { + deps.setupDefaultOrg(tt.defaultOrg) + } + if tt.wantErr == "" { + deps.clientFact.EXPECT().Git(gomock.Any(), tt.wantOrg).Return(deps.gitClient, nil) + deps.gitClient.EXPECT().GetRepository(gomock.Any(), gomock.Any()).Return(sampleRepo(), nil) + } + + opts := &showOptions{targetArg: tt.targetArg} + err := runShow(deps.cmd, opts) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func TestRunShow_ErrorPaths(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + targetArg string + setup func(*dependencies) error + wantErr string + wantErrIs error + defaultOrg string + }{ + { + name: "client factory error", + targetArg: "myorg/Fabrikam/demo-repo", + setup: func(deps *dependencies) error { + expectedErr := errors.New("connection failed") + deps.clientFact.EXPECT().Git(gomock.Any(), "myorg").Return(nil, expectedErr) + return expectedErr + }, + wantErrIs: errors.New("connection failed"), + }, + { + name: "sdk error", + targetArg: "myorg/Fabrikam/demo-repo", + setup: func(deps *dependencies) error { + expectedErr := errors.New("API error") + deps.clientFact.EXPECT().Git(gomock.Any(), "myorg").Return(deps.gitClient, nil) + deps.gitClient.EXPECT().GetRepository(gomock.Any(), gomock.Any()).Return(nil, expectedErr) + return expectedErr + }, + wantErrIs: errors.New("API error"), + }, + { + name: "nil repo", + targetArg: "myorg/Fabrikam/demo-repo", + setup: func(deps *dependencies) error { + deps.clientFact.EXPECT().Git(gomock.Any(), "myorg").Return(deps.gitClient, nil) + deps.gitClient.EXPECT().GetRepository(gomock.Any(), gomock.Any()).Return(nil, nil) + return nil + }, + wantErr: `repository "demo-repo" not found`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := newDependencies(t) + if tt.defaultOrg != "" { + deps.setupDefaultOrg(tt.defaultOrg) + } + expectedErr := tt.setup(deps) + + err := runShow(deps.cmd, &showOptions{targetArg: tt.targetArg}) + require.Error(t, err) + if tt.wantErr != "" { + assert.Contains(t, err.Error(), tt.wantErr) + } + if expectedErr != nil { + assert.ErrorIs(t, err, expectedErr) + } + }) + } +} + +func TestRunShow_JSONOutput(t *testing.T) { + t.Parallel() + + deps := newDependencies(t) + deps.clientFact.EXPECT().Git(gomock.Any(), "myorg").Return(deps.gitClient, nil) + deps.gitClient.EXPECT().GetRepository(gomock.Any(), gomock.Any()).Return(sampleRepo(), nil) + + opts := &showOptions{ + targetArg: "myorg/Fabrikam/demo-repo", + exporter: util.NewJSONExporter(), + } + err := runShow(deps.cmd, opts) + require.NoError(t, err) + + output := cleanOutput(deps.stdout) + assert.Contains(t, output, `"id":"22222222-2222-2222-2222-222222222222"`) + assert.Contains(t, output, `"name":"demo-repo"`) + assert.Contains(t, output, `"defaultBranch":"refs/heads/main"`) + assert.Contains(t, output, `"project"`) + assert.Contains(t, output, `"parentRepository"`) + assert.NotContains(t, output, `"templateData"`) +}