diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index aeb430eb..08852c1a 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -369,6 +369,22 @@ Aliases l, ls ``` +#### `azdo pipelines runs show [ORGANIZATION/]PROJECT RUN_ID [flags]` + +Show details of a pipeline run + +``` +-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 pipelines variable-group` Manage Azure DevOps variable groups diff --git a/docs/azdo_pipelines_runs.md b/docs/azdo_pipelines_runs.md index 2bf783e7..63b51b55 100644 --- a/docs/azdo_pipelines_runs.md +++ b/docs/azdo_pipelines_runs.md @@ -5,6 +5,7 @@ Manage pipeline runs in an Azure DevOps project. ### Available commands * [azdo pipelines runs list](./azdo_pipelines_runs_list.md) +* [azdo pipelines runs show](./azdo_pipelines_runs_show.md) ### See also diff --git a/docs/azdo_pipelines_runs_show.md b/docs/azdo_pipelines_runs_show.md new file mode 100644 index 00000000..b7a890c2 --- /dev/null +++ b/docs/azdo_pipelines_runs_show.md @@ -0,0 +1,52 @@ +## Command `azdo pipelines runs show` + +``` +azdo pipelines runs show [ORGANIZATION/]PROJECT RUN_ID [flags] +``` + +Display the details of a single Azure Pipelines run. + +Mirrors 'az pipelines runs show'. + + +### 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 + +`buildNumber`, `definition`, `finishTime`, `id`, `lastChangedBy`, `parameters`, `priority`, `queue`, `queueTime`, `reason`, `requestedBy`, `requestedFor`, `result`, `retainedByRelease`, `sourceBranch`, `sourceVersion`, `startTime`, `status`, `tags`, `triggerInfo`, `url` + +### Examples + +```bash +# Show a run by ID using the default organization +azdo pipelines runs show Fabrikam 12345 + +# Show a run by ID with explicit organization +azdo pipelines runs show MyOrg/Fabrikam 12345 + +# Export as JSON +azdo pipelines runs show Fabrikam 12345 --json id,buildNumber,status,result +``` + +### See also + +* [azdo pipelines runs](./azdo_pipelines_runs.md) diff --git a/internal/cmd/pipelines/runs/runs.go b/internal/cmd/pipelines/runs/runs.go index 2bf8b6f6..d6a95310 100644 --- a/internal/cmd/pipelines/runs/runs.go +++ b/internal/cmd/pipelines/runs/runs.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/runs/list" + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/runs/show" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -15,5 +16,6 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { } cmd.AddCommand(list.NewCmd(ctx)) + cmd.AddCommand(show.NewCmd(ctx)) return cmd } diff --git a/internal/cmd/pipelines/runs/show/show.go b/internal/cmd/pipelines/runs/show/show.go new file mode 100644 index 00000000..bc236c6d --- /dev/null +++ b/internal/cmd/pipelines/runs/show/show.go @@ -0,0 +1,151 @@ +package show + +import ( + _ "embed" + "fmt" + "strconv" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" + + "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 { + exporter util.Exporter + scopeArg string +} + +//go:embed show.tpl +var showTmpl string + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &showOptions{} + + cmd := &cobra.Command{ + Use: "show [ORGANIZATION/]PROJECT RUN_ID", + Short: "Show details of a pipeline run", + Long: heredoc.Doc(` + Display the details of a single Azure Pipelines run. + + Mirrors 'az pipelines runs show'. + `), + Example: heredoc.Doc(` + # Show a run by ID using the default organization + azdo pipelines runs show Fabrikam 12345 + + # Show a run by ID with explicit organization + azdo pipelines runs show MyOrg/Fabrikam 12345 + + # Export as JSON + azdo pipelines runs show Fabrikam 12345 --json id,buildNumber,status,result + `), + Aliases: []string{"view", "status"}, + Args: util.ExactArgs(2, "project and run id required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.scopeArg = args[0] + rawID := args[1] + return runShow(ctx, opts, rawID) + }, + } + + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "id", "buildNumber", "status", "result", "queueTime", "startTime", "finishTime", + "url", "definition", "queue", "requestedBy", "requestedFor", "lastChangedBy", + "sourceVersion", "sourceBranch", "reason", "priority", "tags", "parameters", + "triggerInfo", "retainedByRelease", + }) + + return cmd +} + +func runShow(ctx util.CmdContext, opts *showOptions, rawID string) error { + ios, err := ctx.IOStreams() + if err != nil { + return err + } + + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + runID, err := strconv.Atoi(rawID) + if err != nil { + return util.FlagErrorf("invalid run id %q: must be an integer", rawID) + } + if runID <= 0 { + return util.FlagErrorf("invalid run id %q: must be a positive integer", rawID) + } + scope, err := util.ParseProjectScope(ctx, opts.scopeArg) + if err != nil { + return util.FlagErrorWrap(err) + } + + zap.L().Debug( + "fetching pipeline run", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.Int("runId", runID), + ) + + client, err := ctx.ClientFactory().Build(ctx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create Build client: %w", err) + } + + project := scope.Project + res, err := client.GetBuild(ctx.Context(), build.GetBuildArgs{ + Project: &project, + BuildId: &runID, + }) + if err != nil { + return fmt.Errorf("GetBuild: %w", err) + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, res) + } + + t := template.New( + ios.Out, + ios.TerminalWidth(), + ios.ColorEnabled(), + ). + WithTheme(ios.TerminalTheme()). + WithFuncs(map[string]any{ + "formatEntity": func(primary, secondary any) string { + first := template.StringOrEmpty(primary) + second := template.StringOrEmpty(secondary) + switch { + case first != "" && second != "": + return fmt.Sprintf("%s (%s)", first, second) + case first != "": + return first + default: + return second + } + }, + "formatDuration": func(start, finish *azuredevops.Time) string { + if start == nil || finish == nil { + return "" + } + return template.FormatDuration(finish.Time.Sub(start.Time)) + }, + "hasItems": template.HasItems, + "hasText": template.HasText, + "s": template.StringOrEmpty, + }) + + err = t.Parse(showTmpl) + if err != nil { + return err + } + + return t.ExecuteData(res) +} diff --git a/internal/cmd/pipelines/runs/show/show.tpl b/internal/cmd/pipelines/runs/show/show.tpl new file mode 100644 index 00000000..cc946672 --- /dev/null +++ b/internal/cmd/pipelines/runs/show/show.tpl @@ -0,0 +1,54 @@ +{{- if hasText .Url }} +{{bold "url:"}} {{hyperlink (s .Url) (s .Url)}} +{{- end }} +{{- if hasText .Id }} +{{bold "id:"}} {{.Id}} +{{- end }} +{{- if hasText .BuildNumber }} +{{bold "build number:"}} {{s .BuildNumber}} +{{- end }} +{{- if hasText .Status }} +{{bold "status:"}} {{s .Status}} +{{- end }} +{{- if and (eq (s .Status) "completed") (hasText .Result) }} +{{bold "result:"}} {{s .Result}} +{{- end }} +{{- if hasText .Reason }} +{{bold "reason:"}} {{s .Reason}} +{{- end }} +{{- if and .Definition (hasText (formatEntity .Definition.Name .Definition.Id)) }} +{{bold "definition:"}} {{formatEntity .Definition.Name .Definition.Id}} +{{- end }} +{{- if and .Queue (hasText (formatEntity .Queue.Name .Queue.Id)) }} +{{bold "queue:"}} {{formatEntity .Queue.Name .Queue.Id}} +{{- end }} +{{- if hasText .SourceBranch }} +{{bold "source branch:"}} {{s .SourceBranch}} +{{- end }} +{{- if hasText .SourceVersion }} +{{bold "source version:"}} {{truncate 8 (s .SourceVersion)}} +{{- end }} +{{- if and .RequestedBy (hasText (formatEntity .RequestedBy.DisplayName .RequestedBy.UniqueName)) }} +{{bold "requested by:"}} {{formatEntity .RequestedBy.DisplayName .RequestedBy.UniqueName}} +{{- end }} +{{- if and .RequestedFor (hasText (formatEntity .RequestedFor.DisplayName .RequestedFor.UniqueName)) }} +{{bold "requested for:"}} {{formatEntity .RequestedFor.DisplayName .RequestedFor.UniqueName}} +{{- end }} +{{- if hasText .Priority }} +{{bold "priority:"}} {{s .Priority}} +{{- end }} +{{- if .QueueTime }} +{{bold "queue time:"}} {{timeago .QueueTime.Time}} ({{timefmt "2006-01-02 15:04:05" .QueueTime.Time}}) +{{- end }} +{{- if .StartTime }} +{{bold "start time:"}} {{timeago .StartTime.Time}} ({{timefmt "2006-01-02 15:04:05" .StartTime.Time}}) +{{- end }} +{{- if .FinishTime }} +{{bold "finish time:"}} {{timeago .FinishTime.Time}} ({{timefmt "2006-01-02 15:04:05" .FinishTime.Time}}) +{{- end }} +{{- if and .StartTime .FinishTime }} +{{bold "duration:"}} {{formatDuration .StartTime .FinishTime}} +{{- end }} +{{- if hasItems .Tags }} +{{bold "tags:"}}{{range $i, $tag := .Tags}}{{if gt $i 0}}; {{end}}{{$tag}}{{end}} +{{- end }} diff --git a/internal/cmd/pipelines/runs/show/show_test.go b/internal/cmd/pipelines/runs/show/show_test.go new file mode 100644 index 00000000..57afe6d2 --- /dev/null +++ b/internal/cmd/pipelines/runs/show/show_test.go @@ -0,0 +1,437 @@ +package show + +import ( + "bytes" + "context" + "strconv" + "testing" + "time" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" + "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 deps struct { + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + build *mocks.MockBuildClient + cfg *mocks.MockConfig + auth *mocks.MockAuthConfig + stdout *bytes.Buffer +} + +func newDeps(t *testing.T, organization string) *deps { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + d := &deps{ + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + build: mocks.NewMockBuildClient(ctrl), + cfg: mocks.NewMockConfig(ctrl), + auth: mocks.NewMockAuthConfig(ctrl), + stdout: out, + } + + d.cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + d.cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + d.cmd.EXPECT().ClientFactory().Return(d.clientFact).AnyTimes() + d.clientFact.EXPECT().Build(gomock.Any(), organization).Return(d.build, nil).AnyTimes() + + return d +} + +func newDepsWithCfg(t *testing.T, defaultOrg string) *deps { + d := newDeps(t, defaultOrg) + d.cmd.EXPECT().Config().Return(d.cfg, nil).AnyTimes() + d.cfg.EXPECT().Authentication().Return(d.auth).AnyTimes() + d.auth.EXPECT().GetDefaultOrganization().Return(defaultOrg, nil).AnyTimes() + return d +} + +func sampleBuild(id int) build.Build { + idPtr := id + bnum := "20240101." + strconv.Itoa(id%10) + sourceBranch := "refs/heads/main" + sourceVersion := "abc12345def" + dispName := "Alice" + uniqName := "alice@x.com" + pipelineName := "MyPipeline" + queueName := "MyQueue" + queueID := 42 + urlStr := "https://dev.azure.com/myorg/fabrikam/_build/results?buildId=" + strconv.Itoa(id) + return build.Build{ + Id: &idPtr, + BuildNumber: &bnum, + Status: &build.BuildStatusValues.Completed, + Result: &build.BuildResultValues.Succeeded, + Reason: &build.BuildReasonValues.Manual, + Definition: &build.DefinitionReference{Name: &pipelineName, Id: &idPtr}, + Queue: &build.AgentPoolQueue{Name: &queueName, Id: &queueID}, + SourceBranch: &sourceBranch, + SourceVersion: &sourceVersion, + RequestedBy: &webapi.IdentityRef{DisplayName: &dispName, UniqueName: &uniqName}, + RequestedFor: &webapi.IdentityRef{DisplayName: &dispName, UniqueName: &uniqName}, + Priority: types.ToPtr(build.QueuePriority("normal")), + QueueTime: &azuredevops.Time{}, + StartTime: &azuredevops.Time{}, + FinishTime: &azuredevops.Time{}, + Url: &urlStr, + } +} + +type spyExporter struct { + writes int + got any +} + +func (s *spyExporter) Fields() []string { return nil } + +func (s *spyExporter) Write(_ *iostreams.IOStreams, v any) error { + s.writes++ + s.got = v + return nil +} + +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.Contains(t, cmd.Use, "[ORGANIZATION/]PROJECT RUN_ID") +} + +func TestRunShow_RunIDMustBeInteger(t *testing.T) { + t.Parallel() + d := newDeps(t, "MyOrg") + + err := runShow(d.cmd, &showOptions{scopeArg: "MyOrg/Fabrikam"}, "abc") + require.Error(t, err) + var fe *util.FlagError + assert.ErrorAs(t, err, &fe) +} + +func TestRunShow_RunIDMustBePositive(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rawID string + }{ + {name: "zero", rawID: "0"}, + {name: "negative", rawID: "-1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := newDeps(t, "MyOrg") + err := runShow(d.cmd, &showOptions{scopeArg: "MyOrg/Fabrikam"}, tt.rawID) + require.Error(t, err) + var fe *util.FlagError + assert.ErrorAs(t, err, &fe) + }) + } +} + +func TestRunShow_BasicCall(t *testing.T) { + t.Parallel() + d := newDeps(t, "MyOrg") + + buildObj := sampleBuild(12345) + d.build.EXPECT().GetBuild(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args build.GetBuildArgs) (*build.Build, error) { + assert.Equal(t, 12345, *args.BuildId) + assert.Equal(t, "Fabrikam", *args.Project) + return &buildObj, nil + }) + + err := runShow(d.cmd, &showOptions{scopeArg: "MyOrg/Fabrikam"}, "12345") + require.NoError(t, err) +} + +func TestRunShow_TemplateOutput_BasicFields(t *testing.T) { + t.Parallel() + d := newDeps(t, "MyOrg") + + buildObj := sampleBuild(42) + buildObj.Status = &build.BuildStatusValues.Completed + buildObj.Result = &build.BuildResultValues.Succeeded + buildObj.Reason = &build.BuildReasonValues.Manual + buildObj.SourceBranch = types.ToPtr("refs/heads/main") + buildObj.SourceVersion = types.ToPtr("abc12345def") + + d.build.EXPECT().GetBuild(gomock.Any(), gomock.Any()).Return(&buildObj, nil) + + err := runShow(d.cmd, &showOptions{scopeArg: "MyOrg/Fabrikam"}, "42") + require.NoError(t, err) + + output := d.stdout.String() + assert.Contains(t, output, "id:") + assert.Contains(t, output, "build number:") + assert.Contains(t, output, "status:") + assert.Contains(t, output, "result:") + assert.Contains(t, output, "reason:") + assert.Contains(t, output, "source branch:") + assert.Contains(t, output, "source version:") + assert.Contains(t, output, "definition:") +} + +func TestRunShow_TemplateOutput_Hyperlink(t *testing.T) { + t.Parallel() + d := newDeps(t, "MyOrg") + + buildObj := sampleBuild(1) + d.build.EXPECT().GetBuild(gomock.Any(), gomock.Any()).Return(&buildObj, nil) + + err := runShow(d.cmd, &showOptions{scopeArg: "MyOrg/Fabrikam"}, "1") + require.NoError(t, err) + + output := d.stdout.String() + assert.Contains(t, output, "\x1b]8;;") + assert.Contains(t, output, "url:") +} + +func TestRunShow_TemplateOutput_DurationFormatted(t *testing.T) { + t.Parallel() + d := newDeps(t, "MyOrg") + + buildObj := sampleBuild(1) + startTime, _ := time.Parse(time.RFC3339, "2024-01-01T12:00:00Z") + finishTime, _ := time.Parse(time.RFC3339, "2024-01-01T12:02:13Z") + buildObj.StartTime = &azuredevops.Time{Time: startTime} + buildObj.FinishTime = &azuredevops.Time{Time: finishTime} + + d.build.EXPECT().GetBuild(gomock.Any(), gomock.Any()).Return(&buildObj, nil) + + err := runShow(d.cmd, &showOptions{scopeArg: "MyOrg/Fabrikam"}, "1") + require.NoError(t, err) + + output := d.stdout.String() + assert.Contains(t, output, "duration:") + assert.Contains(t, output, "2m13s") +} + +func TestRunShow_TemplateOutput_NoDurationNotStarted(t *testing.T) { + t.Parallel() + d := newDeps(t, "MyOrg") + + buildObj := sampleBuild(1) + buildObj.FinishTime = nil + + d.build.EXPECT().GetBuild(gomock.Any(), gomock.Any()).Return(&buildObj, nil) + + err := runShow(d.cmd, &showOptions{scopeArg: "MyOrg/Fabrikam"}, "1") + require.NoError(t, err) + + output := d.stdout.String() + assert.NotContains(t, output, "duration:") +} + +func TestRunShow_TemplateOutput_Tags(t *testing.T) { + t.Parallel() + d := newDeps(t, "MyOrg") + + buildObj := sampleBuild(1) + buildObj.Tags = &[]string{"release", "nightly"} + + d.build.EXPECT().GetBuild(gomock.Any(), gomock.Any()).Return(&buildObj, nil) + + err := runShow(d.cmd, &showOptions{scopeArg: "MyOrg/Fabrikam"}, "1") + require.NoError(t, err) + + output := d.stdout.String() + assert.Contains(t, output, "tags:") + assert.Contains(t, output, "release") + assert.Contains(t, output, "nightly") +} + +func TestRunShow_TemplateOutput_NoTags(t *testing.T) { + t.Parallel() + d := newDeps(t, "MyOrg") + + buildObj := sampleBuild(1) + buildObj.Tags = nil + + d.build.EXPECT().GetBuild(gomock.Any(), gomock.Any()).Return(&buildObj, nil) + + err := runShow(d.cmd, &showOptions{scopeArg: "MyOrg/Fabrikam"}, "1") + require.NoError(t, err) + + output := d.stdout.String() + assert.NotContains(t, output, "tags:") +} + +func TestRunShow_TemplateOutput_DefinitionAndQueueNested(t *testing.T) { + t.Parallel() + d := newDeps(t, "MyOrg") + + buildObj := sampleBuild(1) + defID := 99 + defName := "MyDef" + queueID := 55 + queueName := "Azure Pipelines" + buildObj.Definition = &build.DefinitionReference{Name: &defName, Id: &defID} + buildObj.Queue = &build.AgentPoolQueue{Name: &queueName, Id: &queueID} + + d.build.EXPECT().GetBuild(gomock.Any(), gomock.Any()).Return(&buildObj, nil) + + err := runShow(d.cmd, &showOptions{scopeArg: "MyOrg/Fabrikam"}, "1") + require.NoError(t, err) + + output := d.stdout.String() + assert.Contains(t, output, "MyDef") + assert.Contains(t, output, "Azure Pipelines") +} + +func TestRunShow_TemplateOutput_ResultVisibility(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + status build.BuildStatus + result *build.BuildResult + wantResultRow bool + }{ + { + name: "hidden when not completed", + status: build.BuildStatusValues.InProgress, + result: nil, + wantResultRow: false, + }, + { + name: "shown when completed", + status: build.BuildStatusValues.Completed, + result: types.ToPtr(build.BuildResultValues.Succeeded), + wantResultRow: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := newDeps(t, "MyOrg") + + buildObj := sampleBuild(1) + buildObj.Status = &tt.status + buildObj.Result = tt.result + + d.build.EXPECT().GetBuild(gomock.Any(), gomock.Any()).Return(&buildObj, nil) + + err := runShow(d.cmd, &showOptions{scopeArg: "MyOrg/Fabrikam"}, "1") + require.NoError(t, err) + + output := d.stdout.String() + if tt.wantResultRow { + assert.Contains(t, output, "result:") + assert.Contains(t, output, "succeeded") + return + } + assert.NotContains(t, output, "result:") + }) + } +} + +func TestRunShow_JSONOutput(t *testing.T) { + t.Parallel() + d := newDeps(t, "MyOrg") + + buildObj := sampleBuild(1) + d.build.EXPECT().GetBuild(gomock.Any(), gomock.Any()).Return(&buildObj, nil) + + spy := &spyExporter{} + err := runShow(d.cmd, &showOptions{ + scopeArg: "MyOrg/Fabrikam", + exporter: spy, + }, "1") + require.NoError(t, err) + assert.Equal(t, 1, spy.writes) + require.NotNil(t, spy.got) + + gotBuild, ok := spy.got.(*build.Build) + require.True(t, ok, "exporter must receive *build.Build") + assert.Equal(t, 1, *gotBuild.Id) +} + +func TestRunShow_ProjectScopeParsing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scopeArg string + wantOrg string + wantProj string + withCfg bool + }{ + {name: "org/project", scopeArg: "MyOrg/Fabrikam", wantOrg: "MyOrg", wantProj: "Fabrikam", withCfg: false}, + {name: "project only uses config default", scopeArg: "Fabrikam", wantOrg: "default-org", wantProj: "Fabrikam", withCfg: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var d *deps + if tt.withCfg { + d = newDepsWithCfg(t, tt.wantOrg) + } else { + d = newDeps(t, tt.wantOrg) + } + + buildObj := sampleBuild(1) + d.build.EXPECT().GetBuild(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args build.GetBuildArgs) (*build.Build, error) { + assert.Equal(t, tt.wantProj, *args.Project) + return &buildObj, nil + }) + + err := runShow(d.cmd, &showOptions{scopeArg: tt.scopeArg}, "1") + require.NoError(t, err) + }) + } +} + +func TestRunShow_ClientFactoryError(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + cmd := mocks.NewMockCmdContext(ctrl) + clientFact := mocks.NewMockClientFactory(ctrl) + cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + cmd.EXPECT().ClientFactory().Return(clientFact).AnyTimes() + clientFact.EXPECT().Build(gomock.Any(), "MyOrg").Return(nil, assert.AnError) + + err := runShow(cmd, &showOptions{scopeArg: "MyOrg/Fabrikam"}, "1") + require.Error(t, err) + assert.ErrorIs(t, err, assert.AnError) +} + +func TestRunShow_SDKError(t *testing.T) { + t.Parallel() + d := newDeps(t, "MyOrg") + + d.build.EXPECT().GetBuild(gomock.Any(), gomock.Any()).Return(nil, assert.AnError) + + err := runShow(d.cmd, &showOptions{scopeArg: "MyOrg/Fabrikam"}, "1") + require.Error(t, err) + assert.ErrorIs(t, err, assert.AnError) +} diff --git a/internal/cmd/pr/comment/comment_test.go b/internal/cmd/pr/comment/comment_test.go index d86bf5eb..e72d65c4 100644 --- a/internal/cmd/pr/comment/comment_test.go +++ b/internal/cmd/pr/comment/comment_test.go @@ -24,7 +24,7 @@ type fakePrompter struct{ val string } var _ prompter.Prompter = (*fakePrompter)(nil) -func (f *fakePrompter) Select(msg, def string, opts []string) (int, error) { return 0, nil } +func (f *fakePrompter) Select(msg, def string, opts []string) (int, error) { return 0, nil } func (f *fakePrompter) MultiSelect(msg string, def, opts []string) ([]int, error) { return nil, nil } func (f *fakePrompter) Input(label, def string) (string, error) { return f.val, nil } diff --git a/internal/template/template.go b/internal/template/template.go index 1cc3355e..75ce5278 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -381,3 +381,39 @@ func hyperlinkFunc(link, text string) string { // See https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", link, text) } + +// HasItems returns true if v is a non-nil, non-empty slice/array. +// Accepts a pointer to a slice or a slice directly. +func HasItems(v any) bool { + if v == nil { + return false + } + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return false + } + rv = rv.Elem() + } + k := rv.Kind() + if k != reflect.Slice && k != reflect.Array { + return false + } + return rv.Len() > 0 +} + +// FormatDuration returns a human-readable duration string (e.g. "2m13s"). +func FormatDuration(d time.Duration) string { + d = d.Round(time.Second) + if d <= 0 { + return "" + } + s := d.String() + if d < time.Minute { + return s + } + if d%time.Minute == 0 { + return s[:len(s)-1] + " 0s" + } + return s +}