From 8d0dc760cf82002328ff1cf4a4870d75f7a79bab Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Mon, 15 Jun 2026 20:21:06 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(pipelines):=20=E2=9C=A8add=20`run`=20c?= =?UTF-8?q?ommand=20to=20queue=20pipeline=20builds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cmd/pipelines/pipelines.go | 2 + internal/cmd/pipelines/run/run.go | 222 ++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 internal/cmd/pipelines/run/run.go diff --git a/internal/cmd/pipelines/pipelines.go b/internal/cmd/pipelines/pipelines.go index 5fd0ee04..78ab80c3 100644 --- a/internal/cmd/pipelines/pipelines.go +++ b/internal/cmd/pipelines/pipelines.go @@ -7,6 +7,7 @@ import ( "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" + "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/run" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/runs" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/show" "github.com/tmeckel/azdo-cli/internal/cmd/pipelines/variablegroup" @@ -26,6 +27,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.AddCommand(delete.NewCmd(ctx)) cmd.AddCommand(list.NewCmd(ctx)) + cmd.AddCommand(run.NewCmd(ctx)) cmd.AddCommand(runs.NewCmd(ctx)) cmd.AddCommand(show.NewCmd(ctx)) cmd.AddCommand(variablegroup.NewCmd(ctx)) diff --git a/internal/cmd/pipelines/run/run.go b/internal/cmd/pipelines/run/run.go new file mode 100644 index 00000000..1dddc372 --- /dev/null +++ b/internal/cmd/pipelines/run/run.go @@ -0,0 +1,222 @@ +package run + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" + + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type runOptions struct { + targetArg string + branch string + commitID string + variables []string + folderPath string + exporter util.Exporter +} + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &runOptions{} + + cmd := &cobra.Command{ + Use: "run [ORGANIZATION/]PROJECT/PIPELINE", + Short: "Queue a pipeline run", + Long: heredoc.Doc(` + Queue (run) an existing Azure Pipeline definition. The pipeline is + resolved by positive numeric ID or by name. Supply --branch, + --commit-id, and --variable to customise the run. + `), + Example: heredoc.Doc(` + # Queue a run by pipeline ID + azdo pipelines run Fabrikam/42 + + # Queue against a specific branch + azdo pipelines run MyOrg/Fabrikam/42 --branch main + + # Queue with a commit and a variable + azdo pipelines run Fabrikam/MyPipeline --commit-id abc123 --variable env=prod + `), + Args: util.ExactArgs(1, "pipeline target is required"), + RunE: func(cmd *cobra.Command, args []string) error { + opts.targetArg = args[0] + return runRun(ctx, opts) + }, + } + + cmd.Flags().StringVar(&opts.branch, "branch", "", "Branch or ref to build (bare names get refs/heads/ prepended)") + cmd.Flags().StringVar(&opts.commitID, "commit-id", "", "Source commit SHA to build") + cmd.Flags().StringSliceVar(&opts.variables, "variable", nil, "Queue-time variable in name=value format (repeatable)") + cmd.Flags().StringVar(&opts.folderPath, "folder-path", "", "Folder path filter used when resolving a pipeline name") + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "id", "buildNumber", "status", "result", "sourceBranch", + "sourceVersion", "queueTime", "reason", + }) + + return cmd +} + +func runRun(cmdCtx util.CmdContext, opts *runOptions) error { + ios, err := cmdCtx.IOStreams() + if err != nil { + return err + } + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + scope, err := util.ParseProjectTargetWithDefaultOrganization(cmdCtx, opts.targetArg) + if err != nil { + return util.FlagErrorWrap(err) + } + + buildClient, err := cmdCtx.ClientFactory().Build(cmdCtx.Context(), scope.Organization) + if err != nil { + return fmt.Errorf("failed to create Build client: %w", err) + } + + target := strings.TrimSpace(scope.Targets[0]) + if target == "" { + return util.FlagErrorf("pipeline target cannot be empty") + } + + pipelineID, err := strconv.Atoi(target) + if err == nil { + if pipelineID <= 0 { + return fmt.Errorf("pipeline id must be greater than zero: %q", target) + } + } else { + defs, err := buildClient.GetDefinitions(cmdCtx.Context(), build.GetDefinitionsArgs{ + Project: types.ToPtr(scope.Project), + Name: types.ToPtr(target), + Path: types.NotZeroPtrOrNil(opts.folderPath), + }) + if err != nil { + return fmt.Errorf("failed to query pipeline definitions: %w", err) + } + if defs == nil || len(defs.Value) == 0 { + return fmt.Errorf("pipeline %q not found", target) + } + + pipelineID = types.GetValue(defs.Value[0].Id, 0) + if pipelineID <= 0 { + return fmt.Errorf("pipeline %q returned empty id", target) + } + } + + payload := build.Build{ + Definition: &build.DefinitionReference{ + Id: types.ToPtr(pipelineID), + }, + } + + if opts.branch != "" { + payload.SourceBranch = types.ToPtr(normalizeBranch(opts.branch)) + } + if opts.commitID != "" { + payload.SourceVersion = types.ToPtr(opts.commitID) + } + if len(opts.variables) > 0 { + params, err := encodeVariables(opts.variables) + if err != nil { + return err + } + payload.Parameters = params + } + + zap.L().Debug( + "queueing build", + zap.String("organization", scope.Organization), + zap.String("project", scope.Project), + zap.Int("pipelineId", pipelineID), + zap.String("branch", opts.branch), + ) + + queued, err := buildClient.QueueBuild(cmdCtx.Context(), build.QueueBuildArgs{ + Project: types.ToPtr(scope.Project), + Build: &payload, + }) + if err != nil { + return fmt.Errorf("failed to queue pipeline %d: %w", pipelineID, err) + } + if queued == nil { + return fmt.Errorf("queue pipeline %d returned empty build", pipelineID) + } + + zap.L().Debug( + "build queued", + zap.Int("runId", types.GetValue(queued.Id, 0)), + zap.String("buildNumber", types.GetValue(queued.BuildNumber, "")), + ) + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, queued) + } + + tp, err := cmdCtx.Printer("list") + if err != nil { + return err + } + tp.AddColumns( + "Run ID", "Number", "Status", "Result", + "Pipeline ID", "Pipeline Name", + "Source Branch", "Queued Time", "Reason", + ) + tp.AddField(strconv.Itoa(types.GetValue(queued.Id, 0))) + tp.AddField(types.GetValue(queued.BuildNumber, "")) + tp.AddField(string(types.GetValue(queued.Status, build.BuildStatus("")))) + tp.AddField(string(types.GetValue(queued.Result, build.BuildResult("")))) + + if d := queued.Definition; d != nil { + tp.AddField(strconv.Itoa(types.GetValue(d.Id, 0))) + tp.AddField(types.GetValue(d.Name, "")) + } else { + tp.AddField("") + tp.AddField("") + } + + sb := types.GetValue(queued.SourceBranch, "") + tp.AddField(strings.TrimPrefix(sb, "refs/heads/")) + tp.AddField(util.FormatTimeShort(queued.QueueTime)) + tp.AddField(string(types.GetValue(queued.Reason, build.BuildReason("")))) + tp.EndRow() + + return tp.Render() +} + +func normalizeBranch(b string) string { + if strings.HasPrefix(b, "refs/heads/") || strings.HasPrefix(b, "refs/pull/") || strings.HasPrefix(b, "refs/tags/") { + return b + } + return "refs/heads/" + b +} + +func encodeVariables(vars []string) (*string, error) { + m := make(map[string]string, len(vars)) + for _, v := range vars { + idx := strings.IndexByte(v, '=') + if idx <= 0 { + return nil, util.FlagErrorf("invalid variable %q: expected name=value", v) + } + name := strings.TrimSpace(v[:idx]) + if name == "" { + return nil, util.FlagErrorf("invalid variable %q: name cannot be empty", v) + } + m[name] = v[idx+1:] + } + b, err := json.Marshal(m) + if err != nil { + return nil, fmt.Errorf("failed to encode queue variables: %w", err) + } + return types.ToPtr(string(b)), nil +} From 65322686c91bf605b69142e2e3cff6622b55e7e5 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Mon, 15 Jun 2026 20:25:38 +0000 Subject: [PATCH 2/3] =?UTF-8?q?test(pipelines):=20=F0=9F=A7=AA=20add=20tes?= =?UTF-8?q?ts=20for=20run=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/cmd/pipelines/run/run_test.go | 392 +++++++++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 internal/cmd/pipelines/run/run_test.go diff --git a/internal/cmd/pipelines/run/run_test.go b/internal/cmd/pipelines/run/run_test.go new file mode 100644 index 00000000..431356e2 --- /dev/null +++ b/internal/cmd/pipelines/run/run_test.go @@ -0,0 +1,392 @@ +package run + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/build" + "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" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type runDeps struct { + cmd *mocks.MockCmdContext + clientFact *mocks.MockClientFactory + buildClient *mocks.MockBuildClient + cfg *mocks.MockConfig + authCfg *mocks.MockAuthConfig + stdout *bytes.Buffer + t *testing.T +} + +func setupRunDeps(t *testing.T, organization string) *runDeps { + t.Helper() + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + io, _, out, _ := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStderrTTY(false) + + d := &runDeps{ + cmd: mocks.NewMockCmdContext(ctrl), + clientFact: mocks.NewMockClientFactory(ctrl), + buildClient: mocks.NewMockBuildClient(ctrl), + cfg: mocks.NewMockConfig(ctrl), + authCfg: mocks.NewMockAuthConfig(ctrl), + stdout: out, + t: t, + } + + d.cmd.EXPECT().IOStreams().Return(io, nil).AnyTimes() + d.cmd.EXPECT().Context().Return(context.Background()).AnyTimes() + d.cmd.EXPECT().ClientFactory().Return(d.clientFact).AnyTimes() + d.cmd.EXPECT().Config().Return(d.cfg, nil).AnyTimes() + d.cfg.EXPECT().Authentication().Return(d.authCfg).AnyTimes() + d.authCfg.EXPECT().GetDefaultOrganization().Return(organization, nil).AnyTimes() + + tp, err := printer.NewListPrinter(out) + require.NoError(t, err) + d.cmd.EXPECT().Printer("list").Return(tp, nil).AnyTimes() + + return d +} + +func setupBuildClient(d *runDeps) { + d.clientFact.EXPECT().Build(gomock.Any(), gomock.Any()).Return(d.buildClient, nil).AnyTimes() +} + +func expectQueueBuild(d *runDeps, project string, want *build.Build, resp *build.Build, err error) { + d.buildClient.EXPECT().QueueBuild(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.QueueBuildArgs) (*build.Build, error) { + require.NotNil(d.t, args.Project) + require.Equal(d.t, project, *args.Project) + if want != nil { + require.NotNil(d.t, args.Build) + require.NotNil(d.t, args.Build.Definition) + require.Equal(d.t, types.GetValue(want.Definition.Id, 0), types.GetValue(args.Build.Definition.Id, 0)) + require.Equal(d.t, types.GetValue(want.SourceBranch, ""), types.GetValue(args.Build.SourceBranch, "")) + require.Equal(d.t, types.GetValue(want.SourceVersion, ""), types.GetValue(args.Build.SourceVersion, "")) + require.Equal(d.t, types.GetValue(want.Parameters, ""), types.GetValue(args.Build.Parameters, "")) + } + return resp, err + }, + ).Times(1) +} + +func expectGetDefinitions(d *runDeps, project, name string, resp *build.GetDefinitionsResponseValue, err error) { + d.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.GetDefinitionsArgs) (*build.GetDefinitionsResponseValue, error) { + require.NotNil(d.t, args.Project) + require.Equal(d.t, project, *args.Project) + require.NotNil(d.t, args.Name) + require.Equal(d.t, name, *args.Name) + return resp, err + }, + ).Times(1) +} + +func sampleBuild(t *testing.T) *build.Build { + t.Helper() + return &build.Build{ + Id: types.ToPtr(1001), + BuildNumber: types.ToPtr("20250615.1"), + Status: types.ToPtr(build.BuildStatusValues.Completed), + Result: types.ToPtr(build.BuildResultValues.Succeeded), + Reason: types.ToPtr(build.BuildReasonValues.Manual), + SourceBranch: types.ToPtr("refs/heads/main"), + QueueTime: &azuredevops.Time{Time: time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)}, + Definition: &build.DefinitionReference{ + Id: types.ToPtr(42), + Name: types.ToPtr("MyPipeline"), + }, + } +} + +func TestNewCmd_RegistersAsRunLeaf(t *testing.T) { + t.Parallel() + cmd := NewCmd(nil) + assert.Equal(t, "run [ORGANIZATION/]PROJECT/PIPELINE", cmd.Use) + require.NotNil(t, cmd.RunE) + assert.NotNil(t, cmd.Flags().Lookup("json")) + assert.NotNil(t, cmd.Flags().Lookup("branch")) + assert.NotNil(t, cmd.Flags().Lookup("commit-id")) + assert.NotNil(t, cmd.Flags().Lookup("variable")) + assert.NotNil(t, cmd.Flags().Lookup("folder-path")) +} + +func TestNewCmd_RequiresOneArg(t *testing.T) { + t.Parallel() + cmd := NewCmd(nil) + cmd.SetArgs([]string{}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline target is required") +} + +func TestRunRun_ByPositiveID(t *testing.T) { + t.Parallel() + d := setupRunDeps(t, "MyOrg") + setupBuildClient(d) + d.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).Times(0) + + want := &build.Build{Definition: &build.DefinitionReference{Id: types.ToPtr(42)}} + expectQueueBuild(d, "Fabrikam", want, sampleBuild(t), nil) + + cmd := NewCmd(d.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/42"}) + err := cmd.Execute() + + require.NoError(t, err) + out := d.stdout.String() + assert.Contains(t, out, "Run ID") + assert.Contains(t, out, "1001") +} + +func TestRunRun_ByName(t *testing.T) { + t.Parallel() + d := setupRunDeps(t, "MyOrg") + setupBuildClient(d) + + expectGetDefinitions(d, "Fabrikam", "MyPipeline", &build.GetDefinitionsResponseValue{ + Value: []build.BuildDefinitionReference{ + {Id: types.ToPtr(42), Name: types.ToPtr("MyPipeline")}, + {Id: types.ToPtr(99), Name: types.ToPtr("MyPipeline")}, + }, + }, nil) + + want := &build.Build{Definition: &build.DefinitionReference{Id: types.ToPtr(42)}} + expectQueueBuild(d, "Fabrikam", want, sampleBuild(t), nil) + + cmd := NewCmd(d.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/MyPipeline"}) + err := cmd.Execute() + + require.NoError(t, err) +} + +func TestRunRun_RespectsBranchNormalization(t *testing.T) { + t.Parallel() + d := setupRunDeps(t, "MyOrg") + setupBuildClient(d) + + want := &build.Build{ + Definition: &build.DefinitionReference{Id: types.ToPtr(42)}, + SourceBranch: types.ToPtr("refs/heads/feature"), + } + expectQueueBuild(d, "Fabrikam", want, sampleBuild(t), nil) + + cmd := NewCmd(d.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/42", "--branch", "feature"}) + err := cmd.Execute() + + require.NoError(t, err) +} + +func TestRunRun_SetsCommitID(t *testing.T) { + t.Parallel() + d := setupRunDeps(t, "MyOrg") + setupBuildClient(d) + + want := &build.Build{ + Definition: &build.DefinitionReference{Id: types.ToPtr(42)}, + SourceVersion: types.ToPtr("abc123"), + } + expectQueueBuild(d, "Fabrikam", want, sampleBuild(t), nil) + + cmd := NewCmd(d.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/42", "--commit-id", "abc123"}) + err := cmd.Execute() + + require.NoError(t, err) +} + +func TestRunRun_SetsVariables(t *testing.T) { + t.Parallel() + d := setupRunDeps(t, "MyOrg") + setupBuildClient(d) + + params := `{"env":"prod","ver":"2"}` + want := &build.Build{ + Definition: &build.DefinitionReference{Id: types.ToPtr(42)}, + Parameters: ¶ms, + } + expectQueueBuild(d, "Fabrikam", want, sampleBuild(t), nil) + + cmd := NewCmd(d.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/42", "--variable", "env=prod", "--variable", "ver=2"}) + err := cmd.Execute() + + require.NoError(t, err) +} + +func TestRunRun_DefaultsOrg(t *testing.T) { + t.Parallel() + d := setupRunDeps(t, "DefaultOrg") + setupBuildClient(d) + + want := &build.Build{Definition: &build.DefinitionReference{Id: types.ToPtr(42)}} + expectQueueBuild(d, "Fabrikam", want, sampleBuild(t), nil) + + cmd := NewCmd(d.cmd) + cmd.SetArgs([]string{"Fabrikam/42"}) + err := cmd.Execute() + + require.NoError(t, err) +} + +func TestRunRun_RejectsNonPositiveNumericID(t *testing.T) { + t.Parallel() + d := setupRunDeps(t, "MyOrg") + setupBuildClient(d) + + cmd := NewCmd(d.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/0"}) + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "pipeline id must be greater than zero") + assert.Empty(t, d.stdout.String()) +} + +func TestRunRun_NameNotFound(t *testing.T) { + t.Parallel() + d := setupRunDeps(t, "MyOrg") + setupBuildClient(d) + + expectGetDefinitions(d, "Fabrikam", "Ghost", &build.GetDefinitionsResponseValue{}, nil) + + cmd := NewCmd(d.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/Ghost"}) + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestRunRun_RejectsInvalidVariable(t *testing.T) { + t.Parallel() + d := setupRunDeps(t, "MyOrg") + setupBuildClient(d) + + cmd := NewCmd(d.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/42", "--variable", "bad"}) + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid variable") +} + +func TestRunRun_PropagatesQueueError(t *testing.T) { + t.Parallel() + d := setupRunDeps(t, "MyOrg") + setupBuildClient(d) + + want := &build.Build{Definition: &build.DefinitionReference{Id: types.ToPtr(42)}} + expectQueueBuild(d, "Fabrikam", want, nil, errors.New("queue error")) + + cmd := NewCmd(d.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/42"}) + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to queue pipeline 42: queue error") +} + +func TestRunRun_JSONOutput(t *testing.T) { + t.Parallel() + d := setupRunDeps(t, "MyOrg") + setupBuildClient(d) + + want := &build.Build{Definition: &build.DefinitionReference{Id: types.ToPtr(42)}} + blob := sampleBuild(t) + expectQueueBuild(d, "Fabrikam", want, blob, nil) + + cmd := NewCmd(d.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/42", "--json=id", "--json=buildNumber"}) + err := cmd.Execute() + + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(d.stdout.Bytes(), &got)) + assert.Equal(t, float64(1001), got["id"]) + assert.Equal(t, "20250615.1", got["buildNumber"]) + _, hasStatus := got["status"] + assert.False(t, hasStatus) +} + +func TestRunRun_FolderPathByName(t *testing.T) { + t.Parallel() + d := setupRunDeps(t, "MyOrg") + setupBuildClient(d) + + d.buildClient.EXPECT().GetDefinitions(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, args build.GetDefinitionsArgs) (*build.GetDefinitionsResponseValue, error) { + require.NotNil(d.t, args.Project) + require.Equal(d.t, "Fabrikam", *args.Project) + require.NotNil(d.t, args.Name) + require.Equal(d.t, "MyPipeline", *args.Name) + require.NotNil(d.t, args.Path) + require.Equal(d.t, `\Shared`, *args.Path) + return &build.GetDefinitionsResponseValue{ + Value: []build.BuildDefinitionReference{ + {Id: types.ToPtr(7), Name: types.ToPtr("MyPipeline")}, + {Id: types.ToPtr(8), Name: types.ToPtr("MyPipeline")}, + }, + }, nil + }, + ).Times(1) + + want := &build.Build{Definition: &build.DefinitionReference{Id: types.ToPtr(7)}} + expectQueueBuild(d, "Fabrikam", want, sampleBuild(t), nil) + + cmd := NewCmd(d.cmd) + cmd.SetArgs([]string{"MyOrg/Fabrikam/MyPipeline", "--folder-path", `\Shared`}) + err := cmd.Execute() + + require.NoError(t, err) +} + +func TestEncodeVariables(t *testing.T) { + t.Parallel() + got, err := encodeVariables([]string{"a=b", "c=d=e"}) + require.NoError(t, err) + require.NotNil(t, got) + assert.JSONEq(t, `{"a":"b","c":"d=e"}`, *got) +} + +func TestEncodeVariables_RejectsMissingNameValueSeparator(t *testing.T) { + t.Parallel() + _, err := encodeVariables([]string{"=val"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "expected name=value") +} + +func TestNormalizeBranch(t *testing.T) { + t.Parallel() + tests := []struct { + input, expected string + }{ + {"main", "refs/heads/main"}, + {"feature/x", "refs/heads/feature/x"}, + {"refs/heads/main", "refs/heads/main"}, + {"refs/pull/1/merge", "refs/pull/1/merge"}, + {"refs/tags/v1", "refs/tags/v1"}, + } + for _, tt := range tests { + got := normalizeBranch(tt.input) + assert.Equal(t, tt.expected, got) + } +} From 4b0c00cbdf8c7f5ba912f24e5ae4ca7053aa6149 Mon Sep 17 00:00:00 2001 From: Codex CLI Date: Mon, 15 Jun 2026 20:26:02 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs(pipelines):=20=F0=9F=93=84=20add=20doc?= =?UTF-8?q?umentation=20for=20pipelines=20run=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/azdo_help_reference.md | 14 +++++++++ docs/azdo_pipelines.md | 1 + docs/azdo_pipelines_run.md | 63 +++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 docs/azdo_pipelines_run.md diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 272e6168..331706bd 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -353,6 +353,20 @@ Aliases view, status ``` +### `azdo pipelines run [ORGANIZATION/]PROJECT/PIPELINE [flags]` + +Queue a pipeline run + +``` + --branch string Branch or ref to build (bare names get refs/heads/ prepended) + --commit-id string Source commit SHA to build + --folder-path string Folder path filter used when resolving a pipeline name +-q, --jq expression Filter JSON output using a jq expression + --json fields[=*] Output JSON with the specified fields. Prefix a field with '-' to exclude it. +-t, --template string Format JSON output using a Go template; see "azdo help formatting" + --variable strings Queue-time variable in name=value format (repeatable) +``` + ### `azdo pipelines runs` Manage pipeline runs diff --git a/docs/azdo_pipelines.md b/docs/azdo_pipelines.md index ee0e0402..19705e01 100644 --- a/docs/azdo_pipelines.md +++ b/docs/azdo_pipelines.md @@ -8,6 +8,7 @@ Manage Azure DevOps pipelines * [azdo pipelines delete](./azdo_pipelines_delete.md) * [azdo pipelines list](./azdo_pipelines_list.md) * [azdo pipelines pool](./azdo_pipelines_pool.md) +* [azdo pipelines run](./azdo_pipelines_run.md) * [azdo pipelines runs](./azdo_pipelines_runs.md) * [azdo pipelines show](./azdo_pipelines_show.md) * [azdo pipelines variable-group](./azdo_pipelines_variable-group.md) diff --git a/docs/azdo_pipelines_run.md b/docs/azdo_pipelines_run.md new file mode 100644 index 00000000..ba1232a8 --- /dev/null +++ b/docs/azdo_pipelines_run.md @@ -0,0 +1,63 @@ +## Command `azdo pipelines run` + +``` +azdo pipelines run [ORGANIZATION/]PROJECT/PIPELINE [flags] +``` + +Queue (run) an existing Azure Pipeline definition. The pipeline is +resolved by positive numeric ID or by name. Supply --branch, +--commit-id, and --variable to customise the run. + + +### Options + + +* `--branch` `string` + + Branch or ref to build (bare names get refs/heads/ prepended) + +* `--commit-id` `string` + + Source commit SHA to build + +* `--folder-path` `string` + + Folder path filter used when resolving a pipeline name + +* `-q`, `--jq` `expression` + + Filter JSON output using a jq expression + +* `--json` `fields` + + Output JSON with the specified fields. Prefix a field with '-' to exclude it. + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + +* `--variable` `strings` + + Queue-time variable in name=value format (repeatable) + + +### JSON Fields + +`buildNumber`, `id`, `queueTime`, `reason`, `result`, `sourceBranch`, `sourceVersion`, `status` + +### Examples + +```bash +# Queue a run by pipeline ID +azdo pipelines run Fabrikam/42 + +# Queue against a specific branch +azdo pipelines run MyOrg/Fabrikam/42 --branch main + +# Queue with a commit and a variable +azdo pipelines run Fabrikam/MyPipeline --commit-id abc123 --variable env=prod +``` + +### See also + +* [azdo pipelines](./azdo_pipelines.md)