From 7325634b794c75c061f44893a4b34571614c0823 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 14 Jun 2026 14:51:18 +0000 Subject: [PATCH 1/8] chore: add pre-commit hooks and lint configs --- .markdownlint.yaml | 3 +++ .pre-commit-config.yaml | 41 +++++++++++++++++++++++++++++++++++++++++ .typos.toml | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 .markdownlint.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 .typos.toml diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 00000000..eb89a3c7 --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,3 @@ +--- +MD013: false +MD024: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..32bb8542 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,41 @@ +--- +default_install_hook_types: + - pre-commit + - pre-push +default_stages: + - pre-commit +fail_fast: true + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-added-large-files + - id: fix-byte-order-marker + - id: check-case-conflict + - id: check-json + exclude: bun\.lock + - id: end-of-file-fixer + - id: trailing-whitespace + - id: mixed-line-ending + - id: check-merge-conflict + + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.38.0 + hooks: + - id: yamllint + + - repo: https://github.com/DavidAnson/markdownlint-cli2 + rev: v0.22.1 + hooks: + - id: markdownlint-cli2 + + - repo: https://github.com/rhysd/actionlint + rev: v1.7.12 + hooks: + - id: actionlint-system + + - repo: https://github.com/crate-ci/typos + rev: v1.47.2 + hooks: + - id: typos diff --git a/.typos.toml b/.typos.toml new file mode 100644 index 00000000..0cdb4efb --- /dev/null +++ b/.typos.toml @@ -0,0 +1,32 @@ +# For more information on how to configure typos, see: +# - https://github.com/crate-ci/typos/blob/master/docs/reference.md +# - https://github.com/crate-ci/typos/blob/master/README.md#false-positives + +[files] +# By default, typos will not check files that are ignored by git. +# You can override this behavior here. +# For example, to check all files, you would set: +# ignore-vcs = false +extend-exclude = [ + "**/.mise.toml", +] + +[default] +# This section contains the default configuration for all file types. +# You can override these settings for specific file types below. + +# A list of words that should be considered correct. +# This is useful for words that are specific to your project. +# For example: +# extend-words = { +# "myawesomeword" = "myawesomeword", +# "another" = "another", +# } + +# A list of regular expressions that should be ignored. +# This is useful for things like URLs, or other patterns that +# are not words. +# For example, to ignore all URLs: +# extend-ignore-re = [ +# "https?://[^`\\s]+", +# ] From ae97f2d56c8d413d09a980e4a817771a2676683e Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 14 Jun 2026 14:52:23 +0000 Subject: [PATCH 2/8] chore(pre-commit): remove bun.lock exclude from check-json --- .pre-commit-config.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 32bb8542..5ee6552f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,6 @@ repos: - id: fix-byte-order-marker - id: check-case-conflict - id: check-json - exclude: bun\.lock - id: end-of-file-fixer - id: trailing-whitespace - id: mixed-line-ending From 9a6f147cbc9941735ee5d3186586c3e5282731f4 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 14 Jun 2026 14:53:23 +0000 Subject: [PATCH 3/8] feat(types): add LookupEnum case-insensitive lookup function --- internal/types/types.go | 16 +++++++++++++++- internal/types/types_test.go | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 internal/types/types_test.go diff --git a/internal/types/types.go b/internal/types/types.go index 030e5d99..18958791 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -1,6 +1,9 @@ package types -import "cmp" +import ( + "cmp" + "strings" +) // ToPtr returns a pointer to value. func ToPtr[T any](value T) *T { @@ -33,3 +36,14 @@ func PositivePtrOrNil[T cmp.Ordered](v T) *T { } return &v } + +// LookupEnum returns first case-insensitive match from values. +func LookupEnum[T ~string](input string, values []T) (T, bool) { + for _, v := range values { + if strings.EqualFold(input, string(v)) { + return v, true + } + } + var zero T + return zero, false +} diff --git a/internal/types/types_test.go b/internal/types/types_test.go new file mode 100644 index 00000000..2df0e0a6 --- /dev/null +++ b/internal/types/types_test.go @@ -0,0 +1,37 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLookupEnum_Match(t *testing.T) { + t.Parallel() + + got, ok := LookupEnum("BeTa", []string{"alpha", "beta", "gamma"}) + + require.True(t, ok) + assert.Equal(t, "beta", got) +} + +func TestLookupEnum_NoMatch(t *testing.T) { + t.Parallel() + + got, ok := LookupEnum("delta", []string{"alpha", "beta", "gamma"}) + + assert.False(t, ok) + assert.Equal(t, "", got) +} + +func TestLookupEnum_NamedStringType(t *testing.T) { + t.Parallel() + + type enum string + + got, ok := LookupEnum("SECOND", []enum{"first", "second"}) + + require.True(t, ok) + assert.Equal(t, enum("second"), got) +} From 8a4b3fe76a5e97b0d83ebef5a333c07ea73d7571 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 14 Jun 2026 14:57:14 +0000 Subject: [PATCH 4/8] =?UTF-8?q?style:=20=F0=9F=92=85=F0=9F=8F=BC=20standar?= =?UTF-8?q?dize=20Go=20formatting=20with=20trailing=20commas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add trailing commas to multi-line function calls across the codebase to follow Go style conventions and improve diff clarity for future changes. Also simplify unnecessary parentheses in pointer dereference expressions for cleaner code style. --- internal/azdo/extensions/extension.go | 2 +- internal/cmd/auth/login/login.go | 6 ++++-- internal/cmd/auth/logout/logout.go | 3 ++- internal/cmd/auth/setupgit/setupgit.go | 6 ++++-- internal/cmd/boards/area/project/list/list.go | 3 ++- .../pipelines/variablegroup/delete/delete.go | 6 ++++-- internal/cmd/pr/comment/comment_test.go | 17 +++++++++-------- internal/cmd/pr/create/create.go | 5 +++-- internal/cmd/pr/merge/merge.go | 3 ++- internal/cmd/pr/pr.go | 6 ++++-- internal/cmd/pr/view/view.go | 3 ++- internal/cmd/pr/vote/vote.go | 3 ++- internal/cmd/project/list/list.go | 2 +- internal/cmd/repo/list/list.go | 2 +- internal/cmd/repo/setdefault/setdefault.go | 9 ++++++--- .../cmd/security/group/membership/add/add.go | 9 ++++++--- .../security/group/membership/remove/remove.go | 9 ++++++--- .../cmd/security/permission/delete/delete.go | 12 ++++++++---- internal/cmd/serviceendpoint/create/create.go | 3 ++- internal/cmd/serviceendpoint/delete/delete.go | 3 ++- internal/cmd/serviceendpoint/export/export.go | 3 ++- internal/cmd/serviceendpoint/list/list.go | 3 ++- internal/cmd/serviceendpoint/shared/output.go | 3 ++- internal/cmd/serviceendpoint/update/update.go | 6 ++++-- internal/cmd/team/show/show.go | 6 ++++-- internal/jq/jq.go | 3 ++- internal/prompter/prompter.go | 7 +++++-- 27 files changed, 92 insertions(+), 51 deletions(-) diff --git a/internal/azdo/extensions/extension.go b/internal/azdo/extensions/extension.go index 3a270094..d57a6db3 100644 --- a/internal/azdo/extensions/extension.go +++ b/internal/azdo/extensions/extension.go @@ -102,7 +102,7 @@ func (c *extensionClient) GetSubjectID(ctx context.Context, subject string) (uui return uuid.Nil, err } if storageKey == nil { - return uuid.Nil, fmt.Errorf("failed to get storage key for user %s (%s)", subject, *((*subjects)[0].Descriptor)) + return uuid.Nil, fmt.Errorf("failed to get storage key for user %s (%s)", subject, *(*subjects)[0].Descriptor) } return *storageKey.Value, nil } diff --git a/internal/cmd/auth/login/login.go b/internal/cmd/auth/login/login.go index 6b4d200a..6567bb1e 100644 --- a/internal/cmd/auth/login/login.go +++ b/internal/cmd/auth/login/login.go @@ -121,7 +121,8 @@ func loginRun(ctx util.CmdContext, opts *loginOptions) (err error) { result, err := p.Select( "What is your preferred protocol for Git operations?", options[0], - options) + options, + ) if err != nil { return err } @@ -154,7 +155,8 @@ func promptForOrganizationName(ctx util.CmdContext, _ *loginOptions) (organizati orgType, err := p.Select( "Azure DevOps Organization URL type?", options[0], - options) + options, + ) if err != nil { return organizationURL, organizationName, err } diff --git a/internal/cmd/auth/logout/logout.go b/internal/cmd/auth/logout/logout.go index d70f6c22..68e36d13 100644 --- a/internal/cmd/auth/logout/logout.go +++ b/internal/cmd/auth/logout/logout.go @@ -90,7 +90,8 @@ func logoutRun(ctx util.CmdContext, opts *logoutOptions) (err error) { organizationName = organizations[0] } else { selected, err := p.Select( - "What organization do you want to log out of?", "", organizations) + "What organization do you want to log out of?", "", organizations, + ) if err != nil { return fmt.Errorf("could not prompt: %w", err) } diff --git a/internal/cmd/auth/setupgit/setupgit.go b/internal/cmd/auth/setupgit/setupgit.go index 25335dc3..efd6d60c 100644 --- a/internal/cmd/auth/setupgit/setupgit.go +++ b/internal/cmd/auth/setupgit/setupgit.go @@ -121,7 +121,8 @@ func setupGitRun(ctx util.CmdContext, opts *setupGitOptions) (err error) { } // second configure the actual helper for this host - configureCmd, err := gitClient.Command(ctx.Context(), + configureCmd, err := gitClient.Command( + ctx.Context(), "config", "--global", "--add", credHelperKey, fmt.Sprintf("!%s auth git-credential", gitClient.GetAzDoPath()), @@ -134,7 +135,8 @@ func setupGitRun(ctx util.CmdContext, opts *setupGitOptions) (err error) { return err } - configureCmd, err = gitClient.Command(ctx.Context(), + configureCmd, err = gitClient.Command( + ctx.Context(), "config", "--global", "--add", fmt.Sprintf("%s.useHttpPath", strings.TrimSuffix(credHelperKey, ".helper")), "true", diff --git a/internal/cmd/boards/area/project/list/list.go b/internal/cmd/boards/area/project/list/list.go index 468dc6b3..f91f0823 100644 --- a/internal/cmd/boards/area/project/list/list.go +++ b/internal/cmd/boards/area/project/list/list.go @@ -137,7 +137,8 @@ func runList(ctx util.CmdContext, opts *listOptions) error { return fmt.Errorf("failed to construct request: %w", err) } - zap.L().Debug("listing project area paths", + zap.L().Debug( + "listing project area paths", zap.String("organization", scope.Organization), zap.String("project", scope.Project), zap.String("path", strings.TrimSpace(opts.path)), diff --git a/internal/cmd/pipelines/variablegroup/delete/delete.go b/internal/cmd/pipelines/variablegroup/delete/delete.go index 5a7bf8e0..3e8db6a2 100644 --- a/internal/cmd/pipelines/variablegroup/delete/delete.go +++ b/internal/cmd/pipelines/variablegroup/delete/delete.go @@ -112,7 +112,8 @@ func run(cmdCtx util.CmdContext, opts *options) error { return fmt.Errorf("no project assignments found to delete") } - zap.L().Debug("resolved variable group", + zap.L().Debug( + "resolved variable group", zap.String("organization", scope.Organization), zap.String("project", scope.Project), zap.String("input", scope.Targets[0]), @@ -150,7 +151,8 @@ func run(cmdCtx util.CmdContext, opts *options) error { return fmt.Errorf("failed to delete variable group %d: %w", groupID, err) } - zap.L().Debug("variable group deleted", + zap.L().Debug( + "variable group deleted", zap.Int("groupId", groupID), zap.Strings("projectIds", projectIDs), zap.String("organization", scope.Organization), diff --git a/internal/cmd/pr/comment/comment_test.go b/internal/cmd/pr/comment/comment_test.go index 457bc102..d86bf5eb 100644 --- a/internal/cmd/pr/comment/comment_test.go +++ b/internal/cmd/pr/comment/comment_test.go @@ -24,15 +24,16 @@ 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 } -func (f *fakePrompter) InputOrganizationName() (string, error) { return "", nil } -func (f *fakePrompter) Password(prompt string) (string, error) { return "", nil } -func (f *fakePrompter) AuthToken() (string, error) { return "", nil } -func (f *fakePrompter) Confirm(msg string, def bool) (bool, error) { return false, nil } -func (f *fakePrompter) ConfirmDeletion(required string) error { return nil } -func (f *fakePrompter) Secret(prompt string) (result string, err error) { return "", nil } + +func (f *fakePrompter) Input(label, def string) (string, error) { return f.val, nil } +func (f *fakePrompter) InputOrganizationName() (string, error) { return "", nil } +func (f *fakePrompter) Password(prompt string) (string, error) { return "", nil } +func (f *fakePrompter) AuthToken() (string, error) { return "", nil } +func (f *fakePrompter) Confirm(msg string, def bool) (bool, error) { return false, nil } +func (f *fakePrompter) ConfirmDeletion(required string) error { return nil } +func (f *fakePrompter) Secret(prompt string) (result string, err error) { return "", nil } func setupCommonMocks(ctrl *gomock.Controller) (*mocks.MockCmdContext, *mocks.MockRepository, *mocks.MockAzDOGitClient, *mocks.MockConnectionFactory, *bytes.Buffer, *bytes.Buffer) { io, _, out, errOut := iostreams.Test() diff --git a/internal/cmd/pr/create/create.go b/internal/cmd/pr/create/create.go index 87929a63..b4447965 100644 --- a/internal/cmd/pr/create/create.go +++ b/internal/cmd/pr/create/create.go @@ -227,7 +227,7 @@ func runCmd(ctx util.CmdContext, opts *createOptions) (err error) { opts.headBranch = normalizeBranch(opts.headBranch) } - // Prequisites + // Prerequisites // 1. Is the current branch the same as the base branch? // 2. Is the current branch the same as the head branch? // 3. Does the head branch exist? @@ -360,7 +360,8 @@ func runCmd(ctx util.CmdContext, opts *createOptions) (err error) { t := template.New( iostreams.Out, iostreams.TerminalWidth(), - iostreams.ColorEnabled()). + iostreams.ColorEnabled(), + ). WithTheme(iostreams.TerminalTheme()). WithFuncs(map[string]any{ "s": template.StringOrEmpty, diff --git a/internal/cmd/pr/merge/merge.go b/internal/cmd/pr/merge/merge.go index cb167323..51465ac0 100644 --- a/internal/cmd/pr/merge/merge.go +++ b/internal/cmd/pr/merge/merge.go @@ -121,7 +121,8 @@ func runCmd(ctx util.CmdContext, opts *mergeOptions) (err error) { return fmt.Errorf("failed to merge pull request: %w", err) } - fmt.Fprintf(iostreams.Out, "%s Merged pull request %s#%d\n", + fmt.Fprintf( + iostreams.Out, "%s Merged pull request %s#%d\n", iostreams.ColorScheme().SuccessIcon(), prRepo.FullName(), *pr.PullRequestId, diff --git a/internal/cmd/pr/pr.go b/internal/cmd/pr/pr.go index 2b37cd24..c9413c20 100644 --- a/internal/cmd/pr/pr.go +++ b/internal/cmd/pr/pr.go @@ -36,12 +36,14 @@ func NewCmdPR(ctx util.CmdContext) *cobra.Command { GroupID: "core", } - util.AddGroup(cmd, "General commands", + util.AddGroup( + cmd, "General commands", list.NewCmd(ctx), create.NewCmd(ctx), ) - util.AddGroup(cmd, "Targeted commands", + util.AddGroup( + cmd, "Targeted commands", checkout.NewCmd(ctx), close.NewCmd(ctx), comment.NewCmd(ctx), diff --git a/internal/cmd/pr/view/view.go b/internal/cmd/pr/view/view.go index 91ac1184..16c41fcd 100644 --- a/internal/cmd/pr/view/view.go +++ b/internal/cmd/pr/view/view.go @@ -482,7 +482,8 @@ func runCmd(ctx util.CmdContext, opts *viewOptions) (err error) { t := template.New( iostreams.Out, iostreams.TerminalWidth(), - iostreams.ColorEnabled()). + iostreams.ColorEnabled(), + ). WithTheme(iostreams.TerminalTheme()). WithFuncs(map[string]any{ "substr": func(s string, start, length int) string { diff --git a/internal/cmd/pr/vote/vote.go b/internal/cmd/pr/vote/vote.go index 7516f41f..2844a937 100644 --- a/internal/cmd/pr/vote/vote.go +++ b/internal/cmd/pr/vote/vote.go @@ -100,7 +100,8 @@ func runCmd(ctx util.CmdContext, opts *voteOptions) error { } // Feedback - fmt.Fprintf(io.Out, "%s Set vote to '%s' for %s#%d\n", + fmt.Fprintf( + io.Out, "%s Set vote to '%s' for %s#%d\n", io.ColorScheme().SuccessIcon(), opts.vote, prRepo.FullName(), diff --git a/internal/cmd/project/list/list.go b/internal/cmd/project/list/list.go index 2c3307f6..17e3801d 100644 --- a/internal/cmd/project/list/list.go +++ b/internal/cmd/project/list/list.go @@ -102,7 +102,7 @@ func runList(ctx util.CmdContext, opts *listOptions) (err error) { } sort.Slice(res, func(i, j int) bool { - return strings.ToLower(*(res[i].Name)) < strings.ToLower(*(res[j].Name)) + return strings.ToLower(*res[i].Name) < strings.ToLower(*res[j].Name) }) iostreams.StopProgressIndicator() diff --git a/internal/cmd/repo/list/list.go b/internal/cmd/repo/list/list.go index 6051006f..18ad0245 100644 --- a/internal/cmd/repo/list/list.go +++ b/internal/cmd/repo/list/list.go @@ -103,7 +103,7 @@ func runList(ctx util.CmdContext, opts *listOptions) (err error) { } sort.Slice(*res, func(i, j int) bool { - return strings.ToLower(*((*res)[i].Name)) < strings.ToLower(*((*res)[j].Name)) + return strings.ToLower(*(*res)[i].Name) < strings.ToLower(*(*res)[j].Name) }) iostreams.StopProgressIndicator() diff --git a/internal/cmd/repo/setdefault/setdefault.go b/internal/cmd/repo/setdefault/setdefault.go index 7e286ded..89c283d2 100644 --- a/internal/cmd/repo/setdefault/setdefault.go +++ b/internal/cmd/repo/setdefault/setdefault.go @@ -122,7 +122,8 @@ func setDefaultRun(ctx util.CmdContext, opts *setDefaultOptions) error { var msg string if currentDefaultRemote != nil { if err := gitClient.UnsetRemoteResolution( - ctx.Context(), currentDefaultRemote.Name); err != nil { + ctx.Context(), currentDefaultRemote.Name, + ); err != nil { return err } msg = fmt.Sprintf("%s Unset %s as default repository", @@ -180,14 +181,16 @@ func setDefaultRun(ctx util.CmdContext, opts *setDefaultOptions) error { if currentDefaultRemote != nil { if err := gitClient.UnsetRemoteResolution( ctx.Context(), - currentDefaultRemote.Name); err != nil { + currentDefaultRemote.Name, + ); err != nil { return err } } if err = gitClient.SetRemoteResolution( ctx.Context(), selectedRemote.Name, - resolution); err != nil { + resolution, + ); err != nil { return err } diff --git a/internal/cmd/security/group/membership/add/add.go b/internal/cmd/security/group/membership/add/add.go index b84cda9e..83ff77af 100644 --- a/internal/cmd/security/group/membership/add/add.go +++ b/internal/cmd/security/group/membership/add/add.go @@ -102,7 +102,8 @@ func runAdd(ctx util.CmdContext, o *opts) error { organization := target.Organization project := target.Project - zap.L().Debug("resolving group for membership add", + zap.L().Debug( + "resolving group for membership add", zap.String("organization", organization), zap.String("project", project), zap.String("group", target.Targets[0]), @@ -144,7 +145,8 @@ func runAdd(ctx util.CmdContext, o *opts) error { memberDescriptor := types.GetValue(memberSubject.Descriptor, "") - zap.L().Debug("checking existing membership", + zap.L().Debug( + "checking existing membership", zap.String("groupDescriptor", types.GetValue(group.Descriptor, "")), zap.String("memberDescriptor", memberDescriptor), ) @@ -173,7 +175,8 @@ func runAdd(ctx util.CmdContext, o *opts) error { return fmt.Errorf("failed to check existing membership for %q: %w", memberInput, err) } - zap.L().Debug("adding membership", + zap.L().Debug( + "adding membership", zap.String("groupDescriptor", types.GetValue(group.Descriptor, "")), zap.String("memberDescriptor", memberDescriptor), zap.String("member", memberInput), diff --git a/internal/cmd/security/group/membership/remove/remove.go b/internal/cmd/security/group/membership/remove/remove.go index 45e9c9b6..2d9ecb83 100644 --- a/internal/cmd/security/group/membership/remove/remove.go +++ b/internal/cmd/security/group/membership/remove/remove.go @@ -99,7 +99,8 @@ func runRemove(ctx util.CmdContext, o *opts) error { organization := target.Organization project := target.Project - zap.L().Debug("resolving group for membership removal", + zap.L().Debug( + "resolving group for membership removal", zap.String("organization", organization), zap.String("project", project), zap.String("group", target.Targets[0]), @@ -151,7 +152,8 @@ func runRemove(ctx util.CmdContext, o *opts) error { memberDescriptor := types.GetValue(memberSubject.Descriptor, "") displayName := types.GetValue(memberSubject.DisplayName, memberDescriptor) - zap.L().Debug("checking membership before removal", + zap.L().Debug( + "checking membership before removal", zap.String("groupDescriptor", types.GetValue(group.Descriptor, "")), zap.String("memberDescriptor", memberDescriptor), ) @@ -227,7 +229,8 @@ func runRemove(ctx util.CmdContext, o *opts) error { continue } - zap.L().Debug("removing membership", + zap.L().Debug( + "removing membership", zap.String("groupDescriptor", types.GetValue(group.Descriptor, "")), zap.String("memberDescriptor", candidates[i].descriptor), ) diff --git a/internal/cmd/security/permission/delete/delete.go b/internal/cmd/security/permission/delete/delete.go index 64937297..a657d2b1 100644 --- a/internal/cmd/security/permission/delete/delete.go +++ b/internal/cmd/security/permission/delete/delete.go @@ -99,7 +99,8 @@ func runCommand(ctx util.CmdContext, o *opts) error { return util.FlagErrorf("a subject is required") } - zap.L().Debug("Resolved target for permission delete", + zap.L().Debug( + "Resolved target for permission delete", zap.String("organization", scope.Organization), zap.String("project", scope.Project), zap.String("subject", scope.Subject), @@ -150,7 +151,8 @@ func runCommand(ctx util.CmdContext, o *opts) error { ios.StartProgressIndicator() } - zap.L().Debug("Removing access control entries", + zap.L().Debug( + "Removing access control entries", zap.String("namespaceId", namespaceUUID.String()), zap.String("token", tokenValue), zap.String("descriptor", descriptor), @@ -168,7 +170,8 @@ func runCommand(ctx util.CmdContext, o *opts) error { return fmt.Errorf("failed to delete permissions: service returned no confirmation") } - zap.L().Debug("Verifying permissions are removed", + zap.L().Debug( + "Verifying permissions are removed", zap.String("token", tokenValue), zap.String("descriptor", descriptor), ) @@ -211,7 +214,8 @@ func aclHasDescriptor(acls *[]security.AccessControlList, descriptor string) boo if hasAllow || hasDeny { return true } - zap.L().Debug("Skipping empty ACL entry for descriptor", + zap.L().Debug( + "Skipping empty ACL entry for descriptor", zap.String("descriptor", descriptor), zap.Int("allow", types.GetValue(ace.Allow, 0)), zap.Int("deny", types.GetValue(ace.Deny, 0)), diff --git a/internal/cmd/serviceendpoint/create/create.go b/internal/cmd/serviceendpoint/create/create.go index 03a6bd8b..d3088db4 100644 --- a/internal/cmd/serviceendpoint/create/create.go +++ b/internal/cmd/serviceendpoint/create/create.go @@ -86,7 +86,8 @@ func runCreateFromFile(ctx util.CmdContext, opts *fromFileOptions) error { return err } - zap.L().Debug("Creating service endpoint from file", + zap.L().Debug( + "Creating service endpoint from file", zap.String("organization", scope.Organization), zap.String("project", scope.Project), zap.String("input", shared.DescribeInput(opts.fromFile)), diff --git a/internal/cmd/serviceendpoint/delete/delete.go b/internal/cmd/serviceendpoint/delete/delete.go index b95f3a30..4d9f059b 100644 --- a/internal/cmd/serviceendpoint/delete/delete.go +++ b/internal/cmd/serviceendpoint/delete/delete.go @@ -160,7 +160,8 @@ func runDelete(ctx util.CmdContext, opts *deleteOptions) error { projectIDs = append(projectIDs, target.ID) } - zap.L().Debug("Deleting service endpoint", + zap.L().Debug( + "Deleting service endpoint", zap.String("organization", scope.Organization), zap.String("project", scope.Project), zap.String("identifier", scope.Targets[0]), diff --git a/internal/cmd/serviceendpoint/export/export.go b/internal/cmd/serviceendpoint/export/export.go index 19be5567..9270cc6d 100644 --- a/internal/cmd/serviceendpoint/export/export.go +++ b/internal/cmd/serviceendpoint/export/export.go @@ -142,7 +142,8 @@ func runExport(ctx util.CmdContext, opts *exportOptions) error { } } - zap.L().Debug("Exporting service endpoint", + zap.L().Debug( + "Exporting service endpoint", zap.String("organization", scope.Organization), zap.String("project", scope.Project), zap.String("identifier", scope.Targets[0]), diff --git a/internal/cmd/serviceendpoint/list/list.go b/internal/cmd/serviceendpoint/list/list.go index 9a9356e5..65821835 100644 --- a/internal/cmd/serviceendpoint/list/list.go +++ b/internal/cmd/serviceendpoint/list/list.go @@ -132,7 +132,8 @@ func runList(ctx util.CmdContext, opts *listOptions) error { actionFilter = types.ToPtr(value) } - zap.L().Debug("Listing service endpoints", + zap.L().Debug( + "Listing service endpoints", zap.String("organization", opts.organization), zap.String("project", opts.project), zap.String("type", strings.TrimSpace(opts.typeFilter)), diff --git a/internal/cmd/serviceendpoint/shared/output.go b/internal/cmd/serviceendpoint/shared/output.go index 3fb4104c..ad135dca 100644 --- a/internal/cmd/serviceendpoint/shared/output.go +++ b/internal/cmd/serviceendpoint/shared/output.go @@ -30,7 +30,8 @@ func Output(ctx util.CmdContext, endpoint *serviceendpoint.ServiceEndpoint, expo t := template.New( ios.Out, ios.TerminalWidth(), - ios.ColorEnabled()). + ios.ColorEnabled(), + ). WithTheme(ios.TerminalTheme()). WithFuncs(map[string]any{ "s": template.StringOrEmpty, diff --git a/internal/cmd/serviceendpoint/update/update.go b/internal/cmd/serviceendpoint/update/update.go index 462cbdf0..526edb73 100644 --- a/internal/cmd/serviceendpoint/update/update.go +++ b/internal/cmd/serviceendpoint/update/update.go @@ -198,7 +198,8 @@ func run(ctx util.CmdContext, o *opts) error { } if fromFileSet { - fields = append(fields, + fields = append( + fields, zap.String("mode", "from-file"), zap.String("input", shared.DescribeInput(o.fromFile)), zap.String("encoding", o.encoding), @@ -232,7 +233,8 @@ func run(ctx util.CmdContext, o *opts) error { return fmt.Errorf("updated service endpoint is missing an ID") } - if err := shared.SetAllPipelinesAccessToEndpoint(ctx, + if err := shared.SetAllPipelinesAccessToEndpoint( + ctx, scope.Organization, projectID, endpointID, diff --git a/internal/cmd/team/show/show.go b/internal/cmd/team/show/show.go index fe9aabaf..9cdba189 100644 --- a/internal/cmd/team/show/show.go +++ b/internal/cmd/team/show/show.go @@ -82,7 +82,8 @@ func runShow(ctx util.CmdContext, opts *showOptions) error { return util.FlagErrorWrap(err) } - zap.L().Debug("show team", + zap.L().Debug( + "show team", zap.String("organization", scope.Organization), zap.String("project", scope.Project), zap.String("teamId", scope.Targets[0]), @@ -124,7 +125,8 @@ func renderTeam(ctx util.CmdContext, ios *iostreams.IOStreams, team *core.WebApi t := template.New( ios.Out, ios.TerminalWidth(), - ios.ColorEnabled()). + ios.ColorEnabled(), + ). WithTheme(ios.TerminalTheme()). WithFuncs(map[string]any{ "s": template.StringOrEmpty, diff --git a/internal/jq/jq.go b/internal/jq/jq.go index 181856b2..df274d9e 100644 --- a/internal/jq/jq.go +++ b/internal/jq/jq.go @@ -87,7 +87,8 @@ func CompileExpression(expr string) (*gojq.Code, error) { code, err := gojq.Compile( query, - gojq.WithEnvironLoader(os.Environ)) + gojq.WithEnvironLoader(os.Environ), + ) if err != nil { return nil, fmt.Errorf("failed to compile jq expression: %w", err) } diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 14fb5ae3..f5ec525d 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -140,7 +140,9 @@ func (p *surveyPrompter) ConfirmDeletion(requiredValue string) error { return fmt.Errorf("You entered %s", str) //nolint:staticcheck } return nil - })) + }, + ), + ) } func (p *surveyPrompter) InputOrganizationName() (result string, err error) { @@ -156,7 +158,8 @@ func (p *surveyPrompter) InputOrganizationName() (result string, err error) { return fmt.Errorf("invalid organization name") } return nil - })) + }), + ) return result, err } From 0481a1630122c379cc949717d2adfc1fb2004b2b Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 14 Jun 2026 14:59:03 +0000 Subject: [PATCH 5/8] =?UTF-8?q?feat(pipelines):=20=20=E2=9C=A8add=20runs?= =?UTF-8?q?=20list=20command=20for=20pipelines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `pipelines runs list` (aliases: `l`, `ls`) to list pipeline runs in an Azure DevOps project. Supports filtering by pipeline ID, branch, status, result, reason, requester, and tags, with ordering, pagination, and JSON export options. Mirrors the `az pipelines runs list` behavior. --- internal/cmd/pipelines/pipelines.go | 2 + internal/cmd/pipelines/runs/list/list.go | 325 +++++++++++++++++++++++ internal/cmd/pipelines/runs/runs.go | 19 ++ 3 files changed, 346 insertions(+) create mode 100644 internal/cmd/pipelines/runs/list/list.go create mode 100644 internal/cmd/pipelines/runs/runs.go diff --git a/internal/cmd/pipelines/pipelines.go b/internal/cmd/pipelines/pipelines.go index 0565afc6..b770e080 100644 --- a/internal/cmd/pipelines/pipelines.go +++ b/internal/cmd/pipelines/pipelines.go @@ -5,6 +5,7 @@ import ( "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/agent" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/list" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/pool" + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/runs" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/variablegroup" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -17,6 +18,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { } cmd.AddCommand(list.NewCmd(ctx)) + cmd.AddCommand(runs.NewCmd(ctx)) cmd.AddCommand(variablegroup.NewCmd(ctx)) cmd.AddCommand(agent.NewCmd(ctx)) cmd.AddCommand(pool.NewCmd(ctx)) diff --git a/internal/cmd/pipelines/runs/list/list.go b/internal/cmd/pipelines/runs/list/list.go new file mode 100644 index 00000000..f8f1d9dc --- /dev/null +++ b/internal/cmd/pipelines/runs/list/list.go @@ -0,0 +1,325 @@ +package list + +import ( + "fmt" + "strconv" + "strings" + + "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" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type runOptions struct { + scopeArg string + + pipelineIDs []int + branches []string + statuses []string + results []string + reasons []string + requestedFor string + tags []string + queryOrder string + + top int + maxItems int + + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &runOptions{} + + cmd := &cobra.Command{ + Use: "list [ORGANIZATION/]PROJECT", + Short: "List runs of pipelines in a project.", + Long: heredoc.Doc(` + List runs of pipelines in an Azure DevOps project. Mirrors + 'az pipelines runs list'. + + Filters support pipeline, branch, status, result, reason, requester, + and tags. The full result set is paginated server-side; use + --max-items to cap the response client-side. + `), + Example: heredoc.Doc(` + # List the 20 most recent runs for a project (default org) + azdo pipelines runs list Fabrikam --top 20 + + # Filter by pipeline and branch + azdo pipelines runs list MyOrg/Fabrikam --pipeline-id 42 --branch main + + # Order by queue time, descending + azdo pipelines runs list Fabrikam --query-order queueTimeDescending + + # Export as JSON + azdo pipelines runs list Fabrikam --json id,buildNumber,status,result + `), + Aliases: []string{"l", "ls"}, + Args: util.ExactArgs(1, "project argument required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.scopeArg = args[0] + return runCmd(ctx, opts) + }, + } + + cmd.Flags().IntSliceVar(&opts.pipelineIDs, "pipeline-id", nil, "Limit to runs for these pipeline IDs (repeatable; first value is honored by the SDK).") + cmd.Flags().StringSliceVar(&opts.branches, "branch", nil, "Filter by source branch (repeatable; first value is honored by the SDK). Bare names get refs/heads/ prepended.") + cmd.Flags().StringSliceVar(&opts.statuses, "status", nil, "Filter by status (repeatable; first value is honored). Valid: none, inProgress, completed, cancelling, postponed, notStarted, all.") + cmd.Flags().StringSliceVar(&opts.results, "result", nil, "Filter by result (repeatable; first value is honored). Valid: none, succeeded, partiallySucceeded, failed, canceled.") + cmd.Flags().StringSliceVar(&opts.reasons, "reason", nil, "Filter by reason (repeatable; first value is honored). Valid: manual, individualCI, batchedCI, schedule, scheduleForced, userCreated, pullRequest, etc.") + cmd.Flags().StringVar(&opts.requestedFor, "requested-for", "", "Filter by the user who queued the run. Accepts @me to mean the authenticated user.") + cmd.Flags().StringSliceVar(&opts.tags, "tag", nil, "Filter by tags (all supplied tags must match).") + cmd.Flags().StringVar(&opts.queryOrder, "query-order", "", "Order the results: finishTimeAscending, finishTimeDescending, queueTimeAscending, queueTimeDescending, startTimeAscending, startTimeDescending.") + cmd.Flags().IntVar(&opts.top, "top", 0, "Maximum number of runs to request per server page (0 = server default).") + cmd.Flags().IntVar(&opts.maxItems, "max-items", 0, "Maximum number of runs to return client-side (0 = unlimited).") + + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "id", "buildNumber", "status", "result", "reason", + "definition", "project", "sourceBranch", "sourceVersion", + "startTime", "finishTime", "queueTime", "requestedBy", "requestedFor", + "tags", "uri", "url", + }) + + return cmd +} + +func runCmd(ctx util.CmdContext, opts *runOptions) error { + if opts.top < 0 { + return util.FlagErrorf("--top must be >= 0") + } + if opts.maxItems < 0 { + return util.FlagErrorf("--max-items must be >= 0") + } + + ios, err := ctx.IOStreams() + if err != nil { + return err + } + + ios.StartProgressIndicator() + + scope, err := util.ParseProjectScope(ctx, opts.scopeArg) + if err != nil { + ios.StopProgressIndicator() + return util.FlagErrorWrap(err) + } + + client, err := ctx.ClientFactory().Build(ctx.Context(), scope.Organization) + if err != nil { + ios.StopProgressIndicator() + return fmt.Errorf("failed to create Build client: %w", err) + } + + requestedFor := opts.requestedFor + if strings.EqualFold(requestedFor, "@me") { + zap.L().Debug("resolving @me to current user identity", zap.String("organization", scope.Organization)) + + extensionsClient, err := ctx.ClientFactory().Extensions(ctx.Context(), scope.Organization) + if err != nil { + ios.StopProgressIndicator() + return fmt.Errorf("failed to create Extensions client: %w", err) + } + + identityClient, err := ctx.ClientFactory().Identity(ctx.Context(), scope.Organization) + if err != nil { + ios.StopProgressIndicator() + return fmt.Errorf("failed to create Identity client: %w", err) + } + + selfID, err := extensionsClient.GetSelfID(ctx.Context()) + if err != nil { + ios.StopProgressIndicator() + return fmt.Errorf("failed to resolve @me identity: %w", err) + } + + idStr := selfID.String() + identities, err := identityClient.ReadIdentities(ctx.Context(), identity.ReadIdentitiesArgs{ + IdentityIds: &idStr, + }) + if err != nil { + ios.StopProgressIndicator() + return fmt.Errorf("failed to resolve @me identity details: %w", err) + } + if identities == nil || len(*identities) != 1 { + ios.StopProgressIndicator() + return fmt.Errorf("failed to resolve @me identity details") + } + + requestedFor = types.GetValue((*identities)[0].ProviderDisplayName, "") + } + + project := scope.Project + bArgs := build.GetBuildsArgs{Project: &project} + if ids := opts.pipelineIDs; len(ids) > 0 { + first := ids + bArgs.Definitions = &first + } + if len(opts.branches) > 0 { + branch := opts.branches[0] + if !strings.HasPrefix(branch, "refs/") { + branch = "refs/heads/" + branch + } + bArgs.BranchName = &branch + } + if len(opts.statuses) > 0 { + status, ok := types.LookupEnum(opts.statuses[0], allBuildStatuses) + if !ok { + ios.StopProgressIndicator() + return util.FlagErrorf("unknown --status value %q", opts.statuses[0]) + } + bArgs.StatusFilter = &status + } + if len(opts.results) > 0 { + result, ok := types.LookupEnum(opts.results[0], allBuildResults) + if !ok { + ios.StopProgressIndicator() + return util.FlagErrorf("unknown --result value %q", opts.results[0]) + } + bArgs.ResultFilter = &result + } + if len(opts.reasons) > 0 { + reason, ok := types.LookupEnum(opts.reasons[0], allBuildReasons) + if !ok { + ios.StopProgressIndicator() + return util.FlagErrorf("unknown --reason value %q", opts.reasons[0]) + } + bArgs.ReasonFilter = &reason + } + if requestedFor != "" { + bArgs.RequestedFor = &requestedFor + } + if len(opts.tags) > 0 { + bArgs.TagFilters = &opts.tags + } + if opts.queryOrder != "" { + order, ok := types.LookupEnum(opts.queryOrder, allBuildQueryOrders) + if !ok { + ios.StopProgressIndicator() + return util.FlagErrorf("unknown --query-order value %q", opts.queryOrder) + } + bArgs.QueryOrder = &order + } + if opts.top > 0 { + bArgs.Top = &opts.top + } + + runs := make([]build.Build, 0) +paginate: + for { + resp, err := client.GetBuilds(ctx.Context(), bArgs) + if err != nil { + ios.StopProgressIndicator() + return fmt.Errorf("GetBuilds: %w", err) + } + if resp != nil { + for _, b := range resp.Value { + runs = append(runs, b) + if opts.maxItems > 0 && len(runs) >= opts.maxItems { + break paginate + } + } + } + if resp == nil || resp.ContinuationToken == "" { + break + } + token := resp.ContinuationToken + bArgs.ContinuationToken = &token + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, runs) + } + + tp, err := ctx.Printer("table") + if err != nil { + return fmt.Errorf("printer: %w", err) + } + tp.AddColumns("ID", "NUMBER", "STATUS", "RESULT", "REASON", "PIPELINE", "BRANCH", "REQUESTED FOR", "STARTED", "FINISHED") + for i := range runs { + run := runs[i] + tp.AddField(strconv.Itoa(types.GetValue(run.Id, 0))) + tp.AddField(types.GetValue(run.BuildNumber, "")) + tp.AddField(string(types.GetValue(run.Status, build.BuildStatus("")))) + tp.AddField(string(types.GetValue(run.Result, build.BuildResult("")))) + tp.AddField(string(types.GetValue(run.Reason, build.BuildReason("")))) + + var defName string + if def := run.Definition; def != nil { + if def.Name != nil && *def.Name != "" { + defName = *def.Name + } else if def.Id != nil { + defName = strconv.Itoa(*def.Id) + } + } + tp.AddField(defName) + + tp.AddField(types.GetValue(run.SourceBranch, "")) + + var identName string + if ref := run.RequestedFor; ref != nil { + if name := types.GetValue(ref.DisplayName, ""); name != "" { + identName = name + } else { + identName = types.GetValue(ref.UniqueName, "") + } + } + tp.AddField(identName) + + tp.AddField(util.FormatTimeShort(run.StartTime)) + tp.AddField(util.FormatTimeShort(run.FinishTime)) + tp.EndRow() + } + return tp.Render() +} + +var allBuildStatuses = []build.BuildStatus{ + build.BuildStatusValues.None, + build.BuildStatusValues.InProgress, + build.BuildStatusValues.Completed, + build.BuildStatusValues.Cancelling, + build.BuildStatusValues.Postponed, + build.BuildStatusValues.NotStarted, + build.BuildStatusValues.All, +} + +var allBuildResults = []build.BuildResult{ + build.BuildResultValues.None, + build.BuildResultValues.Succeeded, + build.BuildResultValues.PartiallySucceeded, + build.BuildResultValues.Failed, + build.BuildResultValues.Canceled, +} + +var allBuildReasons = []build.BuildReason{ + build.BuildReasonValues.None, + build.BuildReasonValues.Manual, + build.BuildReasonValues.IndividualCI, + build.BuildReasonValues.BatchedCI, + build.BuildReasonValues.Schedule, + build.BuildReasonValues.ScheduleForced, + build.BuildReasonValues.UserCreated, + build.BuildReasonValues.ValidateShelveset, + build.BuildReasonValues.CheckInShelveset, + build.BuildReasonValues.PullRequest, + build.BuildReasonValues.BuildCompletion, + build.BuildReasonValues.ResourceTrigger, + build.BuildReasonValues.Triggered, + build.BuildReasonValues.All, +} + +var allBuildQueryOrders = []build.BuildQueryOrder{ + build.BuildQueryOrderValues.FinishTimeAscending, + build.BuildQueryOrderValues.FinishTimeDescending, + build.BuildQueryOrderValues.QueueTimeDescending, + build.BuildQueryOrderValues.QueueTimeAscending, + build.BuildQueryOrderValues.StartTimeDescending, + build.BuildQueryOrderValues.StartTimeAscending, +} diff --git a/internal/cmd/pipelines/runs/runs.go b/internal/cmd/pipelines/runs/runs.go new file mode 100644 index 00000000..2bf8b6f6 --- /dev/null +++ b/internal/cmd/pipelines/runs/runs.go @@ -0,0 +1,19 @@ +package runs + +import ( + "github.com/spf13/cobra" + + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/runs/list" + "github.com/tmeckel/azdo-cli/internal/cmd/util" +) + +func NewCmd(ctx util.CmdContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "runs", + Short: "Manage pipeline runs", + Long: "Manage pipeline runs in an Azure DevOps project.", + } + + cmd.AddCommand(list.NewCmd(ctx)) + return cmd +} From cd4a9b754b5619e91c216efdf278b5abfd89609e Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 14 Jun 2026 15:00:32 +0000 Subject: [PATCH 6/8] =?UTF-8?q?test(pipelines):=20=F0=9F=A7=AA=20add=20tes?= =?UTF-8?q?ts=20for=20runs=20list=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cmd/pipelines/runs/list/list_test.go | 381 ++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 internal/cmd/pipelines/runs/list/list_test.go diff --git a/internal/cmd/pipelines/runs/list/list_test.go b/internal/cmd/pipelines/runs/list/list_test.go new file mode 100644 index 00000000..2082f18c --- /dev/null +++ b/internal/cmd/pipelines/runs/list/list_test.go @@ -0,0 +1,381 @@ +package list + +import ( + "bytes" + "context" + "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" + "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/iostreams" + "github.com/tmeckel/azdo-cli/internal/mocks" + "github.com/tmeckel/azdo-cli/internal/printer" +) + +type dependencies struct { + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + build *mocks.MockBuildClient + ext *mocks.MockAzDOExtension + ident *mocks.MockIdentityClient + cfg *mocks.MockConfig + auth *mocks.MockAuthConfig + stdout *bytes.Buffer + stderr *bytes.Buffer +} + +func newDependencies(t *testing.T, organization string) *dependencies { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, serr := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + deps := &dependencies{ + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + build: mocks.NewMockBuildClient(ctrl), + ext: mocks.NewMockAzDOExtension(ctrl), + ident: mocks.NewMockIdentityClient(ctrl), + cfg: mocks.NewMockConfig(ctrl), + auth: mocks.NewMockAuthConfig(ctrl), + stdout: out, + stderr: serr, + } + + deps.cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + deps.cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + deps.cmd.EXPECT().ClientFactory().Return(deps.clientFact).AnyTimes() + deps.clientFact.EXPECT().Build(gomock.Any(), organization).Return(deps.build, nil).AnyTimes() + + tp, err := printer.NewTablePrinter(out, false, 200) + require.NoError(t, err) + deps.cmd.EXPECT().Printer("table").Return(tp, nil).AnyTimes() + + return deps +} + +func newDependenciesWithConfig(t *testing.T, defaultOrg string) *dependencies { + deps := newDependencies(t, defaultOrg) + deps.cmd.EXPECT().Config().Return(deps.cfg, nil).AnyTimes() + deps.cfg.EXPECT().Authentication().Return(deps.auth).AnyTimes() + deps.auth.EXPECT().GetDefaultOrganization().Return(defaultOrg, nil).AnyTimes() + return deps +} + +func sampleBuild(id int) build.Build { + idPtr := id + bnum := strconv.Itoa(id) + pipelineName := "MyPipeline" + sourceBranch := "refs/heads/main" + dispName := "Alice" + uniqName := "alice@x.com" + 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}, + SourceBranch: &sourceBranch, + RequestedFor: &webapi.IdentityRef{DisplayName: &dispName, UniqueName: &uniqName}, + StartTime: &azuredevops.Time{}, + FinishTime: &azuredevops.Time{}, + } +} + +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 TestRunList_DefaultNoFilters(t *testing.T) { + t.Parallel() + deps := newDependencies(t, "MyOrg") + + builds := []build.Build{sampleBuild(1)} + deps.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()). + Return(&build.GetBuildsResponseValue{Value: builds, ContinuationToken: ""}, nil) + + err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam"}) + require.NoError(t, err) +} + +func TestRunList_ScopeParsing(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + scopeArg string + wantOrg string + wantProj string + withCfg bool + }{ + {name: "project without org uses config default", scopeArg: "Fabrikam", wantOrg: "default-org", wantProj: "Fabrikam", withCfg: true}, + {name: "org/project parses both parts", scopeArg: "MyOrg/Fabrikam", wantOrg: "MyOrg", wantProj: "Fabrikam", withCfg: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var deps *dependencies + if tt.withCfg { + deps = newDependenciesWithConfig(t, tt.wantOrg) + } else { + deps = newDependencies(t, tt.wantOrg) + } + + builds := []build.Build{sampleBuild(1)} + deps.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + assert.Equal(t, tt.wantProj, *args.Project) + return &build.GetBuildsResponseValue{Value: builds, ContinuationToken: ""}, nil + }) + + err := runCmd(deps.cmd, &runOptions{scopeArg: tt.scopeArg}) + require.NoError(t, err) + }) + } +} + +func TestRunList_PipelineID(t *testing.T) { + t.Parallel() + deps := newDependencies(t, "MyOrg") + + deps.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + require.NotNil(t, args.Definitions) + require.Len(t, *args.Definitions, 1) + assert.Equal(t, 42, (*args.Definitions)[0]) + return &build.GetBuildsResponseValue{ContinuationToken: ""}, nil + }) + + err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam", pipelineIDs: []int{42}}) + require.NoError(t, err) +} + +func TestRunList_BranchRefsHeadsPrepended(t *testing.T) { + t.Parallel() + deps := newDependencies(t, "MyOrg") + + deps.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + require.NotNil(t, args.BranchName) + assert.Equal(t, "refs/heads/main", *args.BranchName) + return &build.GetBuildsResponseValue{ContinuationToken: ""}, nil + }) + + err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam", branches: []string{"main"}}) + require.NoError(t, err) +} + +func TestRunList_BranchRefsUnchanged(t *testing.T) { + t.Parallel() + deps := newDependencies(t, "MyOrg") + + deps.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + require.NotNil(t, args.BranchName) + assert.Equal(t, "refs/tags/v1.0", *args.BranchName) + return &build.GetBuildsResponseValue{ContinuationToken: ""}, nil + }) + + err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam", branches: []string{"refs/tags/v1.0"}}) + require.NoError(t, err) +} + +func TestRunList_InvalidFilters(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + opts runOptions + want string + }{ + {name: "invalid status", opts: runOptions{scopeArg: "MyOrg/Fabrikam", statuses: []string{"INVALID_STATUS"}}, want: "unknown --status"}, + {name: "invalid result", opts: runOptions{scopeArg: "MyOrg/Fabrikam", results: []string{"INVALID_RESULT"}}, want: "unknown --result"}, + {name: "invalid reason", opts: runOptions{scopeArg: "MyOrg/Fabrikam", reasons: []string{"INVALID_REASON"}}, want: "unknown --reason"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps := newDependencies(t, "MyOrg") + err := runCmd(deps.cmd, &tt.opts) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.want) + }) + } +} + +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) + + var capturedRequestedFor string + deps.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + if args.RequestedFor != nil { + capturedRequestedFor = *args.RequestedFor + } + return &build.GetBuildsResponseValue{ContinuationToken: ""}, nil + }) + + err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam", requestedFor: "@me"}) + require.NoError(t, err) + assert.Equal(t, "Alice", capturedRequestedFor) +} + +func TestRunList_QueryOrder(t *testing.T) { + t.Parallel() + deps := newDependencies(t, "MyOrg") + + deps.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + require.NotNil(t, args.QueryOrder) + assert.Equal(t, build.BuildQueryOrderValues.QueueTimeDescending, *args.QueryOrder) + return &build.GetBuildsResponseValue{ContinuationToken: ""}, nil + }) + + err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam", queryOrder: "queueTimeDescending"}) + require.NoError(t, err) +} + +func TestRunList_Top(t *testing.T) { + t.Parallel() + deps := newDependencies(t, "MyOrg") + + deps.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + require.NotNil(t, args.Top) + assert.Equal(t, 50, *args.Top) + return &build.GetBuildsResponseValue{ContinuationToken: ""}, nil + }) + + err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam", top: 50}) + require.NoError(t, err) +} + +func TestRunList_Pagination(t *testing.T) { + t.Parallel() + + t.Run("paginates across pages with token propagation", func(t *testing.T) { + deps := newDependencies(t, "MyOrg") + + page1 := []build.Build{sampleBuild(1), sampleBuild(2)} + page2 := []build.Build{sampleBuild(3)} + + var capturedToken string + firstCall := true + deps.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + if firstCall { + assert.Nil(t, args.ContinuationToken, "first call must have no token") + firstCall = false + } else { + require.NotNil(t, args.ContinuationToken) + capturedToken = *args.ContinuationToken + } + if capturedToken == "" { + return &build.GetBuildsResponseValue{Value: page1, ContinuationToken: "next-token"}, nil + } + return &build.GetBuildsResponseValue{Value: page2, ContinuationToken: ""}, nil + }).Times(2) + + err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam"}) + require.NoError(t, err) + assert.Equal(t, "next-token", capturedToken) + + output := deps.stdout.String() + assert.Contains(t, output, "3") + }) + + t.Run("max-items truncates and skips remaining pages", func(t *testing.T) { + deps := newDependencies(t, "MyOrg") + + builds := []build.Build{sampleBuild(1), sampleBuild(2), sampleBuild(3)} + deps.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, args build.GetBuildsArgs) (*build.GetBuildsResponseValue, error) { + assert.Nil(t, args.ContinuationToken, "only first page fetched") + return &build.GetBuildsResponseValue{Value: builds, ContinuationToken: "more-token"}, nil + }).Times(1) + + err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam", maxItems: 2}) + require.NoError(t, err) + + output := deps.stdout.String() + assert.Contains(t, output, "1") + assert.Contains(t, output, "2") + assert.NotContains(t, output, "3") + }) +} + +func TestRunList_JSONOutput(t *testing.T) { + t.Parallel() + deps := newDependencies(t, "MyOrg") + + builds := []build.Build{sampleBuild(1)} + deps.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()). + Return(&build.GetBuildsResponseValue{Value: builds, ContinuationToken: ""}, nil) + + spy := &spyExporter{} + err := runCmd(deps.cmd, &runOptions{ + scopeArg: "MyOrg/Fabrikam", + exporter: spy, + }) + require.NoError(t, err) + assert.Equal(t, 1, spy.writes) + require.NotNil(t, spy.got) + + gotBuilds, ok := spy.got.([]build.Build) + require.True(t, ok, "exporter must receive []build.Build") + require.Len(t, gotBuilds, 1) + assert.Equal(t, 1, *gotBuilds[0].Id) +} + +func TestRunList_TableOutput(t *testing.T) { + t.Parallel() + deps := newDependencies(t, "MyOrg") + + builds := []build.Build{sampleBuild(1)} + deps.build.EXPECT().GetBuilds(gomock.Any(), gomock.Any()). + Return(&build.GetBuildsResponseValue{Value: builds, ContinuationToken: ""}, nil) + + err := runCmd(deps.cmd, &runOptions{scopeArg: "MyOrg/Fabrikam"}) + require.NoError(t, err) + + output := deps.stdout.String() + assert.Contains(t, output, "1\t1\tcompleted\tsucceeded\tmanual\tMyPipeline\trefs/heads/main\tAlice") +} From e0e54c86c6a0411bb0fcbef4db15ca8e391f16e0 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 14 Jun 2026 15:05:58 +0000 Subject: [PATCH 7/8] chore(pre-commit): remove markdownlint-cli2 hook and .markdownlint.yaml --- .markdownlint.yaml | 3 --- .pre-commit-config.yaml | 5 ----- 2 files changed, 8 deletions(-) delete mode 100644 .markdownlint.yaml diff --git a/.markdownlint.yaml b/.markdownlint.yaml deleted file mode 100644 index eb89a3c7..00000000 --- a/.markdownlint.yaml +++ /dev/null @@ -1,3 +0,0 @@ ---- -MD013: false -MD024: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5ee6552f..405b315b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,11 +24,6 @@ repos: hooks: - id: yamllint - - repo: https://github.com/DavidAnson/markdownlint-cli2 - rev: v0.22.1 - hooks: - - id: markdownlint-cli2 - - repo: https://github.com/rhysd/actionlint rev: v1.7.12 hooks: From df5cfafd685acedfe99d4443e468e8ad0d37961e Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Sun, 14 Jun 2026 15:06:14 +0000 Subject: [PATCH 8/8] =?UTF-8?q?docs(pipelines):=20=F0=9F=93=84=20add=20doc?= =?UTF-8?q?umentation=20for=20pipelines=20runs=20list=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/azdo_help_reference.md | 30 ++++++++++ docs/azdo_pipelines.md | 1 + docs/azdo_pipelines_runs.md | 11 ++++ docs/azdo_pipelines_runs_list.md | 98 ++++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+) create mode 100644 docs/azdo_pipelines_runs.md create mode 100644 docs/azdo_pipelines_runs_list.md diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index b3848368..aeb430eb 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -339,6 +339,36 @@ Aliases view, status ``` +### `azdo pipelines runs` + +Manage pipeline runs + +#### `azdo pipelines runs list [ORGANIZATION/]PROJECT [flags]` + +List runs of pipelines in a project. + +``` + --branch strings Filter by source branch (repeatable; first value is honored by the SDK). Bare names get refs/heads/ prepended. +-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 runs to return client-side (0 = unlimited). + --pipeline-id ints Limit to runs for these pipeline IDs (repeatable; first value is honored by the SDK). + --query-order string Order the results: finishTimeAscending, finishTimeDescending, queueTimeAscending, queueTimeDescending, startTimeAscending, startTimeDescending. + --reason strings Filter by reason (repeatable; first value is honored). Valid: manual, individualCI, batchedCI, schedule, scheduleForced, userCreated, pullRequest, etc. + --requested-for string Filter by the user who queued the run. Accepts @me to mean the authenticated user. + --result strings Filter by result (repeatable; first value is honored). Valid: none, succeeded, partiallySucceeded, failed, canceled. + --status strings Filter by status (repeatable; first value is honored). Valid: none, inProgress, completed, cancelling, postponed, notStarted, all. + --tag strings Filter by tags (all supplied tags must match). +-t, --template string Format JSON output using a Go template; see "azdo help formatting" + --top int Maximum number of runs to request per server page (0 = server default). +``` + +Aliases + +``` +l, ls +``` + ### `azdo pipelines variable-group` Manage Azure DevOps variable groups diff --git a/docs/azdo_pipelines.md b/docs/azdo_pipelines.md index f720ed92..88bd51e8 100644 --- a/docs/azdo_pipelines.md +++ b/docs/azdo_pipelines.md @@ -7,6 +7,7 @@ Manage Azure DevOps pipelines * [azdo pipelines agent](./azdo_pipelines_agent.md) * [azdo pipelines list](./azdo_pipelines_list.md) * [azdo pipelines pool](./azdo_pipelines_pool.md) +* [azdo pipelines runs](./azdo_pipelines_runs.md) * [azdo pipelines variable-group](./azdo_pipelines_variable-group.md) ### ALIASES diff --git a/docs/azdo_pipelines_runs.md b/docs/azdo_pipelines_runs.md new file mode 100644 index 00000000..2bf783e7 --- /dev/null +++ b/docs/azdo_pipelines_runs.md @@ -0,0 +1,11 @@ +## Command `azdo pipelines runs` + +Manage pipeline runs in an Azure DevOps project. + +### Available commands + +* [azdo pipelines runs list](./azdo_pipelines_runs_list.md) + +### See also + +* [azdo pipelines](./azdo_pipelines.md) diff --git a/docs/azdo_pipelines_runs_list.md b/docs/azdo_pipelines_runs_list.md new file mode 100644 index 00000000..c5ca8e2f --- /dev/null +++ b/docs/azdo_pipelines_runs_list.md @@ -0,0 +1,98 @@ +## Command `azdo pipelines runs list` + +``` +azdo pipelines runs list [ORGANIZATION/]PROJECT [flags] +``` + +List runs of pipelines in an Azure DevOps project. Mirrors +'az pipelines runs list'. + +Filters support pipeline, branch, status, result, reason, requester, +and tags. The full result set is paginated server-side; use +--max-items to cap the response client-side. + + +### Options + + +* `--branch` `strings` + + Filter by source branch (repeatable; first value is honored by the SDK). Bare names get refs/heads/ prepended. + +* `-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 runs to return client-side (0 = unlimited). + +* `--pipeline-id` `ints` + + Limit to runs for these pipeline IDs (repeatable; first value is honored by the SDK). + +* `--query-order` `string` + + Order the results: finishTimeAscending, finishTimeDescending, queueTimeAscending, queueTimeDescending, startTimeAscending, startTimeDescending. + +* `--reason` `strings` + + Filter by reason (repeatable; first value is honored). Valid: manual, individualCI, batchedCI, schedule, scheduleForced, userCreated, pullRequest, etc. + +* `--requested-for` `string` + + Filter by the user who queued the run. Accepts @me to mean the authenticated user. + +* `--result` `strings` + + Filter by result (repeatable; first value is honored). Valid: none, succeeded, partiallySucceeded, failed, canceled. + +* `--status` `strings` + + Filter by status (repeatable; first value is honored). Valid: none, inProgress, completed, cancelling, postponed, notStarted, all. + +* `--tag` `strings` + + Filter by tags (all supplied tags must match). + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + +* `--top` `int` (default `0`) + + Maximum number of runs to request per server page (0 = server default). + + +### ALIASES + +- `l` +- `ls` + +### 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 runs for a project (default org) +azdo pipelines runs list Fabrikam --top 20 + +# Filter by pipeline and branch +azdo pipelines runs list MyOrg/Fabrikam --pipeline-id 42 --branch main + +# Order by queue time, descending +azdo pipelines runs list Fabrikam --query-order queueTimeDescending + +# Export as JSON +azdo pipelines runs list Fabrikam --json id,buildNumber,status,result +``` + +### See also + +* [azdo pipelines runs](./azdo_pipelines_runs.md)