diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index e699a7a6..1d04e557 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -289,6 +289,36 @@ Aliases view, status ``` +### `azdo pipelines build` + +Manage classic builds + +#### `azdo pipelines build list [ORGANIZATION/]PROJECT [flags]` + +List classic build results in a project. + +``` + --branch string Limit to builds for this branch. Bare names get refs/heads/ prepended. + --build-number string Limit to builds that match this build number. Append * for prefix search. + --definition-id ints Limit to builds for these definition IDs (repeatable) +-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. + --max-items int Maximum number of builds to return across all pages (client-side; 0 = unlimited) + --reason string Limit to builds with this reason: {all|batchedci|buildcompletion|checkinshelveset|individualci|manual|none|pullrequest|resourcetrigger|schedule|scheduleforced|triggered|usercreated|validateshelveset} + --requested-for string Limit to builds requested for this user or group; supports @me + --result string Limit to builds with this result: {canceled|failed|none|partiallysucceeded|succeeded} + --status string Limit to builds with this status: {all|cancelling|completed|inprogress|none|notstarted|postponed} + --tag strings Limit to builds that have all of the specified tags (repeatable) +-t, --template string Format JSON output using a Go template; see "azdo help formatting" + --top int Maximum number of builds to return per page (server-side; 0 = server default) +``` + +Aliases + +``` +ls, l +``` + ### `azdo pipelines delete [ORGANIZATION/]PROJECT/PIPELINE [flags]` Delete a pipeline definition diff --git a/docs/azdo_pipelines.md b/docs/azdo_pipelines.md index 8a7cd566..e351a97e 100644 --- a/docs/azdo_pipelines.md +++ b/docs/azdo_pipelines.md @@ -5,6 +5,7 @@ Manage Azure DevOps pipelines ### Available commands * [azdo pipelines agent](./azdo_pipelines_agent.md) +* [azdo pipelines build](./azdo_pipelines_build.md) * [azdo pipelines delete](./azdo_pipelines_delete.md) * [azdo pipelines list](./azdo_pipelines_list.md) * [azdo pipelines pool](./azdo_pipelines_pool.md) diff --git a/docs/azdo_pipelines_build.md b/docs/azdo_pipelines_build.md new file mode 100644 index 00000000..20494353 --- /dev/null +++ b/docs/azdo_pipelines_build.md @@ -0,0 +1,20 @@ +## Command `azdo pipelines build` + +Manage classic Azure Pipelines builds (Build v1). +For modern Pipelines runs, see 'azdo pipelines runs'. + + +### Available commands + +* [azdo pipelines build list](./azdo_pipelines_build_list.md) + +### Examples + +```bash +# List builds in a project +azdo pipelines build list Fabrikam +``` + +### See also + +* [azdo pipelines](./azdo_pipelines.md) diff --git a/docs/azdo_pipelines_build_list.md b/docs/azdo_pipelines_build_list.md new file mode 100644 index 00000000..e5b387ca --- /dev/null +++ b/docs/azdo_pipelines_build_list.md @@ -0,0 +1,92 @@ +## Command `azdo pipelines build list` + +``` +azdo pipelines build list [ORGANIZATION/]PROJECT [flags] +``` + +List classic build (Build v1) records in a project. Supports filter, +pagination, and JSON export. For the modern Pipelines runs surface, +see 'azdo pipelines runs list'. + + +### Options + + +* `--branch` `string` + + Limit to builds for this branch. Bare names get refs/heads/ prepended. + +* `--build-number` `string` + + Limit to builds that match this build number. Append * for prefix search. + +* `--definition-id` `ints` + + Limit to builds for these definition IDs (repeatable) + +* `-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. + +* `--max-items` `int` (default `0`) + + Maximum number of builds to return across all pages (client-side; 0 = unlimited) + +* `--reason` `string` + + Limit to builds with this reason: {all|batchedci|buildcompletion|checkinshelveset|individualci|manual|none|pullrequest|resourcetrigger|schedule|scheduleforced|triggered|usercreated|validateshelveset} + +* `--requested-for` `string` + + Limit to builds requested for this user or group; supports @me + +* `--result` `string` + + Limit to builds with this result: {canceled|failed|none|partiallysucceeded|succeeded} + +* `--status` `string` + + Limit to builds with this status: {all|cancelling|completed|inprogress|none|notstarted|postponed} + +* `--tag` `strings` + + Limit to builds that have all of the specified tags (repeatable) + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + +* `--top` `int` (default `0`) + + Maximum number of builds to return per page (server-side; 0 = server default) + + +### ALIASES + +- `ls` +- `l` + +### JSON Fields + +`buildNumber`, `definition`, `finishTime`, `id`, `project`, `queueTime`, `reason`, `requestedBy`, `requestedFor`, `result`, `sourceBranch`, `sourceVersion`, `startTime`, `status`, `tags`, `uri`, `url` + +### Examples + +```bash +# List the 20 most recent builds for a project +azdo pipelines build list Fabrikam --top 20 + +# Filter by branch, status, and tag +azdo pipelines build list Fabrikam --branch main --status completed --tag release + +# Export as JSON +azdo pipelines build list Fabrikam --json id,buildNumber,status,result +``` + +### See also + +* [azdo pipelines build](./azdo_pipelines_build.md) diff --git a/internal/azdo/extensions/extension.go b/internal/azdo/extensions/extension.go index d57a6db3..56550288 100644 --- a/internal/azdo/extensions/extension.go +++ b/internal/azdo/extensions/extension.go @@ -32,7 +32,10 @@ type Client interface { // Inputs that cannot be resolved are absent from the result map (keyed by the trimmed input string). // The map naturally deduplicates repeated inputs; only catastrophic failures (e.g. client creation) return an error. ResolveSubjects(ctx context.Context, members []string) (map[string]*graph.GraphSubject, error) + // ResolveIdentity resolves a member identifier (SID, descriptor, email, display name, or account name). ResolveIdentity(ctx context.Context, member string) (*identity.Identity, error) + // ResolveCurrentIdentity retrieves the identity record for the authenticated user. + ResolveCurrentIdentity(ctx context.Context) (*identity.Identity, error) } type extensionClient struct { diff --git a/internal/azdo/extensions/member_lookup.go b/internal/azdo/extensions/member_lookup.go index 79c641a6..24e5ef75 100644 --- a/internal/azdo/extensions/member_lookup.go +++ b/internal/azdo/extensions/member_lookup.go @@ -339,6 +339,28 @@ func resolveSearchBatch(ctx context.Context, identityClient identity.Client, gra return nil } +func (c *extensionClient) ResolveCurrentIdentity(ctx context.Context) (*identity.Identity, error) { + selfID, err := c.GetSelfID(ctx) + if err != nil { + return nil, fmt.Errorf("failed to resolve @me identity: %w", err) + } + + identityClient, err := identity.NewClient(ctx, c.conn) + if err != nil { + return nil, fmt.Errorf("failed to create Identity client: %w", err) + } + + idStr := selfID.String() + identities, err := identityClient.ReadIdentities(ctx, identity.ReadIdentitiesArgs{IdentityIds: &idStr}) + if err != nil { + return nil, fmt.Errorf("failed to resolve @me identity details: %w", err) + } + if identities == nil || len(*identities) != 1 { + return nil, fmt.Errorf("failed to resolve @me identity details") + } + return &(*identities)[0], nil +} + func (c *extensionClient) ResolveIdentity(ctx context.Context, member string) (*identity.Identity, error) { member = strings.TrimSpace(member) if member == "" { diff --git a/internal/cmd/boards/workitem/list/list.go b/internal/cmd/boards/workitem/list/list.go index a558834b..4e383c98 100644 --- a/internal/cmd/boards/workitem/list/list.go +++ b/internal/cmd/boards/workitem/list/list.go @@ -480,19 +480,12 @@ func resolveAssignedToFilter(ctx util.CmdContext, organization string, assignedT } var extensionsClient extensions.Client - var identityClient identity.Client if needsLookup { ext, err := ctx.ClientFactory().Extensions(ctx.Context(), organization) if err != nil { return nil, fmt.Errorf("failed to create Extensions client: %w", err) } extensionsClient = ext - - idc, err := ctx.ClientFactory().Identity(ctx.Context(), organization) - if err != nil { - return nil, fmt.Errorf("failed to create Identity client: %w", err) - } - identityClient = idc } //nolint:dupl // intentional duplicate of resolveCreatedByFilter loop @@ -504,21 +497,11 @@ func resolveAssignedToFilter(ctx util.CmdContext, organization string, assignedT } if strings.EqualFold(raw, "@me") { - selfID, err := extensionsClient.GetSelfID(ctx.Context()) - if err != nil { - return nil, fmt.Errorf("failed to resolve @me identity: %w", err) - } - identityIds := selfID.String() - identities, err := identityClient.ReadIdentities(ctx.Context(), identity.ReadIdentitiesArgs{ - IdentityIds: &identityIds, - }) + ident, err := extensionsClient.ResolveCurrentIdentity(ctx.Context()) if err != nil { - return nil, fmt.Errorf("failed to resolve @me identity details: %w", err) + return nil, err } - if identities == nil || len(*identities) != 1 { - return nil, fmt.Errorf("failed to resolve @me identity details") - } - value := identityAccountOrDisplay((*identities)[0]) + value := identityAccountOrDisplay(*ident) if value == "" { return nil, fmt.Errorf("authenticated identity is missing account or display name") } @@ -568,18 +551,12 @@ func resolveCreatedByFilter(ctx util.CmdContext, organization string, createdBy } } var extensionsClient extensions.Client - var identityClient identity.Client if needsLookup { ext, err := ctx.ClientFactory().Extensions(ctx.Context(), organization) if err != nil { return nil, fmt.Errorf("failed to create Extensions client: %w", err) } extensionsClient = ext - idc, err := ctx.ClientFactory().Identity(ctx.Context(), organization) - if err != nil { - return nil, fmt.Errorf("failed to create Identity client: %w", err) - } - identityClient = idc } resolved := make([]string, 0, len(createdBy)) for _, raw := range createdBy { @@ -588,19 +565,11 @@ func resolveCreatedByFilter(ctx util.CmdContext, organization string, createdBy continue } if strings.EqualFold(raw, "@me") { - selfID, err := extensionsClient.GetSelfID(ctx.Context()) + ident, err := extensionsClient.ResolveCurrentIdentity(ctx.Context()) if err != nil { - return nil, fmt.Errorf("failed to resolve @me identity: %w", err) - } - identityIds := selfID.String() - identities, err := identityClient.ReadIdentities(ctx.Context(), identity.ReadIdentitiesArgs{IdentityIds: &identityIds}) - if err != nil { - return nil, fmt.Errorf("failed to resolve @me identity details: %w", err) - } - if identities == nil || len(*identities) != 1 { - return nil, fmt.Errorf("failed to resolve @me identity details") + return nil, err } - value := identityAccountOrDisplay((*identities)[0]) + value := identityAccountOrDisplay(*ident) if value == "" { return nil, fmt.Errorf("authenticated identity is missing account or display name") } diff --git a/internal/cmd/boards/workitem/list/list_test.go b/internal/cmd/boards/workitem/list/list_test.go index c68b1490..4d897696 100644 --- a/internal/cmd/boards/workitem/list/list_test.go +++ b/internal/cmd/boards/workitem/list/list_test.go @@ -10,7 +10,6 @@ import ( "testing" "time" - "github.com/google/uuid" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/workitemtracking" "github.com/stretchr/testify/assert" @@ -114,8 +113,7 @@ func TestRunList_SortInvalidField(t *testing.T) { t.Parallel() ios, _, _, _ := iostreams.Test() deps := &fakeListDeps{ - cmd: mocks.NewMockCmdContext(ctrlFromT(t)), - stdout: &bytes.Buffer{}, + cmd: mocks.NewMockCmdContext(ctrlFromT(t)), } deps.cmd.EXPECT().IOStreams().Return(ios, nil).AnyTimes() @@ -188,8 +186,7 @@ func TestRunList_InvalidDateFlag(t *testing.T) { ctrl := gomock.NewController(t) t.Cleanup(ctrl.Finish) deps := &fakeListDeps{ - cmd: mocks.NewMockCmdContext(ctrl), - stdout: &bytes.Buffer{}, + cmd: mocks.NewMockCmdContext(ctrl), } deps.cmd.EXPECT().IOStreams().Return(ios, nil).AnyTimes() @@ -259,21 +256,14 @@ func TestRunList_CreatedByMe(t *testing.T) { deps := setupFakeDeps(t, "org") stubDefaultOpenTypes(deps) stubBatch(t, deps, false) - - selfID := uuid.New() deps.clientFact.EXPECT().Extensions(gomock.Any(), "org").Return(deps.ext, nil) - deps.ext.EXPECT().GetSelfID(gomock.Any()).Return(selfID, nil) - deps.clientFact.EXPECT().Identity(gomock.Any(), "org").Return(deps.ident, nil) - deps.ident.EXPECT().ReadIdentities(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, args identity.ReadIdentitiesArgs) (*[]identity.Identity, error) { - require.NotNil(t, args.IdentityIds) - assert.Equal(t, selfID.String(), *args.IdentityIds) - id := identity.Identity{ + deps.ext.EXPECT().ResolveCurrentIdentity(gomock.Any()).DoAndReturn( + func(_ context.Context) (*identity.Identity, error) { + return &identity.Identity{ Properties: map[string]any{ "Account": map[string]any{"$value": "Alice "}, }, - } - return &[]identity.Identity{id}, nil + }, nil }, ) @@ -403,7 +393,7 @@ func TestRunList_StatusAndStateIntersect(t *testing.T) { }) require.NoError(t, err) // We expect the category predicate (e.g. from "New","Active","Proposed","InProgress") - // ANDed with the state predicate, both inside the state segment. + // ANDead with the state predicate, both inside the state segment. // The exact form: ( [System.State] IN ('New','Active','Proposed','InProgress') ) AND ( [System.State] IN ('Active') ) assert.Contains(t, captured, ") AND ([System.State] IN ('Active')") } @@ -977,24 +967,15 @@ func TestRunList_AssignedToMeResolvesIdentity(t *testing.T) { t.Parallel() deps := setupFakeDeps(t, "org") - - selfID := uuid.MustParse("11111111-1111-1111-1111-111111111111") deps.clientFact.EXPECT().Extensions(gomock.Any(), "org").Return(deps.ext, nil) - deps.clientFact.EXPECT().Identity(gomock.Any(), "org").Return(deps.ident, nil) - deps.ext.EXPECT().GetSelfID(gomock.Any()).Return(selfID, nil) - - idsArg := selfID.String() - deps.ident.EXPECT().ReadIdentities(gomock.Any(), gomock.Any()).DoAndReturn( - func(_ context.Context, args identity.ReadIdentitiesArgs) (*[]identity.Identity, error) { - require.NotNil(t, args.IdentityIds) - assert.Equal(t, idsArg, *args.IdentityIds) + deps.ext.EXPECT().ResolveCurrentIdentity(gomock.Any()).DoAndReturn( + func(_ context.Context) (*identity.Identity, error) { account := "Account.From.Properties" display := "Self User" - out := []identity.Identity{{ + return &identity.Identity{ Properties: map[string]any{"Account": map[string]any{"$value": account}}, ProviderDisplayName: &display, - }} - return &out, nil + }, nil }, ) diff --git a/internal/cmd/pipelines/build/build.go b/internal/cmd/pipelines/build/build.go new file mode 100644 index 00000000..7b147e9d --- /dev/null +++ b/internal/cmd/pipelines/build/build.go @@ -0,0 +1,27 @@ +package build + +import ( + "github.com/MakeNowJust/heredoc/v2" + "github.com/spf13/cobra" + + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/build/list" + "github.com/tmeckel/azdo-cli/internal/cmd/util" +) + +func NewCmd(ctx util.CmdContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "build", + Short: "Manage classic builds", + Long: heredoc.Doc(` + Manage classic Azure Pipelines builds (Build v1). + For modern Pipelines runs, see 'azdo pipelines runs'. + `), + Example: heredoc.Doc(` + # List builds in a project + azdo pipelines build list Fabrikam + `), + } + + cmd.AddCommand(list.NewCmd(ctx)) + return cmd +} diff --git a/internal/cmd/pipelines/build/list/list.go b/internal/cmd/pipelines/build/list/list.go new file mode 100644 index 00000000..1b2e25ad --- /dev/null +++ b/internal/cmd/pipelines/build/list/list.go @@ -0,0 +1,283 @@ +package list + +import ( + "fmt" + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" + "github.com/spf13/cobra" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type listOptions struct { + scopeArg string + + definitionIDs []int + branch *string + buildNumber *string + + status *string + result *string + reason *string + tags []string + requestedFor *string + + top int + maxItems int + + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &listOptions{} + + cmd := &cobra.Command{ + Use: "list [ORGANIZATION/]PROJECT", + Short: "List classic build results in a project.", + Long: heredoc.Doc(` + List classic build (Build v1) records in a project. Supports filter, + pagination, and JSON export. For the modern Pipelines runs surface, + see 'azdo pipelines runs list'. + `), + Example: heredoc.Doc(` + # List the 20 most recent builds for a project + azdo pipelines build list Fabrikam --top 20 + + # Filter by branch, status, and tag + azdo pipelines build list Fabrikam --branch main --status completed --tag release + + # Export as JSON + azdo pipelines build list Fabrikam --json id,buildNumber,status,result + `), + Aliases: []string{"ls", "l"}, + Args: util.ExactArgs(1, "project argument required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.scopeArg = args[0] + return runList(ctx, opts) + }, + } + + cmd.Flags().IntSliceVar(&opts.definitionIDs, "definition-id", nil, "Limit to builds for these definition IDs (repeatable)") + util.NilStringFlag(cmd, &opts.branch, "branch", "", "Limit to builds for this branch. Bare names get refs/heads/ prepended.") + util.NilStringFlag(cmd, &opts.buildNumber, "build-number", "", "Limit to builds that match this build number. Append * for prefix search.") + util.NilStringEnumFlag(cmd, &opts.status, "status", "", buildStatusLookup.Keys(), "Limit to builds with this status") + util.NilStringEnumFlag(cmd, &opts.result, "result", "", buildResultLookup.Keys(), "Limit to builds with this result") + util.NilStringEnumFlag(cmd, &opts.reason, "reason", "", buildReasonLookup.Keys(), "Limit to builds with this reason") + cmd.Flags().StringSliceVar(&opts.tags, "tag", nil, "Limit to builds that have all of the specified tags (repeatable)") + util.NilStringFlag(cmd, &opts.requestedFor, "requested-for", "", "Limit to builds requested for this user or group; supports @me") + cmd.Flags().IntVar(&opts.top, "top", 0, "Maximum number of builds to return per page (server-side; 0 = server default)") + cmd.Flags().IntVar(&opts.maxItems, "max-items", 0, "Maximum number of builds to return across all pages (client-side; 0 = unlimited)") + + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "id", "buildNumber", "status", "result", "reason", + "queueTime", "startTime", "finishTime", + "sourceBranch", "sourceVersion", + "definition", "project", "requestedBy", "requestedFor", + "tags", "uri", "url", + }) + + return cmd +} + +func runList(ctx util.CmdContext, opts *listOptions) error { + ios, err := ctx.IOStreams() + if err != nil { + return err + } + + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + if opts.top < 0 { + return util.FlagErrorf("--top must be >= 0") + } + if opts.maxItems < 0 { + return util.FlagErrorf("--max-items must be >= 0") + } + + scope, err := util.ParseProjectScope(ctx, opts.scopeArg) + if err != nil { + return util.FlagErrorWrap(err) + } + + client, err := ctx.ClientFactory().Build(ctx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create Build client: %w", err) + } + + args := build.GetBuildsArgs{Project: &scope.Project} + if ids := types.Unique(opts.definitionIDs); len(ids) > 0 { + args.Definitions = &ids + } + if opts.branch != nil { + branch := *opts.branch + if !strings.HasPrefix(branch, "refs/") { + branch = "refs/heads/" + branch + } + args.BranchName = &branch + } + if opts.buildNumber != nil { + args.BuildNumber = opts.buildNumber + } + if status, ok := buildStatusLookup.GetValuePtr(opts.status); !ok { + return util.FlagErrorf("unknown --status value %q", types.GetValue(opts.status, "")) + } else if status != nil { + args.StatusFilter = status + } + if result, ok := buildResultLookup.GetValuePtr(opts.result); !ok { + return util.FlagErrorf("unknown --result value %q", types.GetValue(opts.result, "")) + } else if result != nil { + args.ResultFilter = result + } + if reason, ok := buildReasonLookup.GetValuePtr(opts.reason); !ok { + return util.FlagErrorf("unknown --reason value %q", types.GetValue(opts.reason, "")) + } else if reason != nil { + args.ReasonFilter = reason + } + if len(opts.tags) > 0 { + args.TagFilters = &opts.tags + } + if requestedFor := types.GetValue(opts.requestedFor, ""); requestedFor != "" { + if strings.EqualFold(requestedFor, "@me") { + extClient, err := ctx.ClientFactory().Extensions(ctx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create Extensions client: %w", err) + } + ident, err := extClient.ResolveCurrentIdentity(ctx.Context()) + if err != nil { + return err + } + if m, ok := ident.Properties.(map[string]any); ok { + if raw, ok := m["Account"]; ok && raw != nil { + if account, ok := raw.(map[string]any); ok { + if v, ok := account["$value"].(string); ok && v != "" { + requestedFor = v + } + } + } + } + if requestedFor == "" { + requestedFor = types.GetValue(ident.ProviderDisplayName, "") + } + if requestedFor == "" { + return fmt.Errorf("authenticated identity is missing account or display name") + } + } + args.RequestedFor = &requestedFor + } + if opts.top > 0 { + args.Top = &opts.top + } + + builds := make([]build.Build, 0) +paginate: + for { + resp, err := client.GetBuilds(ctx.Context(), args) + if err != nil { + return fmt.Errorf("failed to list builds: %w", err) + } + if resp != nil && len(resp.Value) > 0 { + for _, b := range resp.Value { + builds = append(builds, b) + if opts.maxItems > 0 && len(builds) >= opts.maxItems { + break paginate + } + } + } + if resp == nil || resp.ContinuationToken == "" { + break + } + token := resp.ContinuationToken + args.ContinuationToken = &token + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, builds) + } + + tp, err := ctx.Printer("list") + if err != nil { + return err + } + + tp.AddColumns("ID", "NUMBER", "STATUS", "RESULT", "REASON", "DEFINITION", "BRANCH", "REQUESTED FOR", "STARTED", "FINISHED") + for _, b := range builds { + defName := "" + if b.Definition != nil { + defName = types.GetValue(b.Definition.Name, "") + } + requestedFor := "" + if ref := b.RequestedFor; ref != nil { + if v := types.GetValue(ref.DisplayName, ""); v != "" { + requestedFor = v + } else { + requestedFor = types.GetValue(ref.UniqueName, "") + } + } + status := "" + if b.Status != nil { + status = string(*b.Status) + } + result := "" + if b.Result != nil { + result = string(*b.Result) + } + reason := "" + if b.Reason != nil { + reason = string(*b.Reason) + } + tp.AddField(fmt.Sprintf("%d", types.GetValue(b.Id, 0))) + tp.AddField(types.GetValue(b.BuildNumber, "")) + tp.AddField(status) + tp.AddField(result) + tp.AddField(reason) + tp.AddField(defName) + tp.AddField(types.GetValue(b.SourceBranch, "")) + tp.AddField(requestedFor) + tp.AddField(util.FormatTimeShort(b.StartTime)) + tp.AddField(util.FormatTimeShort(b.FinishTime)) + tp.EndRow() + } + + return tp.Render() +} + +var buildStatusLookup = types.EnumLookup[build.BuildStatus]{ + "none": build.BuildStatusValues.None, + "inprogress": build.BuildStatusValues.InProgress, + "completed": build.BuildStatusValues.Completed, + "cancelling": build.BuildStatusValues.Cancelling, + "postponed": build.BuildStatusValues.Postponed, + "notstarted": build.BuildStatusValues.NotStarted, + "all": build.BuildStatusValues.All, +} + +var buildResultLookup = types.EnumLookup[build.BuildResult]{ + "none": build.BuildResultValues.None, + "succeeded": build.BuildResultValues.Succeeded, + "partiallysucceeded": build.BuildResultValues.PartiallySucceeded, + "failed": build.BuildResultValues.Failed, + "canceled": build.BuildResultValues.Canceled, +} + +var buildReasonLookup = types.EnumLookup[build.BuildReason]{ + "none": build.BuildReasonValues.None, + "manual": build.BuildReasonValues.Manual, + "individualci": build.BuildReasonValues.IndividualCI, + "batchedci": build.BuildReasonValues.BatchedCI, + "schedule": build.BuildReasonValues.Schedule, + "scheduleforced": build.BuildReasonValues.ScheduleForced, + "usercreated": build.BuildReasonValues.UserCreated, + "validateshelveset": build.BuildReasonValues.ValidateShelveset, + "checkinshelveset": build.BuildReasonValues.CheckInShelveset, + "pullrequest": build.BuildReasonValues.PullRequest, + "buildcompletion": build.BuildReasonValues.BuildCompletion, + "resourcetrigger": build.BuildReasonValues.ResourceTrigger, + "triggered": build.BuildReasonValues.Triggered, + "all": build.BuildReasonValues.All, +} diff --git a/internal/cmd/pipelines/build/list/list_test.go b/internal/cmd/pipelines/build/list/list_test.go new file mode 100644 index 00000000..649f272e --- /dev/null +++ b/internal/cmd/pipelines/build/list/list_test.go @@ -0,0 +1,434 @@ +package list + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "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/identity" + "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/printer" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type deps struct { + ctrl *gomock.Controller + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + build *mocks.MockBuildClient + ext *mocks.MockAzDOExtension + ident *mocks.MockIdentityClient + config *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{ + ctrl: ctrl, + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + build: mocks.NewMockBuildClient(ctrl), + ext: mocks.NewMockAzDOExtension(ctrl), + ident: mocks.NewMockIdentityClient(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() + if organization != "" { + d.clientFact.EXPECT().Build(gomock.Any(), organization).Return(d.build, nil).AnyTimes() + } + + return d +} + +func (d *deps) setupDefaultOrg(org string) { + d.config = mocks.NewMockConfig(d.ctrl) + d.auth = mocks.NewMockAuthConfig(d.ctrl) + 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 sampleBuild(id int, number, status, result, reason, defName, branch, requestedFor string) build.Build { + b := build.Build{ + Id: types.ToPtr(id), + BuildNumber: types.ToPtr(number), + Status: buildStatusPtr(status), + Result: buildResultPtr(result), + Reason: buildReasonPtr(reason), + SourceBranch: types.ToPtr(branch), + StartTime: &azuredevops.Time{Time: time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC)}, + FinishTime: &azuredevops.Time{Time: time.Date(2025, 1, 15, 11, 30, 0, 0, time.UTC)}, + } + if defName != "" { + b.Definition = &build.DefinitionReference{Name: types.ToPtr(defName)} + } + if requestedFor != "" { + b.RequestedFor = &webapi.IdentityRef{DisplayName: types.ToPtr(requestedFor)} + } + return b +} + +func buildStatusPtr(s string) *build.BuildStatus { + if s == "" { + return nil + } + v := build.BuildStatus(s) + return &v +} + +func buildResultPtr(s string) *build.BuildResult { + if s == "" { + return nil + } + v := build.BuildResult(s) + return &v +} + +func buildReasonPtr(s string) *build.BuildReason { + if s == "" { + return nil + } + v := build.BuildReason(s) + return &v +} + +func TestNewCmd(t *testing.T) { + t.Parallel() + + cmd := NewCmd(nil) + assert.Equal(t, "list [ORGANIZATION/]PROJECT", cmd.Use) + assert.ElementsMatch(t, []string{"ls", "l"}, cmd.Aliases) + assert.NotNil(t, cmd.RunE) +} + +func TestList_EmptyResult(t *testing.T) { + t.Parallel() + d := newDeps(t, "org") + d.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()).Return(&build.GetBuildsResponseValue{}, nil) + + tp, err := printer.NewTablePrinter(d.stdout, false, 200) + require.NoError(t, err) + d.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + err = runList(d.cmd, &listOptions{scopeArg: "org/Fabrikam"}) + require.NoError(t, err) + assert.Empty(t, d.stdout.String()) +} + +func TestList_NoFilters(t *testing.T) { + t.Parallel() + d := newDeps(t, "org") + + builds := []build.Build{ + sampleBuild(1, "1", "completed", "succeeded", "manual", "web-app", "refs/heads/main", "Alice"), + sampleBuild(2, "2", "completed", "failed", "pullRequest", "api", "refs/heads/feature/x", "Bob"), + } + d.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()).Return(&build.GetBuildsResponseValue{Value: builds}, nil) + + tp, err := printer.NewTablePrinter(d.stdout, false, 200) + require.NoError(t, err) + d.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + err = runList(d.cmd, &listOptions{scopeArg: "org/Fabrikam"}) + require.NoError(t, err) + + output := d.stdout.String() + assert.Contains(t, output, "1") + assert.Contains(t, output, "web-app") + assert.Contains(t, output, "Alice") + assert.Contains(t, output, "Bob") +} + +func TestList_ContinuationToken_Loops(t *testing.T) { + t.Parallel() + d := newDeps(t, "org") + callCount := 0 + + d.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + callCount++ + switch callCount { + case 1: + assert.Nil(t, args.ContinuationToken, "first call has no token") + return &build.GetBuildsResponseValue{ + Value: []build.Build{sampleBuild(1, "1", "completed", "succeeded", "manual", "web", "main", "Alice")}, + ContinuationToken: "tok-1", + }, nil + case 2: + assert.NotNil(t, args.ContinuationToken) + assert.Equal(t, "tok-1", *args.ContinuationToken) + return &build.GetBuildsResponseValue{ + Value: []build.Build{sampleBuild(2, "2", "completed", "failed", "pullRequest", "api", "feature/x", "Bob")}, + ContinuationToken: "", + }, nil + default: + return nil, fmt.Errorf("unexpected call %d", callCount) + } + }, + ).Times(2) + + tp, err := printer.NewTablePrinter(d.stdout, false, 200) + require.NoError(t, err) + d.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + err = runList(d.cmd, &listOptions{scopeArg: "org/Fabrikam"}) + require.NoError(t, err) + assert.Equal(t, 2, callCount) + assert.Contains(t, d.stdout.String(), "1") + assert.Contains(t, d.stdout.String(), "2") +} + +func TestList_FiltersPassedToSDK(t *testing.T) { + t.Parallel() + d := newDeps(t, "org") + + var capturedArgs build.GetBuildsArgs + d.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + capturedArgs = args + return &build.GetBuildsResponseValue{Value: []build.Build{sampleBuild(1, "1", "completed", "succeeded", "manual", "web", "main", "Alice")}}, nil + }, + ) + + d.clientFact.EXPECT().Extensions(gomock.Any(), "org").Return(d.ext, nil) + d.ext.EXPECT().ResolveCurrentIdentity(gomock.Any()).Return(&identity.Identity{ + Properties: map[string]any{"Account": map[string]any{"$value": "Alice "}}, + ProviderDisplayName: types.ToPtr("Alice"), + }, nil) + + tp, err := printer.NewTablePrinter(d.stdout, false, 200) + require.NoError(t, err) + d.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + err = runList(d.cmd, &listOptions{ + scopeArg: "org/Fabrikam", + definitionIDs: []int{1, 2}, + branch: types.ToPtr("main"), + buildNumber: types.ToPtr("build-*"), + status: types.ToPtr("completed"), + result: types.ToPtr("succeeded"), + reason: types.ToPtr("manual"), + tags: []string{"release"}, + requestedFor: types.ToPtr("@me"), + top: 50, + }) + require.NoError(t, err) + + require.NotNil(t, capturedArgs.Project) + assert.Equal(t, "Fabrikam", *capturedArgs.Project) + require.NotNil(t, capturedArgs.Definitions) + assert.Equal(t, []int{1, 2}, *capturedArgs.Definitions) + require.NotNil(t, capturedArgs.BranchName) + assert.Equal(t, "refs/heads/main", *capturedArgs.BranchName) + require.NotNil(t, capturedArgs.BuildNumber) + assert.Equal(t, "build-*", *capturedArgs.BuildNumber) + require.NotNil(t, capturedArgs.StatusFilter) + assert.Equal(t, build.BuildStatus("completed"), *capturedArgs.StatusFilter) + require.NotNil(t, capturedArgs.ResultFilter) + assert.Equal(t, build.BuildResult("succeeded"), *capturedArgs.ResultFilter) + require.NotNil(t, capturedArgs.ReasonFilter) + assert.Equal(t, build.BuildReason("manual"), *capturedArgs.ReasonFilter) + require.NotNil(t, capturedArgs.TagFilters) + assert.Equal(t, []string{"release"}, *capturedArgs.TagFilters) + require.NotNil(t, capturedArgs.RequestedFor) + assert.Equal(t, "Alice ", *capturedArgs.RequestedFor) + require.NotNil(t, capturedArgs.Top) + assert.Equal(t, 50, *capturedArgs.Top) +} + +func TestList_MaxItemsCap(t *testing.T) { + t.Parallel() + d := newDeps(t, "org") + + items := make([]build.Build, 5) + for i := 0; i < 5; i++ { + items[i] = sampleBuild(i+1, fmt.Sprintf("%d", i+1), "completed", "succeeded", "manual", "web", "main", "Alice") + } + d.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()).Return(&build.GetBuildsResponseValue{Value: items}, nil) + + tp, err := printer.NewTablePrinter(d.stdout, false, 200) + require.NoError(t, err) + d.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + err = runList(d.cmd, &listOptions{scopeArg: "org/Fabrikam", maxItems: 3}) + require.NoError(t, err) + assert.Contains(t, d.stdout.String(), "1") + assert.Contains(t, d.stdout.String(), "3") + assert.NotContains(t, d.stdout.String(), "4") +} + +func TestList_JSONOutput(t *testing.T) { + t.Parallel() + d := newDeps(t, "org") + + items := []build.Build{sampleBuild(42, "42", "completed", "succeeded", "manual", "web-app", "main", "Alice")} + d.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()).Return(&build.GetBuildsResponseValue{Value: items}, nil) + + exporter := util.NewJSONExporter() + err := runList(d.cmd, &listOptions{scopeArg: "org/Fabrikam", exporter: exporter}) + require.NoError(t, err) + + var parsed []map[string]any + err = json.Unmarshal(d.stdout.Bytes(), &parsed) + require.NoError(t, err) + require.Len(t, parsed, 1) + assert.Equal(t, float64(42), parsed[0]["id"]) + assert.Equal(t, "42", parsed[0]["buildNumber"]) + assert.Equal(t, "completed", parsed[0]["status"]) + assert.Equal(t, "succeeded", parsed[0]["result"]) +} + +func TestList_PaginationAcrossPages_CappedByMaxItems(t *testing.T) { + t.Parallel() + d := newDeps(t, "org") + callCount := 0 + + page1 := make([]build.Build, 5) + for i := 0; i < 5; i++ { + page1[i] = sampleBuild(i+1, fmt.Sprintf("%d", i+1), "completed", "succeeded", "manual", "web", "main", "Alice") + } + page2 := make([]build.Build, 5) + for i := 0; i < 5; i++ { + page2[i] = sampleBuild(i+6, fmt.Sprintf("%d", i+6), "completed", "succeeded", "manual", "web", "main", "Alice") + } + + d.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + callCount++ + switch callCount { + case 1: + return &build.GetBuildsResponseValue{Value: page1, ContinuationToken: "tok-1"}, nil + case 2: + return &build.GetBuildsResponseValue{Value: page2}, nil + default: + return nil, fmt.Errorf("unexpected call %d", callCount) + } + }, + ).Times(2) + + tp, err := printer.NewTablePrinter(d.stdout, false, 200) + require.NoError(t, err) + d.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + err = runList(d.cmd, &listOptions{scopeArg: "org/Fabrikam", maxItems: 6}) + require.NoError(t, err) + assert.Equal(t, 2, callCount) + assert.Contains(t, d.stdout.String(), "6") + assert.NotContains(t, d.stdout.String(), "7") + assert.NotContains(t, d.stdout.String(), "8") +} + +func TestList_RequestedFor_ResolvesAtMe(t *testing.T) { + t.Parallel() + d := newDeps(t, "org") + + d.clientFact.EXPECT().Extensions(gomock.Any(), "org").Return(d.ext, nil) + d.ext.EXPECT().ResolveCurrentIdentity(gomock.Any()).Return(&identity.Identity{ + Properties: map[string]any{"Account": map[string]any{"$value": "Alice "}}, + ProviderDisplayName: types.ToPtr("Alice"), + }, nil) + + var capturedArgs build.GetBuildsArgs + d.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + capturedArgs = args + return &build.GetBuildsResponseValue{}, nil + }, + ) + + tp, err := printer.NewTablePrinter(d.stdout, false, 200) + require.NoError(t, err) + d.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + err = runList(d.cmd, &listOptions{scopeArg: "org/Fabrikam", requestedFor: types.ToPtr("@me")}) + require.NoError(t, err) + + require.NotNil(t, capturedArgs.RequestedFor) + assert.Equal(t, "Alice ", *capturedArgs.RequestedFor) +} + +func TestList_ScopeArg_ParsesOrgSlashProject(t *testing.T) { + t.Parallel() + d := newDeps(t, "") + + var buildOrg string + d.clientFact.EXPECT().Build(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, org string) (*mocks.MockBuildClient, error) { + buildOrg = org + return d.build, nil + }, + ).AnyTimes() + + var project string + d.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + project = *args.Project + return &build.GetBuildsResponseValue{}, nil + }, + ) + + tp, err := printer.NewTablePrinter(d.stdout, false, 200) + require.NoError(t, err) + d.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + err = runList(d.cmd, &listOptions{scopeArg: "myOrg/myProject"}) + require.NoError(t, err) + assert.Equal(t, "myOrg", buildOrg) + assert.Equal(t, "myProject", project) +} + +func TestList_ScopeArg_DefaultOrg(t *testing.T) { + t.Parallel() + d := newDeps(t, "") + d.setupDefaultOrg("default-org") + + d.clientFact.EXPECT().Build(gomock.Any(), "default-org").Return(d.build, nil).AnyTimes() + + var project string + d.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + project = *args.Project + return &build.GetBuildsResponseValue{}, nil + }, + ) + + tp, err := printer.NewTablePrinter(d.stdout, false, 200) + require.NoError(t, err) + d.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + err = runList(d.cmd, &listOptions{scopeArg: "myProject"}) + require.NoError(t, err) + assert.Equal(t, "myProject", project) +} + +func TestList_InvalidMaxItems(t *testing.T) { + t.Parallel() + d := newDeps(t, "") + err := runList(d.cmd, &listOptions{scopeArg: "org/Fabrikam", maxItems: -1}) + require.Error(t, err) + assert.Contains(t, err.Error(), "--max-items") +} diff --git a/internal/cmd/pipelines/pipelines.go b/internal/cmd/pipelines/pipelines.go index 0cc73217..b9e8c64a 100644 --- a/internal/cmd/pipelines/pipelines.go +++ b/internal/cmd/pipelines/pipelines.go @@ -4,6 +4,7 @@ import ( "github.com/MakeNowJust/heredoc/v2" "github.com/spf13/cobra" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/agent" + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/build" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/delete" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/list" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/pool" @@ -26,6 +27,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { `), } + cmd.AddCommand(build.NewCmd(ctx)) cmd.AddCommand(delete.NewCmd(ctx)) cmd.AddCommand(list.NewCmd(ctx)) cmd.AddCommand(run.NewCmd(ctx)) diff --git a/internal/cmd/pipelines/queue/show/show_test.go b/internal/cmd/pipelines/queue/show/show_test.go index 5de01fae..eb185493 100644 --- a/internal/cmd/pipelines/queue/show/show_test.go +++ b/internal/cmd/pipelines/queue/show/show_test.go @@ -380,6 +380,7 @@ func TestRunShow_SDKError(t *testing.T) { require.Error(t, err) assert.ErrorIs(t, err, expectedErr) } + func TestNewCmd_HasFlags(t *testing.T) { t.Parallel() diff --git a/internal/cmd/pipelines/runs/list/list.go b/internal/cmd/pipelines/runs/list/list.go index 7887f319..2c94aa07 100644 --- a/internal/cmd/pipelines/runs/list/list.go +++ b/internal/cmd/pipelines/runs/list/list.go @@ -7,7 +7,6 @@ import ( "github.com/MakeNowJust/heredoc/v2" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" - "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" "github.com/spf13/cobra" "go.uber.org/zap" @@ -123,29 +122,25 @@ func runCmd(ctx util.CmdContext, opts *runOptions) error { if err != nil { return fmt.Errorf("failed to create Extensions client: %w", err) } - - identityClient, err := ctx.ClientFactory().Identity(ctx.Context(), scope.Organization) + ident, err := extensionsClient.ResolveCurrentIdentity(ctx.Context()) if err != nil { - return fmt.Errorf("failed to create Identity client: %w", err) + return err } - - selfID, err := extensionsClient.GetSelfID(ctx.Context()) - if err != nil { - return fmt.Errorf("failed to resolve @me identity: %w", err) + if m, ok := ident.Properties.(map[string]any); ok { + if raw, ok := m["Account"]; ok && raw != nil { + if account, ok := raw.(map[string]any); ok { + if v, ok := account["$value"].(string); ok && v != "" { + requestedFor = v + } + } + } } - - idStr := selfID.String() - identities, err := identityClient.ReadIdentities(ctx.Context(), identity.ReadIdentitiesArgs{ - IdentityIds: &idStr, - }) - if err != nil { - return fmt.Errorf("failed to resolve @me identity details: %w", err) + if requestedFor == "" { + requestedFor = types.GetValue(ident.ProviderDisplayName, "") } - if identities == nil || len(*identities) != 1 { - return fmt.Errorf("failed to resolve @me identity details") + if requestedFor == "" { + return fmt.Errorf("authenticated identity is missing account or display name") } - - requestedFor = types.GetValue((*identities)[0].ProviderDisplayName, "") } project := scope.Project diff --git a/internal/cmd/pipelines/runs/list/list_test.go b/internal/cmd/pipelines/runs/list/list_test.go index 4a3f4a0b..6d494e84 100644 --- a/internal/cmd/pipelines/runs/list/list_test.go +++ b/internal/cmd/pipelines/runs/list/list_test.go @@ -6,7 +6,6 @@ import ( "strconv" "testing" - "github.com/google/uuid" "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/identity" @@ -253,20 +252,13 @@ func TestRunList_RequestedForAtMe(t *testing.T) { t.Parallel() deps := newDependencies(t, "MyOrg") - selfID := uuid.New() - aliceUUID := uuid.MustParse("00000000-0000-0000-0000-000000000001") dispName := "Alice" - identities := []identity.Identity{ - {ProviderDisplayName: &dispName, Id: &aliceUUID}, - } deps.clientFact.EXPECT().Extensions(gomock.Any(), "MyOrg").Return(deps.ext, nil) - deps.clientFact.EXPECT().Identity(gomock.Any(), "MyOrg").Return(deps.ident, nil) - deps.ext.EXPECT().GetSelfID(gomock.Any()).Return(selfID, nil) - - idStr := selfID.String() - deps.ident.EXPECT().ReadIdentities(gomock.Any(), identity.ReadIdentitiesArgs{IdentityIds: &idStr}). - Return(&identities, nil) + deps.ext.EXPECT().ResolveCurrentIdentity(gomock.Any()).Return(&identity.Identity{ + Properties: map[string]any{"Account": map[string]any{"$value": "Alice "}}, + ProviderDisplayName: &dispName, + }, nil) var capturedRequestedFor string deps.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()). @@ -279,7 +271,7 @@ func TestRunList_RequestedForAtMe(t *testing.T) { err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam", requestedFor: types.ToPtr("@me")}) require.NoError(t, err) - assert.Equal(t, "Alice", capturedRequestedFor) + assert.Equal(t, "Alice ", capturedRequestedFor) } func TestRunList_QueryOrder(t *testing.T) { diff --git a/internal/mocks/extension_mock.go b/internal/mocks/extension_mock.go index 29770675..1eef5a29 100644 --- a/internal/mocks/extension_mock.go +++ b/internal/mocks/extension_mock.go @@ -105,6 +105,21 @@ func (mr *MockAzDOExtensionMockRecorder) GetVariableGroups(ctx, args any) *gomoc return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetVariableGroups", reflect.TypeOf((*MockAzDOExtension)(nil).GetVariableGroups), ctx, args) } +// ResolveCurrentIdentity mocks base method. +func (m *MockAzDOExtension) ResolveCurrentIdentity(ctx context.Context) (*identity.Identity, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveCurrentIdentity", ctx) + ret0, _ := ret[0].(*identity.Identity) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ResolveCurrentIdentity indicates an expected call of ResolveCurrentIdentity. +func (mr *MockAzDOExtensionMockRecorder) ResolveCurrentIdentity(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveCurrentIdentity", reflect.TypeOf((*MockAzDOExtension)(nil).ResolveCurrentIdentity), ctx) +} + // ResolveIdentity mocks base method. func (m *MockAzDOExtension) ResolveIdentity(ctx context.Context, member string) (*identity.Identity, error) { m.ctrl.T.Helper()