diff --git a/AGENTS.md b/AGENTS.md index fa4e30a0..a72951dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,8 @@ Developer: # Repository Guidelines - **Linting:** Follow `golangci-lint` guidance; wrap errors using `%w` for error chains. - **Naming:** Packages use lowercase; exported identifiers in CamelCase; files use lower_snake_case. - **CLI Flags:** Use kebab-case (e.g., `--organization-url`). +- **Multi-value flag conventions:** When supporting “remove all” semantics on list flags, reserve `*` as the exclusive sentinel. Commands must reject combinations like `--remove-label foo,*` and treat a lone `*` as “remove every existing entry.” +- **Editing Tools:** Modify files using git-aware patches (e.g., `apply_patch`). Do not rely on ad-hoc scripts (Python, sed, etc.) to edit tracked files so diffs stay reviewable. - **Logging:** Use `zap.L()` with structured messages; prefer `%w` for wrapping errors. - **Variables:** Variable names must never collide with any imports or name of GO packages - **Indentation During Drafts:** Cosmetic indentation mismatches are acceptable while implementing changes. Final formatting is applied with `gofumpt` after coding is complete, so focus on correctness first. @@ -199,6 +201,10 @@ To ensure high-quality, production-ready code and prevent common errors, adhere - **Detail:** Ensure imports for standard library packages (e.g., `fmt`, `strings`, `context`), third-party libraries (e.g., `github.com/spf13/cobra`, `github.com/MakeNowJust/heredoc`), and internal project modules (e.g., `github.com/tmeckel/azdo-cli/internal/cmd/util`, `github.com/tmeckel/azdo-cli/internal/azdo`) are present. If unsure, err on the side of including common imports for the context. +### Reuse Existing Helpers + +- Prefer the generic helpers in `internal/types` (e.g., `MapSlice`, `MapSlicePtr`) when transforming SDK slices instead of rewriting mapping loops. These helpers already handle nil pointers and keep slice code consistent across commands. + ### Idiomatic Go Code & Error Handling - **Context & IOStreams:** When interacting with `util.CmdContext`, always retrieve `IOStreams` and `Prompter` into local variables to handle potential errors immediately. diff --git a/TESTING.md b/TESTING.md index 1ed0f273..b2d9cffa 100644 --- a/TESTING.md +++ b/TESTING.md @@ -94,6 +94,7 @@ Acceptance tests (`*_acc_test.go`) run against a live Azure DevOps organization | `AZDO_ACC_ORG` | Organization name used for the session. | | `AZDO_ACC_ORG_URL` | Optional explicit organization URL; defaults to `https://dev.azure.com/`. | | `AZDO_ACC_PAT` | Personal Access Token with the scopes required by the test steps. | +| `AZDO_ACC_PROJECT` | Project name used by acceptance tests that operate on project-scoped resources. | | `AZDO_ACC_TIMEOUT` | Optional override for the default 60 s timeout. Accepts Go durations (`45s`, `2m`) or integer seconds. | ### Step-by-step skeleton @@ -114,6 +115,12 @@ go test ./internal/cmd/security/permission/delete -run TestAccDeletePermission ``` Acceptance tests are not run in CI; execute them manually before publishing features that depend on live Azure DevOps behavior. +### TestContext helpers & utilities + +- `inttest.TestContext` now exposes `Project()` alongside `Org`, `OrgUrl`, and `PAT`. Set `AZDO_ACC_PROJECT` when a test needs to target a specific project and fail fast in `PreRun` if it is missing. +- Use `TestContext.SetValue(key, value)`/`Value(key)` to propagate data across `PreRun`, `Run`, `Verify`, and `PostRun` without relying on package-level variables. Keys can be simple strings or typed aliases; mimic `context.Context` usage. +- The helper `inttest.WriteTestFile(path, contents)` creates or truncates files with `0600` permissions and ensures parent directories exist, which is useful for acceptance tests that need temporary credentials or certificates. + ### Updating Mocks All mocks in this project are generated and managed by a single script. diff --git a/docs/azdo_help_reference.md b/docs/azdo_help_reference.md index 477f2b1c..b359b275 100644 --- a/docs/azdo_help_reference.md +++ b/docs/azdo_help_reference.md @@ -328,16 +328,15 @@ View changes in a pull request Edit a pull request ``` - --add-label strings Add labels (comma-separated) - --add-optional-reviewer strings Add optional reviewers (comma-separated) - --add-required-reviewer strings Add required reviewers (comma-separated) --B, --base string Change the base branch for this pull request --b, --body string Set the new body. --F, --body-file string Read body text from file (use "-" to read from standard input) - --remove-label strings Remove labels (comma-separated) - --remove-optional-reviewer strings Remove optional reviewers (comma-separated) - --remove-required-reviewer strings Remove required reviewers (comma-separated) --t, --title string Set the new title. + --add-label strings Add labels (comma-separated) + --add-optional-reviewer strings Add or demote optional reviewers (comma-separated) + --add-required-reviewer strings Add or promote required reviewers (comma-separated) +-B, --base string Change the base branch for this pull request +-b, --body string Set the new body. +-F, --body-file string Read body text from file (use "-" to read from standard input) + --remove-label strings Remove labels (comma-separated, use * to remove all) + --remove-reviewer strings Remove reviewers (comma-separated, use * to remove all) +-t, --title string Set the new title. ``` ### `azdo pr list [[organization/]project/repository] [flags]` @@ -393,8 +392,8 @@ View a pull request --comment-type string Filter comments by type; defaults to 'text': {text|system|all} (default "text") -c, --comments View pull request comments -C, --commits View pull request commits - --format string Output format: {json} -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. -r, --raw View pull request raw -t, --template string Format JSON output using a Go template; see "azdo help formatting" ``` @@ -402,7 +401,7 @@ View a pull request Aliases ``` -show +show, status ``` ### `azdo pr vote [ | | ] [flags]` @@ -442,7 +441,7 @@ Create a new Azure DevOps Project Aliases ``` -cr +cr, c, new, n, add, a ``` ### `azdo project delete [ORGANIZATION/]PROJECT [flags]` @@ -933,7 +932,43 @@ Work with Azure DevOps service connections. Aliases ``` -service-endpoints, serviceendpoint, serviceendpoints, se, sep +service-endpoints, serviceendpoints, se +``` + +### `azdo service-endpoint create` + +Create service connections + +#### `azdo service-endpoint create azurerm [ORGANIZATION/]PROJECT --name --authentication-scheme [flags]` + +Create an Azure Resource Manager service connection + +``` + --authentication-scheme string Authentication scheme: {ServicePrincipal|ManagedServiceIdentity|WorkloadIdentityFederation} (default "ServicePrincipal") + --certificate-path string Path to service principal certificate file (PEM format) + --description string Description for the service endpoint + --environment string Azure environment: {AzureCloud|AzureChinaCloud|AzureUSGovernment|AzureGermanCloud|AzureStack} (default "AzureCloud") + --grant-permission-to-all-pipelines Grant access permission to all pipelines to use the service connection +-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. + --management-group-id string Azure management group ID + --management-group-name string Azure management group name + --name string Name of the service endpoint + --resource-group string Name of the resource group (for subscription-level scope) + --server-url string Azure Stack Resource Manager base URL. Required if --environment is AzureStack. + --service-principal-id string Service principal/application ID (e.g., GUID) + --service-principal-key string Service principal key (secret value) + --subscription-id string Azure subscription ID (e.g., GUID) + --subscription-name string Azure subscription name +-t, --template string Format JSON output using a Go template; see "azdo help formatting" + --tenant-id string Azure tenant ID (e.g., GUID) +-y, --yes Skip confirmation prompts +``` + +Aliases + +``` +cr, c, new, n, add, a ``` ### `azdo service-endpoint list [ORGANIZATION/]PROJECT [flags]` diff --git a/docs/azdo_pr_edit.md b/docs/azdo_pr_edit.md index 233ca0ed..e43ec85f 100644 --- a/docs/azdo_pr_edit.md +++ b/docs/azdo_pr_edit.md @@ -8,7 +8,17 @@ Edit an existing pull request. Without an argument, the pull request that belongs to the current branch is selected. If there are more than one pull request associated with the current branch, one pull request will be selected based on the shared finder logic. -%!(EXTRA string=`) + +The command can: +- Add reviewers as optional or required, promoting/demoting existing reviewers when needed. +- Remove reviewers regardless of their current required/optional state. +- Add or remove labels + +Examples: + `azdo pr edit --add-required-reviewer alice@example.com bob@example.com` + `azdo pr edit --add-optional-reviewer alice@example.com --remove-reviewer bob@example.com` + `azdo pr edit --add-label bug --remove-label needs-review` + ### Options @@ -19,11 +29,11 @@ If there are more than one pull request associated with the current branch, one * `--add-optional-reviewer` `strings` - Add optional reviewers (comma-separated) + Add or demote optional reviewers (comma-separated) * `--add-required-reviewer` `strings` - Add required reviewers (comma-separated) + Add or promote required reviewers (comma-separated) * `-B`, `--base` `string` @@ -39,15 +49,11 @@ If there are more than one pull request associated with the current branch, one * `--remove-label` `strings` - Remove labels (comma-separated) - -* `--remove-optional-reviewer` `strings` - - Remove optional reviewers (comma-separated) + Remove labels (comma-separated, use * to remove all) -* `--remove-required-reviewer` `strings` +* `--remove-reviewer` `strings` - Remove required reviewers (comma-separated) + Remove reviewers (comma-separated, use * to remove all) * `-t`, `--title` `string` diff --git a/docs/azdo_pr_view.md b/docs/azdo_pr_view.md index ea326dbf..ae3d84aa 100644 --- a/docs/azdo_pr_view.md +++ b/docs/azdo_pr_view.md @@ -29,14 +29,14 @@ is displayed. View pull request commits -* `--format` `string` - - Output format: {json} - * `-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. + * `-r`, `--raw` View pull request raw @@ -49,6 +49,11 @@ is displayed. ### ALIASES - `show` +- `status` + +### JSON Fields + +`author`, `commits`, `createdOn`, `description`, `id`, `isDraft`, `labels`, `mergeStatus`, `reviewers`, `sourceBranch`, `status`, `targetBranch`, `threads`, `title`, `url` ### See also diff --git a/docs/azdo_project_create.md b/docs/azdo_project_create.md index 3a6bbaf8..c21747d2 100644 --- a/docs/azdo_project_create.md +++ b/docs/azdo_project_create.md @@ -60,6 +60,11 @@ If the organization name is omitted from the project argument, the default confi ### ALIASES - `cr` +- `c` +- `new` +- `n` +- `add` +- `a` ### JSON Fields diff --git a/docs/azdo_service-endpoint.md b/docs/azdo_service-endpoint.md index 1f5e87b9..f128bebe 100644 --- a/docs/azdo_service-endpoint.md +++ b/docs/azdo_service-endpoint.md @@ -5,15 +5,14 @@ Manage Azure DevOps service endpoints (service connections) for projects. ### Available commands +* [azdo service-endpoint create](./azdo_service-endpoint_create.md) * [azdo service-endpoint list](./azdo_service-endpoint_list.md) ### ALIASES - `service-endpoints` -- `serviceendpoint` - `serviceendpoints` - `se` -- `sep` ### See also diff --git a/docs/azdo_service-endpoint_create.md b/docs/azdo_service-endpoint_create.md new file mode 100644 index 00000000..6b3d8b57 --- /dev/null +++ b/docs/azdo_service-endpoint_create.md @@ -0,0 +1,11 @@ +## Command `azdo service-endpoint create` + +Create service connections + +### Available commands + +* [azdo service-endpoint create azurerm](./azdo_service-endpoint_create_azurerm.md) + +### See also + +* [azdo service-endpoint](./azdo_service-endpoint.md) diff --git a/docs/azdo_service-endpoint_create_azurerm.md b/docs/azdo_service-endpoint_create_azurerm.md new file mode 100644 index 00000000..d4ad30da --- /dev/null +++ b/docs/azdo_service-endpoint_create_azurerm.md @@ -0,0 +1,185 @@ +## Command `azdo service-endpoint create azurerm` + +``` +azdo service-endpoint create azurerm [ORGANIZATION/]PROJECT --name --authentication-scheme [flags] +``` + +Create an Azure Resource Manager service connection. +This command is modeled after the Azure DevOps Terraform Provider's implementation for creating azurerm service endpoints. + + +### Options + + +* `--authentication-scheme` `string` (default `"ServicePrincipal"`) + + Authentication scheme: {ServicePrincipal|ManagedServiceIdentity|WorkloadIdentityFederation} + +* `--certificate-path` `string` + + Path to service principal certificate file (PEM format) + +* `--description` `string` + + Description for the service endpoint + +* `--environment` `string` (default `"AzureCloud"`) + + Azure environment: {AzureCloud|AzureChinaCloud|AzureUSGovernment|AzureGermanCloud|AzureStack} + +* `--grant-permission-to-all-pipelines` + + Grant access permission to all pipelines to use the service connection + +* `-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. + +* `--management-group-id` `string` + + Azure management group ID + +* `--management-group-name` `string` + + Azure management group name + +* `--name` `string` + + Name of the service endpoint + +* `--resource-group` `string` + + Name of the resource group (for subscription-level scope) + +* `--server-url` `string` + + Azure Stack Resource Manager base URL. Required if --environment is AzureStack. + +* `--service-principal-id` `string` + + Service principal/application ID (e.g., GUID) + +* `--service-principal-key` `string` + + Service principal key (secret value) + +* `--subscription-id` `string` + + Azure subscription ID (e.g., GUID) + +* `--subscription-name` `string` + + Azure subscription name + +* `-t`, `--template` `string` + + Format JSON output using a Go template; see "azdo help formatting" + +* `--tenant-id` `string` + + Azure tenant ID (e.g., GUID) + +* `-y`, `--yes` + + Skip confirmation prompts + + +### ALIASES + +- `cr` +- `c` +- `new` +- `n` +- `add` +- `a` + +### JSON Fields + +`authorization`, `description`, `id`, `name`, `type`, `url` + +### Examples + +```bash +# Service Principal with a secret +azdo service-endpoint create azurerm my-org/my-project \ + --name "My AzureRM SPN Secret Connection" \ + --authentication-scheme ServicePrincipal \ + --tenant-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ + --service-principal-id "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" \ + --service-principal-key "my-service-principal-secret" \ + --subscription-id "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz" \ + --subscription-name "My Azure Subscription" \ + --resource-group "my-resource-group" \ + --description "Service Connection for my AzureRM resources" + +# Service Principal with a certificate +azdo service-endpoint create azurerm my-org/my-project \ + --name "My AzureRM SPN Cert Connection" \ + --authentication-scheme ServicePrincipal \ + --tenant-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ + --service-principal-id "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" \ + --certificate-path "/path/to/my-cert.pem" \ + --subscription-id "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz" \ + --subscription-name "My Azure Subscription" \ + --description "Certificate-based Service Connection" + +# Managed Service Identity +azdo service-endpoint create azurerm my-org/my-project \ + --name "My AzureRM MSI Connection" \ + --authentication-scheme ManagedServiceIdentity \ + --tenant-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ + --subscription-id "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz" \ + --subscription-name "My Azure Subscription" \ + --description "MSI Service Connection" + +# Workload Identity Federation (Manual mode, with existing Service Principal) +azdo service-endpoint create azurerm my-org/my-project \ + --name "My AzureRM WIF Manual Connection" \ + --authentication-scheme WorkloadIdentityFederation \ + --tenant-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ + --service-principal-id "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" \ + --subscription-id "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz" \ + --subscription-name "My Azure Subscription" \ + --description "WIF Manual Service Connection" + +# Workload Identity Federation (Automatic mode, Azure DevOps creates Service Principal) +azdo service-endpoint create azurerm my-org/my-project \ + --name "My AzureRM WIF Automatic Connection" \ + --authentication-scheme WorkloadIdentityFederation \ + --tenant-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ + --subscription-id "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz" \ + --subscription-name "My Azure Subscription" \ + --description "WIF Automatic Service Connection" + +# Service Principal with Management Group Scope +azdo service-endpoint create azurerm my-org/my-project \ + --name "My AzureRM MGMT Group Connection" \ + --authentication-scheme ServicePrincipal \ + --tenant-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ + --service-principal-id "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" \ + --service-principal-key "my-service-principal-secret" \ + --management-group-id "my-mgmt-group-id" \ + --management-group-name "My Management Group" \ + --description "Service Connection scoped to a Management Group" + +# Azure Stack Environment +azdo service-endpoint create azurerm my-org/my-project \ + --name "My AzureStack Connection" \ + --authentication-scheme ServicePrincipal \ + --tenant-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ + --service-principal-id "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" \ + --service-principal-key "my-service-principal-secret" \ + --subscription-id "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz" \ + --subscription-name "My Azure Stack Subscription" \ + --environment AzureStack \ + --server-url "https://management.myazurestack.com/" \ + --description "Service Connection for Azure Stack" +``` + +### See also + +* [azdo service-endpoint create](./azdo_service-endpoint_create.md) diff --git a/internal/azdo/connection.go b/internal/azdo/connection.go index 51163c7f..b41d537c 100644 --- a/internal/azdo/connection.go +++ b/internal/azdo/connection.go @@ -13,6 +13,7 @@ import ( "github.com/microsoft/azure-devops-go-api/azuredevops/v7/graph" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/operations" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinepermissions" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/security" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/serviceendpoint" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent" @@ -51,6 +52,7 @@ type ClientFactory interface { Graph(ctx context.Context, organization string) (graph.Client, error) Core(ctx context.Context, organization string) (core.Client, error) Operations(ctx context.Context, organization string) (operations.Client, error) + PipelinePermissions(ctx context.Context, organization string) (pipelinepermissions.Client, error) ServiceEndpoint(ctx context.Context, organization string) (serviceendpoint.Client, error) Security(ctx context.Context, organization string) (security.Client, error) TaskAgent(ctx context.Context, organization string) (taskagent.Client, error) diff --git a/internal/azdo/factory.go b/internal/azdo/factory.go index 595c345f..7ffac116 100644 --- a/internal/azdo/factory.go +++ b/internal/azdo/factory.go @@ -9,6 +9,7 @@ import ( "github.com/microsoft/azure-devops-go-api/azuredevops/v7/graph" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/operations" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinepermissions" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/security" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/serviceendpoint" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent" @@ -96,6 +97,14 @@ func (c *clientFactory) Operations(ctx context.Context, org string) (operations. return operations.NewClient(ctx, conn.(*connectionAdapter).conn), nil } +func (c *clientFactory) PipelinePermissions(ctx context.Context, org string) (pipelinepermissions.Client, error) { + conn, err := c.factory.Connection(org) + if err != nil { + return nil, err + } + return pipelinepermissions.NewClient(ctx, conn.(*connectionAdapter).conn) +} + func (c *clientFactory) ServiceEndpoint(ctx context.Context, org string) (serviceendpoint.Client, error) { conn, err := c.factory.Connection(org) if err != nil { diff --git a/internal/cmd/pipelines/variablegroups/variable/variable.go b/internal/cmd/pipelines/variablegroups/variable/variable.go index ae538321..a12f6d1a 100644 --- a/internal/cmd/pipelines/variablegroups/variable/variable.go +++ b/internal/cmd/pipelines/variablegroups/variable/variable.go @@ -1,4 +1,3 @@ - package variable import ( diff --git a/internal/cmd/pr/edit/edit.go b/internal/cmd/pr/edit/edit.go index fc8f8bff..4bd111f0 100644 --- a/internal/cmd/pr/edit/edit.go +++ b/internal/cmd/pr/edit/edit.go @@ -1,14 +1,18 @@ package edit import ( + "context" "fmt" "io" "os" + "slices" "strings" "github.com/MakeNowJust/heredoc" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" "github.com/microsoft/azure-devops-go-api/azuredevops/v7/git" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" "github.com/spf13/cobra" "github.com/tmeckel/azdo-cli/internal/cmd/pr/shared" "github.com/tmeckel/azdo-cli/internal/cmd/util" @@ -18,16 +22,20 @@ import ( type editOptions struct { selectorArg string - addRequiredReviewer []string - removeRequiredReviewer []string - addOptionalReviewer []string - removeOptionalReviewer []string - base string - body string - bodyFile string - title string - addLabel []string - removeLabel []string + addRequiredReviewer []string + addOptionalReviewer []string + removeReviewer []string + base string + body string + bodyFile string + title string + addLabel []string + removeLabel []string +} + +type reviewerIntent struct { + ref git.IdentityRefWithVote + required bool } func NewCmd(ctx util.CmdContext) *cobra.Command { @@ -41,6 +49,16 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { Without an argument, the pull request that belongs to the current branch is selected. If there are more than one pull request associated with the current branch, one pull request will be selected based on the shared finder logic. + + The command can: + - Add reviewers as optional or required, promoting/demoting existing reviewers when needed. + - Remove reviewers regardless of their current required/optional state. + - Add or remove labels + + Examples: + %[1]sazdo pr edit --add-required-reviewer alice@example.com bob@example.com%[1]s + %[1]sazdo pr edit --add-optional-reviewer alice@example.com --remove-reviewer bob@example.com%[1]s + %[1]sazdo pr edit --add-label bug --remove-label needs-review%[1]s `, "`"), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -57,16 +75,15 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { }, } - cmd.Flags().StringSliceVarP(&opts.addRequiredReviewer, "add-required-reviewer", "", nil, "Add required reviewers (comma-separated)") - cmd.Flags().StringSliceVarP(&opts.removeRequiredReviewer, "remove-required-reviewer", "", nil, "Remove required reviewers (comma-separated)") - cmd.Flags().StringSliceVarP(&opts.addOptionalReviewer, "add-optional-reviewer", "", nil, "Add optional reviewers (comma-separated)") - cmd.Flags().StringSliceVarP(&opts.removeOptionalReviewer, "remove-optional-reviewer", "", nil, "Remove optional reviewers (comma-separated)") + cmd.Flags().StringSliceVarP(&opts.addRequiredReviewer, "add-required-reviewer", "", nil, "Add or promote required reviewers (comma-separated)") + cmd.Flags().StringSliceVarP(&opts.addOptionalReviewer, "add-optional-reviewer", "", nil, "Add or demote optional reviewers (comma-separated)") + cmd.Flags().StringSliceVarP(&opts.removeReviewer, "remove-reviewer", "", nil, "Remove reviewers (comma-separated, use * to remove all)") cmd.Flags().StringVarP(&opts.base, "base", "B", "", "Change the base branch for this pull request") cmd.Flags().StringVarP(&opts.body, "body", "b", "", "Set the new body.") cmd.Flags().StringVarP(&opts.bodyFile, "body-file", "F", "", "Read body text from file (use \"-\" to read from standard input)") cmd.Flags().StringVarP(&opts.title, "title", "t", "", "Set the new title.") cmd.Flags().StringSliceVarP(&opts.addLabel, "add-label", "", nil, "Add labels (comma-separated)") - cmd.Flags().StringSliceVarP(&opts.removeLabel, "remove-label", "", nil, "Remove labels (comma-separated)") + cmd.Flags().StringSliceVarP(&opts.removeLabel, "remove-label", "", nil, "Remove labels (comma-separated, use * to remove all)") // Register branch completion for the base flag // I will need to get the GitClient here or pass it to RegisterBranchCompletionFlags @@ -86,6 +103,14 @@ func runCmd(ctx util.CmdContext, opts *editOptions) (err error) { return err } + if slices.Contains(opts.removeReviewer, "*") && len(opts.removeReviewer) > 1 { + return util.FlagErrorf("--remove-reviewer cannot combine \"*\" with other values") + } + + if slices.Contains(opts.removeLabel, "*") && len(opts.removeLabel) > 1 { + return util.FlagErrorf("--remove-label cannot combine \"*\" with other values") + } + finder, err := shared.NewFinder(ctx) if err != nil { return fmt.Errorf("failed to create PR finder: %w", err) @@ -99,7 +124,7 @@ func runCmd(ctx util.CmdContext, opts *editOptions) (err error) { } if pr == nil { - return fmt.Errorf("pull request not found") + return util.FlagErrorf("pull request not found") } gitClient, err := prRepo.GitClient(ctx.Context(), ctx.ConnectionFactory()) @@ -141,112 +166,240 @@ func runCmd(ctx util.CmdContext, opts *editOptions) (err error) { updatePullRequest.TargetRefName = types.ToPtr("refs/heads/" + base) } - // Handle reviewers - reviewersMap := make(map[string]git.IdentityRefWithVote) + // Call the API to update the pull request + updatedPr, err := gitClient.UpdatePullRequest(ctx.Context(), git.UpdatePullRequestArgs{ + GitPullRequestToUpdate: &updatePullRequest, // Corrected field name + RepositoryId: types.ToPtr(repo.Id.String()), + PullRequestId: pr.PullRequestId, + Project: types.ToPtr(prRepo.Project()), + }) + if err != nil { + return fmt.Errorf("failed to update pull request: %w", err) + } + + currentReviewers := make(map[string]git.IdentityRefWithVote) if pr.Reviewers != nil { - for _, r := range *pr.Reviewers { - if r.Id != nil { - reviewersMap[*r.Id] = r + for _, reviewer := range *pr.Reviewers { + if reviewer.Id == nil { + continue } + currentReviewers[*reviewer.Id] = reviewer } } - // Handle reviewers to remove - if len(opts.removeRequiredReviewer) > 0 { - identities, err := shared.GetReviewerIdentities(ctx.Context(), identityClient, opts.removeRequiredReviewer) - if err != nil { - return fmt.Errorf("failed to resolve required reviewers to remove: %w", err) - } - for _, identity := range identities { - id := identity.Id.String() - if reviewer, ok := reviewersMap[id]; ok && reviewer.IsRequired != nil && *reviewer.IsRequired { - delete(reviewersMap, id) + var remove map[string]git.IdentityRefWithVote + if slices.Contains(opts.removeReviewer, "*") { + remove = map[string]git.IdentityRefWithVote{} + for _, id := range types.MapSlicePtr(pr.Reviewers, func(ident git.IdentityRefWithVote) string { + return *ident.Id + }) { + remove[id] = git.IdentityRefWithVote{ + Id: types.ToPtr(id), } } + } else { + remove, err = buildReviewerMap(ctx.Context(), identityClient, opts.removeReviewer) + if err != nil { + return fmt.Errorf("failed to resolve reviewers to remove: %w", err) + } } - if len(opts.removeOptionalReviewer) > 0 { - identities, err := shared.GetReviewerIdentities(ctx.Context(), identityClient, opts.removeOptionalReviewer) + + for reviewerID := range remove { + _, ok := currentReviewers[reviewerID] + if !ok { + continue + } + + err := gitClient.DeletePullRequestReviewer(ctx.Context(), git.DeletePullRequestReviewerArgs{ + RepositoryId: types.ToPtr(repo.Id.String()), + PullRequestId: pr.PullRequestId, + ReviewerId: types.ToPtr(reviewerID), + Project: types.ToPtr(prRepo.Project()), + }) if err != nil { - return fmt.Errorf("failed to resolve optional reviewers to remove: %w", err) + return fmt.Errorf("failed to remove reviewer %s: %w", reviewerID, err) } - for _, identity := range identities { - id := identity.Id.String() - if reviewer, ok := reviewersMap[id]; ok && (reviewer.IsRequired == nil || !*reviewer.IsRequired) { - delete(reviewersMap, id) - } + + delete(currentReviewers, reviewerID) + } + + intents := make(map[string]reviewerIntent) + if err := addReviewerIntents(ctx.Context(), identityClient, opts.addOptionalReviewer, false, intents); err != nil { + return fmt.Errorf("failed to resolve optional reviewers to add: %w", err) + } + if err := addReviewerIntents(ctx.Context(), identityClient, opts.addRequiredReviewer, true, intents); err != nil { + return fmt.Errorf("failed to resolve required reviewers to add: %w", err) + } + + var reviewersToAdd []webapi.IdentityRef + for id, intent := range intents { + if _, exists := currentReviewers[id]; exists { + continue } + reviewersToAdd = append(reviewersToAdd, webapi.IdentityRef{Id: intent.ref.Id}) } - // Handle reviewers to add - if len(opts.addOptionalReviewer) > 0 { - identities, err := shared.GetReviewerIdentities(ctx.Context(), identityClient, opts.addOptionalReviewer) + if len(reviewersToAdd) > 0 { + _, err = gitClient.CreatePullRequestReviewers(ctx.Context(), git.CreatePullRequestReviewersArgs{ + RepositoryId: types.ToPtr(repo.Id.String()), + PullRequestId: pr.PullRequestId, + Project: types.ToPtr(prRepo.Project()), + Reviewers: &reviewersToAdd, + }) if err != nil { - return fmt.Errorf("failed to resolve optional reviewers to add: %w", err) + return fmt.Errorf("failed to add reviewers: %w", err) } - for _, identity := range identities { - id := identity.Id.String() - if _, exists := reviewersMap[id]; !exists { - reviewersMap[id] = git.IdentityRefWithVote{Id: types.ToPtr(id), IsRequired: types.ToPtr(false)} + + for _, reviewer := range reviewersToAdd { + if reviewer.Id == nil { + continue + } + currentReviewers[*reviewer.Id] = git.IdentityRefWithVote{ + Id: reviewer.Id, } } } - if len(opts.addRequiredReviewer) > 0 { - identities, err := shared.GetReviewerIdentities(ctx.Context(), identityClient, opts.addRequiredReviewer) + + for id, intent := range intents { + reviewer, exists := currentReviewers[id] + if !exists { + continue + } + currentRequired := reviewer.IsRequired != nil && *reviewer.IsRequired + if currentRequired == intent.required { + continue + } + + _, err := gitClient.CreatePullRequestReviewer(ctx.Context(), git.CreatePullRequestReviewerArgs{ + RepositoryId: types.ToPtr(repo.Id.String()), + PullRequestId: pr.PullRequestId, + Project: types.ToPtr(prRepo.Project()), + ReviewerId: types.ToPtr(id), + Reviewer: &git.IdentityRefWithVote{ + Id: types.ToPtr(id), + IsRequired: types.ToPtr(intent.required), + }, + }) if err != nil { - return fmt.Errorf("failed to resolve required reviewers to add: %w", err) + state := "required" + if !intent.required { + state = "optional" + } + return fmt.Errorf("failed to set reviewer %s %s: %w", id, state, err) } - for _, identity := range identities { - id := identity.Id.String() - reviewersMap[id] = git.IdentityRefWithVote{Id: types.ToPtr(id), IsRequired: types.ToPtr(true)} + + reviewer.IsRequired = types.ToPtr(intent.required) + currentReviewers[id] = reviewer + } + + // Track existing labels with lowercase keys so we can (a) match user input when + // removing labels regardless of casing, and (b) skip redundant additions. The map + // stores the canonical server casing so Delete/Create calls stay precise. + labelLookup := make(map[string]string) + if pr.Labels != nil { + for _, lbl := range *pr.Labels { + if lbl.Name == nil { + continue + } + labelLookup[strings.ToLower(*lbl.Name)] = *lbl.Name } } - // Convert map to slice - finalReviewers := make([]git.IdentityRefWithVote, 0, len(reviewersMap)) - for _, r := range reviewersMap { - finalReviewers = append(finalReviewers, r) + if slices.Contains(opts.removeLabel, "*") { + opts.removeLabel = types.MapSlicePtr(pr.Labels, func(i core.WebApiTagDefinition) string { + return *i.Name + }) } - // We need to set the reviewers on the update request, even if the final list is empty, - // to support removing all reviewers. - updatePullRequest.Reviewers = &finalReviewers + for _, raw := range opts.removeLabel { + name := strings.TrimSpace(raw) + if name == "" { + continue + } + lower := strings.ToLower(name) + if _, exists := labelLookup[lower]; !exists { + continue + } - // Handle labels - var labels []core.WebApiTagDefinition - if pr.Labels != nil { - // Need to convert git.GitPullRequestLabel to core.WebApiTagDefinition - for _, label := range *pr.Labels { - labels = append(labels, core.WebApiTagDefinition{ - Id: label.Id, - Name: label.Name, - }) + err := gitClient.DeletePullRequestLabels(ctx.Context(), git.DeletePullRequestLabelsArgs{ + RepositoryId: types.ToPtr(repo.Id.String()), + PullRequestId: pr.PullRequestId, + LabelIdOrName: types.ToPtr(labelLookup[lower]), + Project: types.ToPtr(prRepo.Project()), + }) + if err != nil { + return fmt.Errorf("failed to remove label %s: %w", name, err) } + + delete(labelLookup, lower) } - // Add labels - for _, l := range opts.addLabel { - labels = append(labels, core.WebApiTagDefinition{ - Name: types.ToPtr(l), + for _, raw := range opts.addLabel { + name := strings.TrimSpace(raw) + if name == "" { + continue + } + lower := strings.ToLower(name) + if _, exists := labelLookup[lower]; exists { + continue + } + + _, err := gitClient.CreatePullRequestLabel(ctx.Context(), git.CreatePullRequestLabelArgs{ + RepositoryId: types.ToPtr(repo.Id.String()), + PullRequestId: pr.PullRequestId, + Project: types.ToPtr(prRepo.Project()), + Label: &core.WebApiCreateTagRequestData{ + Name: types.ToPtr(name), + }, }) + if err != nil { + return fmt.Errorf("failed to add label %s: %w", name, err) + } + + labelLookup[lower] = name } - // Remove labels - for _, l := range opts.removeLabel { - labels = removeLabel(labels, l) + fmt.Fprintf(iostreams.Out, "Pull request #%d updated: %s\n", *updatedPr.PullRequestId, *updatedPr.Title) + + return nil +} + +func buildReviewerMap(ctx context.Context, identityClient identity.Client, handles []string) (map[string]git.IdentityRefWithVote, error) { + reviewers := make(map[string]git.IdentityRefWithVote) + if len(handles) == 0 { + return reviewers, nil } - updatePullRequest.Labels = &labels - // Call the API to update the pull request - updatedPr, err := gitClient.UpdatePullRequest(ctx.Context(), git.UpdatePullRequestArgs{ - GitPullRequestToUpdate: &updatePullRequest, // Corrected field name - RepositoryId: types.ToPtr(repo.Id.String()), - PullRequestId: pr.PullRequestId, - Project: types.ToPtr(prRepo.Project()), - }) + resolved, err := shared.GetReviewerIdentities(ctx, identityClient, handles) if err != nil { - return fmt.Errorf("failed to update pull request: %w", err) + return nil, err } - fmt.Fprintf(iostreams.Out, "Pull request #%d updated: %s\n", *updatedPr.PullRequestId, *updatedPr.Title) + for _, identity := range resolved { + id := identity.Id.String() + reviewers[id] = git.IdentityRefWithVote{ + Id: types.ToPtr(id), + } + } + + return reviewers, nil +} + +func addReviewerIntents(ctx context.Context, identityClient identity.Client, handles []string, required bool, intents map[string]reviewerIntent) error { + resolved, err := shared.GetReviewerIdentities(ctx, identityClient, handles) + if err != nil { + return err + } + + for _, identity := range resolved { + id := identity.Id.String() + intent := intents[id] + intent.required = intent.required || required + intent.ref = git.IdentityRefWithVote{ + Id: types.ToPtr(id), + } + + intents[id] = intent + } return nil } @@ -257,13 +410,3 @@ func readBodyFile(filename string) ([]byte, error) { } return os.ReadFile(filename) //nolint:gosec } - -func removeLabel(labels []core.WebApiTagDefinition, labelToRemove string) []core.WebApiTagDefinition { - var updatedLabels []core.WebApiTagDefinition - for _, l := range labels { - if !strings.EqualFold(*l.Name, labelToRemove) { - updatedLabels = append(updatedLabels, l) - } - } - return updatedLabels -} diff --git a/internal/cmd/pr/view/view.go b/internal/cmd/pr/view/view.go index d4bb7d01..fc0193d7 100644 --- a/internal/cmd/pr/view/view.go +++ b/internal/cmd/pr/view/view.go @@ -54,6 +54,7 @@ type pullRequestJSON struct { Description *string `json:"description,omitempty"` Threads *[]threadJSON `json:"threads,omitempty"` Commits *[]commitJSON `json:"commits,omitempty"` + Labels *[]string `json:"labels,omitempty"` } type authorJSON struct { @@ -121,6 +122,7 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { Args: cobra.MaximumNArgs(1), Aliases: []string{ "show", + "status", }, RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { @@ -135,7 +137,23 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { cmd.Flags().BoolVarP(&opts.showRaw, "raw", "r", false, "View pull request raw") util.StringEnumFlag(cmd, &opts.commentType, "comment-type", "", "text", []string{"text", "system", "all"}, "Filter comments by type; defaults to 'text'") util.StringEnumFlag(cmd, &opts.commentSort, "comment-sort", "", "desc", []string{"desc", "asc"}, "Sort comments by creation time; defaults to 'desc' (newest first)") - util.AddFormatFlags(cmd, &opts.exporter) + util.AddJSONFlags(cmd, &opts.exporter, []string{ + "url", + "id", + "title", + "author", + "createdOn", + "status", + "mergeStatus", + "isDraft", + "sourceBranch", + "targetBranch", + "reviewers", + "description", + "threads", + "commits", + "labels", + }) return cmd } @@ -379,6 +397,17 @@ func runCmd(ctx util.CmdContext, opts *viewOptions) (err error) { } prJSON.Reviewers = &reviewers } + if pr.Labels != nil && len(*pr.Labels) > 0 { + labels := make([]string, 0, len(*pr.Labels)) + for _, label := range *pr.Labels { + if label.Name != nil { + labels = append(labels, *label.Name) + } + } + if len(labels) > 0 { + prJSON.Labels = &labels + } + } if threads != nil { threadsJSON := make([]threadJSON, 0) for _, thread := range *threads { diff --git a/internal/cmd/pr/view/view.tpl b/internal/cmd/pr/view/view.tpl index e82b2a8e..6e3588dd 100644 --- a/internal/cmd/pr/view/view.tpl +++ b/internal/cmd/pr/view/view.tpl @@ -8,6 +8,13 @@ {{bold "draft:"}} {{.PullRequest.IsDraft}} {{bold "source branch:"}} {{stripprefix (s .PullRequest.SourceRefName) "refs/heads/" }} {{bold "target branch:"}} {{stripprefix (s .PullRequest.TargetRefName) "refs/heads/" }} +{{ $labels := .PullRequest.Labels -}} +{{ $llength := len $labels -}} +{{if gt $llength 0 -}} +{{bold "labels:"}} +{{range .PullRequest.Labels}} {{s .Name}} +{{end -}} +{{end -}} {{ $reviewers := userReviewers .PullRequest.Reviewers -}} {{ $length := len $reviewers -}} {{if gt $length 0 -}} diff --git a/internal/cmd/project/create/create.go b/internal/cmd/project/create/create.go index 8ecc17fa..93c5f4c2 100644 --- a/internal/cmd/project/create/create.go +++ b/internal/cmd/project/create/create.go @@ -71,6 +71,11 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { Args: cobra.ExactArgs(1), Aliases: []string{ "cr", + "c", + "new", + "n", + "add", + "a", }, PreRunE: func(cmd *cobra.Command, args []string) error { noWaitChanged := cmd.Flags().Changed("no-wait") diff --git a/internal/cmd/serviceendpoint/create/azurerm/create.go b/internal/cmd/serviceendpoint/create/azurerm/create.go new file mode 100644 index 00000000..b5420d46 --- /dev/null +++ b/internal/cmd/serviceendpoint/create/azurerm/create.go @@ -0,0 +1,491 @@ +package azurerm + +import ( + "errors" + "fmt" + "os" + + "github.com/MakeNowJust/heredoc" + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/serviceendpoint" + "github.com/spf13/cobra" + "go.uber.org/zap" + + "github.com/tmeckel/azdo-cli/internal/cmd/serviceendpoint/shared" + "github.com/tmeckel/azdo-cli/internal/cmd/util" + "github.com/tmeckel/azdo-cli/internal/iostreams" + "github.com/tmeckel/azdo-cli/internal/prompter" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type createOptions struct { + project string + + name string + description string + authenticationScheme string + servicePrincipalID string + servicePrincipalKey string + servicePrincipalCertificate string + certificatePath string + tenantID string + subscriptionID string + subscriptionName string + managementGroupID string + managementGroupName string + environment string + resourceGroup string + serverURL string + serviceEndpointCreationMode string + grantPermissionToAllPipelines bool + + yes bool + exporter util.Exporter +} + +const ( + // Authentication Schemes + AuthSchemeServicePrincipal = "ServicePrincipal" + AuthSchemeManagedServiceIdentity = "ManagedServiceIdentity" + AuthSchemeWorkloadIdentityFederation = "WorkloadIdentityFederation" + + // Creation Modes + CreationModeManual = "Manual" + CreationModeAutomatic = "Automatic" + + // Scope Levels + ScopeLevelSubscription = "Subscription" + ScopeLevelResourceGroup = "ResourceGroup" + ScopeLevelManagementGroup = "ManagementGroup" +) + +func NewCmd(ctx util.CmdContext) *cobra.Command { + opts := &createOptions{} + + cmd := &cobra.Command{ + Use: "azurerm [ORGANIZATION/]PROJECT --name --authentication-scheme [flags]", + Short: "Create an Azure Resource Manager service connection", + Long: heredoc.Doc(` + Create an Azure Resource Manager service connection. + This command is modeled after the Azure DevOps Terraform Provider's implementation for creating azurerm service endpoints. + `), + Example: heredoc.Doc(` + # Service Principal with a secret + azdo service-endpoint create azurerm my-org/my-project \ + --name "My AzureRM SPN Secret Connection" \ + --authentication-scheme ServicePrincipal \ + --tenant-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ + --service-principal-id "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" \ + --service-principal-key "my-service-principal-secret" \ + --subscription-id "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz" \ + --subscription-name "My Azure Subscription" \ + --resource-group "my-resource-group" \ + --description "Service Connection for my AzureRM resources" + + # Service Principal with a certificate + azdo service-endpoint create azurerm my-org/my-project \ + --name "My AzureRM SPN Cert Connection" \ + --authentication-scheme ServicePrincipal \ + --tenant-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ + --service-principal-id "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" \ + --certificate-path "/path/to/my-cert.pem" \ + --subscription-id "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz" \ + --subscription-name "My Azure Subscription" \ + --description "Certificate-based Service Connection" + + # Managed Service Identity + azdo service-endpoint create azurerm my-org/my-project \ + --name "My AzureRM MSI Connection" \ + --authentication-scheme ManagedServiceIdentity \ + --tenant-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ + --subscription-id "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz" \ + --subscription-name "My Azure Subscription" \ + --description "MSI Service Connection" + + # Workload Identity Federation (Manual mode, with existing Service Principal) + azdo service-endpoint create azurerm my-org/my-project \ + --name "My AzureRM WIF Manual Connection" \ + --authentication-scheme WorkloadIdentityFederation \ + --tenant-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ + --service-principal-id "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" \ + --subscription-id "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz" \ + --subscription-name "My Azure Subscription" \ + --description "WIF Manual Service Connection" + + # Workload Identity Federation (Automatic mode, Azure DevOps creates Service Principal) + azdo service-endpoint create azurerm my-org/my-project \ + --name "My AzureRM WIF Automatic Connection" \ + --authentication-scheme WorkloadIdentityFederation \ + --tenant-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ + --subscription-id "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz" \ + --subscription-name "My Azure Subscription" \ + --description "WIF Automatic Service Connection" + + # Service Principal with Management Group Scope + azdo service-endpoint create azurerm my-org/my-project \ + --name "My AzureRM MGMT Group Connection" \ + --authentication-scheme ServicePrincipal \ + --tenant-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ + --service-principal-id "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" \ + --service-principal-key "my-service-principal-secret" \ + --management-group-id "my-mgmt-group-id" \ + --management-group-name "My Management Group" \ + --description "Service Connection scoped to a Management Group" + + # Azure Stack Environment + azdo service-endpoint create azurerm my-org/my-project \ + --name "My AzureStack Connection" \ + --authentication-scheme ServicePrincipal \ + --tenant-id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" \ + --service-principal-id "yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy" \ + --service-principal-key "my-service-principal-secret" \ + --subscription-id "zzzzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz" \ + --subscription-name "My Azure Stack Subscription" \ + --environment AzureStack \ + --server-url "https://management.myazurestack.com/" \ + --description "Service Connection for Azure Stack" + `), + Args: cobra.ExactArgs(1), + Aliases: []string{ + "cr", + "c", + "new", + "n", + "add", + "a", + }, + RunE: func(cmd *cobra.Command, args []string) error { + opts.project = args[0] + return runCreate(ctx, opts) + }, + } + + cmd.Flags().StringVar(&opts.name, "name", "", "Name of the service endpoint") + cmd.Flags().StringVar(&opts.description, "description", "", "Description for the service endpoint") + util.StringEnumFlag(cmd, &opts.authenticationScheme, "authentication-scheme", "", AuthSchemeServicePrincipal, + []string{AuthSchemeServicePrincipal, AuthSchemeManagedServiceIdentity, AuthSchemeWorkloadIdentityFederation}, + "Authentication scheme") + cmd.Flags().StringVar(&opts.tenantID, "tenant-id", "", "Azure tenant ID (e.g., GUID)") + cmd.Flags().StringVar(&opts.subscriptionID, "subscription-id", "", "Azure subscription ID (e.g., GUID)") + cmd.Flags().StringVar(&opts.subscriptionName, "subscription-name", "", "Azure subscription name") + cmd.Flags().StringVar(&opts.managementGroupID, "management-group-id", "", "Azure management group ID") + cmd.Flags().StringVar(&opts.managementGroupName, "management-group-name", "", "Azure management group name") + cmd.Flags().StringVar(&opts.resourceGroup, "resource-group", "", "Name of the resource group (for subscription-level scope)") + cmd.Flags().StringVar(&opts.servicePrincipalID, "service-principal-id", "", "Service principal/application ID (e.g., GUID)") + cmd.Flags().StringVar(&opts.servicePrincipalKey, "service-principal-key", "", "Service principal key (secret value)") + cmd.Flags().StringVar(&opts.certificatePath, "certificate-path", "", "Path to service principal certificate file (PEM format)") + util.StringEnumFlag(cmd, &opts.environment, "environment", "", "AzureCloud", + []string{"AzureCloud", "AzureChinaCloud", "AzureUSGovernment", "AzureGermanCloud", "AzureStack"}, + "Azure environment") + cmd.Flags().StringVar(&opts.serverURL, "server-url", "", "Azure Stack Resource Manager base URL. Required if --environment is AzureStack.") + cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Skip confirmation prompts") + cmd.Flags().BoolVar(&opts.grantPermissionToAllPipelines, "grant-permission-to-all-pipelines", false, "Grant access permission to all pipelines to use the service connection") + + util.AddJSONFlags(cmd, &opts.exporter, []string{"id", "name", "type", "url", "description", "authorization"}) + + _ = cmd.MarkFlagRequired("name") + _ = cmd.MarkFlagRequired("authentication-scheme") + + return cmd +} + +func runCreate(ctx util.CmdContext, opts *createOptions) error { + ios, err := ctx.IOStreams() + if err != nil { + return err + } + + p, err := ctx.Prompter() + if err != nil { + return err + } + + scope, err := util.ParseProjectScope(ctx, opts.project) + if err != nil { + return util.FlagErrorWrap(err) + } + + if err := validateOpts(opts, ios, p); err != nil { + return util.FlagErrorWrap(err) + } + + if !opts.yes { + ok, err := p.Confirm("This will create credentials in Azure DevOps. Continue?", false) + if err != nil { + return err + } + if !ok { + return util.ErrCancel + } + } + + projectRef, err := resolveProjectReference(ctx, scope) + if err != nil { + return util.FlagErrorWrap(err) + } + + endpoint, err := buildServiceEndpoint(opts, projectRef) + if err != nil { + return util.FlagErrorf("failed to build service endpoint payload: %w", err) + } + + ios.StartProgressIndicator() + defer ios.StopProgressIndicator() + + client, err := ctx.ClientFactory().ServiceEndpoint(ctx.Context(), scope.Organization) + if err != nil { + return err + } + + createdEndpoint, err := client.CreateServiceEndpoint(ctx.Context(), serviceendpoint.CreateServiceEndpointArgs{ + Endpoint: endpoint, + }) + if err != nil { + return fmt.Errorf("failed to create service endpoint: %w", err) + } + + zap.L().Debug("azurerm service endpoint created", + zap.String("id", types.GetValue(createdEndpoint.Id, uuid.Nil).String()), + zap.String("name", types.GetValue(createdEndpoint.Name, "")), + ) + + if opts.grantPermissionToAllPipelines { + projectID := types.GetValue(projectRef.Id, uuid.Nil) + if projectID == uuid.Nil { + return errors.New("project reference missing ID") + } + + endpointID := types.GetValue(createdEndpoint.Id, uuid.Nil) + if endpointID == uuid.Nil { + return errors.New("service endpoint create response missing ID") + } + + if err := shared.GrantAllPipelinesAccessToEndpoint(ctx, + scope.Organization, + projectID, + endpointID, + func() error { + return client.DeleteServiceEndpoint(ctx.Context(), serviceendpoint.DeleteServiceEndpointArgs{ + EndpointId: types.ToPtr(endpointID), + ProjectIds: &[]string{projectID.String()}, + }) + }); err != nil { + return err + } + + zap.L().Debug("Granted all pipelines permission to service endpoint", + zap.String("id", endpointID.String()), + ) + } + + ios.StopProgressIndicator() + + if opts.exporter != nil { + return opts.exporter.Write(ios, createdEndpoint) + } + + tp, err := ctx.Printer("list") + if err != nil { + return err + } + tp.AddColumns("ID", "Name", "Type", "URL") + tp.EndRow() + tp.AddField(types.GetValue(createdEndpoint.Id, uuid.Nil).String()) + tp.AddField(types.GetValue(createdEndpoint.Name, "")) + tp.AddField(types.GetValue(createdEndpoint.Type, "")) + tp.AddField(types.GetValue(createdEndpoint.Url, "")) + tp.EndRow() + tp.Render() + + return nil +} + +func validateOpts(opts *createOptions, ios *iostreams.IOStreams, p prompter.Prompter) error { + if opts.tenantID == "" { + return errors.New("--tenant-id is required") + } + + // Validate scope + if opts.subscriptionID == "" && opts.managementGroupID == "" { + return errors.New("one of --subscription-id or --management-group-id must be provided") + } + if opts.subscriptionID != "" && opts.managementGroupID != "" { + return errors.New("--subscription-id and --management-group-id are mutually exclusive") + } + if opts.managementGroupID != "" && opts.managementGroupName == "" { + return errors.New("--management-group-name is required when --management-group-id is specified") + } + if opts.subscriptionID != "" && opts.subscriptionName == "" { + return errors.New("--subscription-name is required when --subscription-id is specified") + } + + // Set creation mode + hasCredentials := opts.servicePrincipalID != "" + if opts.authenticationScheme == AuthSchemeServicePrincipal || opts.authenticationScheme == AuthSchemeWorkloadIdentityFederation { + if hasCredentials { + opts.serviceEndpointCreationMode = CreationModeManual + } else { + opts.serviceEndpointCreationMode = CreationModeAutomatic + } + } + + // Validate auth scheme specific requirements + switch opts.authenticationScheme { + case AuthSchemeServicePrincipal: + if opts.serviceEndpointCreationMode == CreationModeAutomatic { + return errors.New("automatic creation mode is not supported for ServicePrincipal from the CLI. Please provide --service-principal-id") + } + if opts.servicePrincipalKey == "" && opts.certificatePath == "" { + if !ios.CanPrompt() { + return errors.New("--service-principal-key not provided and prompting disabled") + } + secret, err := p.Password("Service principal key:") + if err != nil { + return fmt.Errorf("prompt for secret failed: %w", err) + } + opts.servicePrincipalKey = secret + } + if opts.servicePrincipalKey != "" && opts.certificatePath != "" { + return errors.New("--service-principal-key and --certificate-path are mutually exclusive") + } + if opts.certificatePath != "" { + certBytes, err := os.ReadFile(opts.certificatePath) + if err != nil { + return fmt.Errorf("failed to read certificate file: %w", err) + } + opts.servicePrincipalCertificate = string(certBytes) + } + case AuthSchemeWorkloadIdentityFederation: + // This is a valid scenario, where ADO will configure the SPN. + case AuthSchemeManagedServiceIdentity: + // No specific validation needed + default: + return fmt.Errorf("invalid --authentication-scheme: %s", opts.authenticationScheme) + } + + if opts.environment == "AzureStack" && opts.serverURL == "" { + return errors.New("--server-url is required when environment is AzureStack") + } + + return nil +} + +func buildServiceEndpoint(opts *createOptions, projectRef *serviceendpoint.ProjectReference) (*serviceendpoint.ServiceEndpoint, error) { + endpointType := "azurerm" + endpointURL, err := getEndpointURL(opts) + if err != nil { + return nil, err + } + owner := "library" + + authParams := map[string]string{ + "tenantid": opts.tenantID, + } + + data := map[string]string{ + "environment": opts.environment, + } + + if opts.serviceEndpointCreationMode != "" && opts.authenticationScheme != AuthSchemeManagedServiceIdentity { + data["creationMode"] = opts.serviceEndpointCreationMode + } + + // Scope handling + if opts.subscriptionID != "" { + if opts.resourceGroup != "" && opts.authenticationScheme != AuthSchemeManagedServiceIdentity { + authParams["scope"] = fmt.Sprintf("/subscriptions/%s/resourcegroups/%s", opts.subscriptionID, opts.resourceGroup) + } + data["scopeLevel"] = ScopeLevelSubscription + data["subscriptionId"] = opts.subscriptionID + data["subscriptionName"] = opts.subscriptionName + + } else if opts.managementGroupID != "" { + data["scopeLevel"] = ScopeLevelManagementGroup + data["managementGroupId"] = opts.managementGroupID + data["managementGroupName"] = opts.managementGroupName + } + + // Auth scheme specific logic + switch opts.authenticationScheme { + case AuthSchemeServicePrincipal: + authParams["serviceprincipalid"] = opts.servicePrincipalID + if opts.servicePrincipalKey != "" { + authParams["authenticationType"] = "spnKey" + authParams["serviceprincipalkey"] = opts.servicePrincipalKey + } else if opts.servicePrincipalCertificate != "" { + authParams["authenticationType"] = "spnCertificate" + authParams["servicePrincipalCertificate"] = opts.servicePrincipalCertificate + } + case AuthSchemeWorkloadIdentityFederation: + if opts.serviceEndpointCreationMode == CreationModeManual { + if opts.servicePrincipalID == "" { + return nil, errors.New("serviceprincipalid is required for WorkloadIdentityFederation in Manual mode") + } + authParams["serviceprincipalid"] = opts.servicePrincipalID + } else { + authParams["serviceprincipalid"] = "" + } + case AuthSchemeManagedServiceIdentity: + // No extra auth params needed + } + + return &serviceendpoint.ServiceEndpoint{ + Name: &opts.name, + Type: &endpointType, + Url: &endpointURL, + Description: &opts.description, + Owner: &owner, + Authorization: &serviceendpoint.EndpointAuthorization{ + Scheme: &opts.authenticationScheme, + Parameters: &authParams, + }, + Data: &data, + ServiceEndpointProjectReferences: &[]serviceendpoint.ServiceEndpointProjectReference{ + { + ProjectReference: projectRef, + Name: &opts.name, + Description: &opts.description, + }, + }, + }, nil +} + +func getEndpointURL(opts *createOptions) (string, error) { + switch opts.environment { + case "AzureCloud": + return "https://management.azure.com/", nil + case "AzureChinaCloud": + return "https://management.chinacloudapi.cn/", nil + case "AzureUSGovernment": + return "https://management.usgovcloudapi.net/", nil + case "AzureGermanCloud": + return "https://management.microsoftazure.de/", nil + case "AzureStack": + return opts.serverURL, nil + default: + return "", fmt.Errorf("unknown environment: %s", opts.environment) + } +} + +func resolveProjectReference(ctx util.CmdContext, scope *util.Scope) (*serviceendpoint.ProjectReference, error) { + coreClient, err := ctx.ClientFactory().Core(ctx.Context(), scope.Organization) + if err != nil { + return nil, fmt.Errorf("failed to create core client: %w", err) + } + + project, err := coreClient.GetProject(ctx.Context(), core.GetProjectArgs{ + ProjectId: types.ToPtr(scope.Project), + }) + if err != nil { + return nil, fmt.Errorf("failed to resolve project %q: %w", scope.Project, err) + } + if project == nil || project.Id == nil { + return nil, fmt.Errorf("project %q does not expose an ID", scope.Project) + } + + return &serviceendpoint.ProjectReference{ + Id: project.Id, + Name: project.Name, + }, nil +} diff --git a/internal/cmd/serviceendpoint/create/azurerm/create_acc_test.go b/internal/cmd/serviceendpoint/create/azurerm/create_acc_test.go new file mode 100644 index 00000000..53add778 --- /dev/null +++ b/internal/cmd/serviceendpoint/create/azurerm/create_acc_test.go @@ -0,0 +1,479 @@ +package azurerm + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/core" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/operations" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/serviceendpoint" + + "github.com/tmeckel/azdo-cli/internal/azdo" + inttest "github.com/tmeckel/azdo-cli/internal/test" + "github.com/tmeckel/azdo-cli/internal/types" +) + +type contextKey string + +const ( + ctxKeyCreateOpts contextKey = "azurerm/create-opts" + ctxKeyEndpointID contextKey = "azurerm/endpoint-id" + ctxKeyEndpointProjectID contextKey = "azurerm/project-id" + ctxKeyCertPath contextKey = "azurerm/cert-path" + ctxKeyProjectName contextKey = "azurerm/test-project-name" + ctxKeyProjectID contextKey = "azurerm/test-project-id" + testCertificatePEM = `-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJAKoK/heBjcOuMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTIwOTEyMjE1MjAyWhcNMTUwOTEyMjE1MjAyWjBF +MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 +ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBC +gKCAQEAwuTanj/Uo5Yhq7ckmL5jycB3Z/zPBuZjviQ4fAar/7xeOUe7/y2Kpls= +-----END CERTIFICATE-----` +) + +func TestAccCreateAzureRMServiceEndpoint(t *testing.T) { + sharedProj := newProject(fmt.Sprintf("azdo-cli-acc-%s", uuid.New().String())) + t.Cleanup(func() { + err := sharedProj.Cleanup() + if err != nil { + t.Logf("failed to delete project: %v", err) + } + }) + + // Test Service Principal with Secret + t.Run("ServicePrincipalWithSecret", func(t *testing.T) { + t.Parallel() + testAccCreateAzureRMServiceEndpoint(t, sharedProj, AuthSchemeServicePrincipal, CreationModeManual, func(opts *createOptions) { + opts.servicePrincipalKey = "test-secret-123" + }) + }) + + // Test Service Principal with Certificate + t.Run("ServicePrincipalWithCertificate", func(t *testing.T) { + t.Parallel() + testAccCreateAzureRMServiceEndpoint(t, sharedProj, AuthSchemeServicePrincipal, CreationModeManual, func(opts *createOptions) { + opts.certificatePath = "test-cert.pem" + }) + }) + + // Test Managed Service Identity + t.Run("ManagedServiceIdentity", func(t *testing.T) { + t.Parallel() + testAccCreateAzureRMServiceEndpoint(t, sharedProj, AuthSchemeManagedServiceIdentity, CreationModeManual, nil) + }) + + // Test Workload Identity Federation - Manual + t.Run("WorkloadIdentityFederationManual", func(t *testing.T) { + t.Parallel() + testAccCreateAzureRMServiceEndpoint(t, sharedProj, AuthSchemeWorkloadIdentityFederation, CreationModeManual, nil) + }) + + // Test Workload Identity Federation - Automatic + t.Run("WorkloadIdentityFederationAutomatic", func(t *testing.T) { + t.Parallel() + testAccCreateAzureRMServiceEndpoint(t, sharedProj, AuthSchemeWorkloadIdentityFederation, CreationModeAutomatic, nil) + }) +} + +func testAccCreateAzureRMServiceEndpoint(t *testing.T, sharedProj *sharedProject, authScheme string, creationMode string, setupFunc func(*createOptions)) { + // Generate unique names for each test run + endpointName := fmt.Sprintf("azdo-cli-test-ep-%s-%s", authScheme, uuid.New().String()) + subscriptionID := uuid.New().String() + subscriptionName := fmt.Sprintf("Test Subscription %s", authScheme) + resourceGroup := fmt.Sprintf("test-rg-%s", uuid.New().String()) + + inttest.Test(t, inttest.TestCase{ + Steps: []inttest.Step{ + { + PreRun: func(ctx inttest.TestContext) error { + return sharedProj.Ensure(ctx) + }, + Run: func(ctx inttest.TestContext) error { + projectName, err := getTestProjectName(ctx) + if err != nil { + return err + } + projectArg := fmt.Sprintf("%s/%s", ctx.Org(), projectName) + + opts := &createOptions{ + project: projectArg, + name: endpointName, + description: fmt.Sprintf("Test AzureRM endpoint with %s auth", authScheme), + authenticationScheme: authScheme, + servicePrincipalID: uuid.New().String(), // Random SPN ID + servicePrincipalKey: "", + certificatePath: "", + tenantID: uuid.New().String(), // Random tenant ID + subscriptionID: subscriptionID, + subscriptionName: subscriptionName, + resourceGroup: resourceGroup, + environment: "AzureCloud", + serviceEndpointCreationMode: creationMode, + grantPermissionToAllPipelines: true, + yes: true, + } + + if setupFunc != nil { + setupFunc(opts) + } + + if opts.certificatePath != "" { + // create a temporary certificate file using the test helper + certPath, err := inttest.WriteTestFileWithName(t, opts.certificatePath, strings.NewReader(testCertificatePEM)) + if err != nil { + return fmt.Errorf("failed to write certificate file: %w", err) + } + // override the path in opts so the command uses the generated file + opts.certificatePath = certPath + ctx.SetValue(ctxKeyCertPath, certPath) + } + + ctx.SetValue(ctxKeyCreateOpts, opts) + + // Execute the command + return runCreate(ctx, opts) + }, + Verify: func(ctx inttest.TestContext) error { + storedOpts, ok := ctx.Value(ctxKeyCreateOpts) + if !ok { + return fmt.Errorf("test context missing create options") + } + opts := storedOpts.(*createOptions) + + client, err := ctx.ClientFactory().ServiceEndpoint(ctx.Context(), ctx.Org()) + if err != nil { + return fmt.Errorf("failed to create service endpoint client: %w", err) + } + + return inttest.Poll(func() error { + projectName, err := getTestProjectName(ctx) + if err != nil { + return err + } + + endpoints, err := client.GetServiceEndpoints(ctx.Context(), serviceendpoint.GetServiceEndpointsArgs{ + Project: &projectName, + Type: types.ToPtr("azurerm"), + IncludeDetails: types.ToPtr(true), + }) + if err != nil { + return fmt.Errorf("failed to list service endpoints: %w", err) + } + + var foundEndpoint *serviceendpoint.ServiceEndpoint + for _, ep := range *endpoints { + if ep.Name != nil && *ep.Name == endpointName { + foundEndpoint = &ep + break + } + } + + if foundEndpoint == nil { + return fmt.Errorf("service endpoint '%s' not found", endpointName) + } + + if foundEndpoint.Id != nil { + ctx.SetValue(ctxKeyEndpointID, foundEndpoint.Id.String()) + } + if refs := foundEndpoint.ServiceEndpointProjectReferences; refs != nil { + for _, ref := range *refs { + if ref.ProjectReference != nil && ref.ProjectReference.Id != nil { + ctx.SetValue(ctxKeyEndpointProjectID, ref.ProjectReference.Id.String()) + break + } + } + } + + if foundEndpoint.Type == nil || *foundEndpoint.Type != "azurerm" { + return fmt.Errorf("expected endpoint type 'azurerm', got '%s'", types.GetValue(foundEndpoint.Type, "")) + } + + if foundEndpoint.Authorization == nil || foundEndpoint.Authorization.Scheme == nil { + return fmt.Errorf("endpoint authorization scheme is nil") + } + + if *foundEndpoint.Authorization.Scheme != authScheme { + return fmt.Errorf("expected auth scheme '%s', got '%s'", authScheme, *foundEndpoint.Authorization.Scheme) + } + + if foundEndpoint.Data == nil { + return fmt.Errorf("endpoint data is nil") + } + + data := *foundEndpoint.Data + if _, ok := data["subscriptionId"]; !ok { + return fmt.Errorf("subscriptionId not found in endpoint data") + } + if _, ok := data["subscriptionName"]; !ok { + return fmt.Errorf("subscriptionName not found in endpoint data") + } + if _, ok := data["environment"]; !ok { + return fmt.Errorf("environment not found in endpoint data") + } + + if foundEndpoint.Authorization.Parameters == nil { + return fmt.Errorf("endpoint authorization parameters is nil") + } + + params := *foundEndpoint.Authorization.Parameters + if _, ok := params["tenantid"]; !ok { + return fmt.Errorf("tenantid not found in auth parameters") + } + + switch authScheme { + case AuthSchemeServicePrincipal: + if _, ok := params["serviceprincipalid"]; !ok { + return fmt.Errorf("serviceprincipalid not found in auth parameters") + } + if opts.servicePrincipalKey != "" { + if _, ok := params["authenticationType"]; !ok || params["authenticationType"] != "spnKey" { + return fmt.Errorf("expected authenticationType 'spnKey' for service principal with secret") + } + } else if opts.certificatePath != "" { + if _, ok := params["authenticationType"]; !ok || params["authenticationType"] != "spnCertificate" { + return fmt.Errorf("expected authenticationType 'spnCertificate' for service principal with certificate") + } + } + case AuthSchemeWorkloadIdentityFederation: + if creationMode == CreationModeManual { + if _, ok := params["serviceprincipalid"]; !ok { + return fmt.Errorf("serviceprincipalid not found in auth parameters for manual WIF") + } + } + } + + return nil + }, inttest.PollOptions{ + Tries: 10, + Timeout: 30 * time.Second, + }) + }, + PostRun: func(ctx inttest.TestContext) error { + var errs []error + + if err := deleteCreatedEndpoint(ctx); err != nil { + errs = append(errs, err) + } + + if certVal, ok := ctx.Value(ctxKeyCertPath); ok { + if certPath, _ := certVal.(string); strings.TrimSpace(certPath) != "" { + if err := os.Remove(certPath); err != nil && !errors.Is(err, os.ErrNotExist) { + errs = append(errs, fmt.Errorf("failed to remove certificate file: %w", err)) + } + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil + }, + }, + }, + }) +} + +func getTestProjectName(ctx inttest.TestContext) (string, error) { + if val, ok := ctx.Value(ctxKeyProjectName); ok { + if name, _ := val.(string); strings.TrimSpace(name) != "" { + return name, nil + } + } + if name := strings.TrimSpace(ctx.Project()); name != "" { + return name, nil + } + return "", fmt.Errorf("test project name not available") +} + +type sharedProject struct { + name string + id string + initOnce sync.Once + cleanupOnce sync.Once + ctx inttest.TestContext +} + +func newProject(name string) *sharedProject { + return &sharedProject{name: name} +} + +func (p *sharedProject) Ensure(ctx inttest.TestContext) error { + var initErr error + p.initOnce.Do(func() { + projectID, err := provisionProject(ctx, p.name) + if err != nil { + initErr = err + return + } + p.id = projectID + p.ctx = ctx + }) + if initErr != nil { + return initErr + } + if strings.TrimSpace(p.id) == "" { + return fmt.Errorf("shared project initialization incomplete") + } + ctx.SetValue(ctxKeyProjectName, p.name) + ctx.SetValue(ctxKeyProjectID, p.id) + return nil +} + +func (p *sharedProject) Cleanup() error { + var cleanupErr error + p.cleanupOnce.Do(func() { + cleanupErr = deleteProjectByID(p.ctx, p.id) + }) + return cleanupErr +} + +func provisionProject(ctx inttest.TestContext, projectName string) (string, error) { + coreClient, err := ctx.ClientFactory().Core(ctx.Context(), ctx.Org()) + if err != nil { + return "", fmt.Errorf("failed to create core client: %w", err) + } + + teamProject := &core.TeamProject{ + Name: types.ToPtr(projectName), + } + vis := core.ProjectVisibilityValues.Private + teamProject.Visibility = &vis + + processID, err := resolveProcessTemplate(ctx, coreClient, "Agile") + if err != nil { + return "", err + } + + capabilities := map[string]map[string]string{ + "versioncontrol": { + "sourceControlType": "Git", + }, + "processTemplate": { + "templateTypeId": processID, + }, + } + teamProject.Capabilities = &capabilities + + opRef, err := coreClient.QueueCreateProject(ctx.Context(), core.QueueCreateProjectArgs{ + ProjectToCreate: teamProject, + }) + if err != nil { + return "", fmt.Errorf("failed to queue project creation: %w", err) + } + + if err := waitForOperation(ctx, opRef); err != nil { + return "", fmt.Errorf("project creation failed: %w", err) + } + + project, err := coreClient.GetProject(ctx.Context(), core.GetProjectArgs{ + ProjectId: types.ToPtr(projectName), + IncludeCapabilities: types.ToPtr(true), + }) + if err != nil { + return "", fmt.Errorf("failed to fetch created project %q: %w", projectName, err) + } + if project == nil || project.Id == nil { + return "", fmt.Errorf("project %q returned empty id", projectName) + } + + return project.Id.String(), nil +} + +func deleteProjectByID(ctx inttest.TestContext, projectID string) error { + projectID = strings.TrimSpace(projectID) + if projectID == "" { + return nil + } + context := context.Background() + coreClient, err := ctx.ClientFactory().Core(context, ctx.Org()) + if err != nil { + return fmt.Errorf("failed to create core client for cleanup: %w", err) + } + parsedProjectID, err := uuid.Parse(projectID) + if err != nil { + return fmt.Errorf("invalid project ID %q: %w", projectID, err) + } + op, err := coreClient.QueueDeleteProject(context, core.QueueDeleteProjectArgs{ + ProjectId: &parsedProjectID, + }) + if err != nil { + return fmt.Errorf("failed to queue project deletion: %w", err) + } + return waitForOperation(ctx, op) +} + +func deleteCreatedEndpoint(ctx inttest.TestContext) error { + endpointVal, _ := ctx.Value(ctxKeyEndpointID) + projectVal, _ := ctx.Value(ctxKeyEndpointProjectID) + if projectVal == nil { + projectVal, _ = ctx.Value(ctxKeyProjectID) + } + endpointID, _ := endpointVal.(string) + projectID, _ := projectVal.(string) + + if endpointID == "" || projectID == "" { + return nil + } + + client, err := ctx.ClientFactory().ServiceEndpoint(ctx.Context(), ctx.Org()) + if err != nil { + return fmt.Errorf("failed to create service endpoint client: %w", err) + } + parsedEndpointID, err := uuid.Parse(endpointID) + if err != nil { + return fmt.Errorf("invalid endpoint ID %q: %w", endpointID, err) + } + projectIDs := []string{projectID} + if err := client.DeleteServiceEndpoint(ctx.Context(), serviceendpoint.DeleteServiceEndpointArgs{ + EndpointId: &parsedEndpointID, + ProjectIds: &projectIDs, + }); err != nil { + return fmt.Errorf("failed to delete service endpoint: %w", err) + } + return nil +} + +func resolveProcessTemplate(ctx inttest.TestContext, client core.Client, preferred string) (string, error) { + processes, err := client.GetProcesses(ctx.Context(), core.GetProcessesArgs{}) + if err != nil { + return "", fmt.Errorf("failed to list processes: %w", err) + } + preferred = strings.TrimSpace(preferred) + var fallback string + for _, process := range *processes { + if process.Id == nil { + continue + } + if fallback == "" { + fallback = process.Id.String() + } + if process.Name != nil && preferred != "" && strings.EqualFold(*process.Name, preferred) { + return process.Id.String(), nil + } + } + if fallback == "" { + return "", fmt.Errorf("no processes available in organization %s", ctx.Org()) + } + return fallback, nil +} + +func waitForOperation(ctx inttest.TestContext, opRef *operations.OperationReference) error { + if opRef == nil { + return fmt.Errorf("operation reference is nil") + } + context := context.Background() + operationsClient, err := ctx.ClientFactory().Operations(context, ctx.Org()) + if err != nil { + return fmt.Errorf("failed to create operations client: %w", err) + } + _, err = azdo.PollOperationResult(context, operationsClient, opRef, 10*time.Minute) + return err +} diff --git a/internal/cmd/serviceendpoint/create/create.go b/internal/cmd/serviceendpoint/create/create.go new file mode 100644 index 00000000..6401d083 --- /dev/null +++ b/internal/cmd/serviceendpoint/create/create.go @@ -0,0 +1,18 @@ +package create + +import ( + "github.com/spf13/cobra" + "github.com/tmeckel/azdo-cli/internal/cmd/serviceendpoint/create/azurerm" + "github.com/tmeckel/azdo-cli/internal/cmd/util" +) + +func NewCmd(ctx util.CmdContext) *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create service connections", + } + + cmd.AddCommand(azurerm.NewCmd(ctx)) + + return cmd +} diff --git a/internal/cmd/serviceendpoint/serviceendpoint.go b/internal/cmd/serviceendpoint/serviceendpoint.go index 1eca7ceb..a5e9ea6d 100644 --- a/internal/cmd/serviceendpoint/serviceendpoint.go +++ b/internal/cmd/serviceendpoint/serviceendpoint.go @@ -3,6 +3,7 @@ package serviceendpoint import ( "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" + "github.com/tmeckel/azdo-cli/internal/cmd/serviceendpoint/create" "github.com/tmeckel/azdo-cli/internal/cmd/serviceendpoint/list" "github.com/tmeckel/azdo-cli/internal/cmd/util" ) @@ -16,15 +17,14 @@ func NewCmd(ctx util.CmdContext) *cobra.Command { `), Aliases: []string{ "service-endpoints", - "serviceendpoint", "serviceendpoints", "se", - "sep", }, GroupID: "core", } cmd.AddCommand(list.NewCmd(ctx)) + cmd.AddCommand(create.NewCmd(ctx)) return cmd } diff --git a/internal/cmd/serviceendpoint/shared/pipeline_permissions.go b/internal/cmd/serviceendpoint/shared/pipeline_permissions.go new file mode 100644 index 00000000..3b20d51c --- /dev/null +++ b/internal/cmd/serviceendpoint/shared/pipeline_permissions.go @@ -0,0 +1,77 @@ +package shared + +import ( + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinepermissions" + + "github.com/tmeckel/azdo-cli/internal/cmd/util" +) + +const ( + // EndpointResourceType is the Azure DevOps pipeline permission resource type for service connections. + EndpointResourceType = "endpoint" +) + +// CleanupFunc allows callers to provide optional rollback logic when granting permissions fails. +type CleanupFunc func() error + +// GrantAllPipelinesAccessToEndpoint allows every pipeline in the specified project to use the service endpoint. +func GrantAllPipelinesAccessToEndpoint( + cmdCtx util.CmdContext, + organization string, + projectID uuid.UUID, + endpointID uuid.UUID, + cleanup CleanupFunc, +) error { + if cmdCtx == nil { + return errors.New("nil command context") + } + if organization == "" { + return errors.New("organization is required") + } + if projectID == uuid.Nil { + return errors.New("project ID is required") + } + if endpointID == uuid.Nil { + return errors.New("endpoint ID is required") + } + + permissionsClient, err := cmdCtx.ClientFactory().PipelinePermissions(cmdCtx.Context(), organization) + if err != nil { + return runCleanup(fmt.Errorf("failed to initialize pipeline permissions client: %w", err), cleanup) + } + + allPipelines := true + projectIDStr := projectID.String() + resourceType := EndpointResourceType + resourceID := endpointID.String() + + _, err = permissionsClient.UpdatePipelinePermisionsForResource(cmdCtx.Context(), pipelinepermissions.UpdatePipelinePermisionsForResourceArgs{ + Project: &projectIDStr, + ResourceType: &resourceType, + ResourceId: &resourceID, + ResourceAuthorization: &pipelinepermissions.ResourcePipelinePermissions{ + AllPipelines: &pipelinepermissions.Permission{ + Authorized: &allPipelines, + }, + }, + }) + if err != nil { + return runCleanup(fmt.Errorf("failed to authorize endpoint %s for all pipelines: %w", endpointID, err), cleanup) + } + + return nil +} + +func runCleanup(opErr error, cleanup CleanupFunc) error { + if cleanup == nil { + return opErr + } + if err := cleanup(); err != nil { + return fmt.Errorf("%w (cleanup failed: %v)", opErr, err) + } + return opErr +} diff --git a/internal/mocks/connection_factory_mock.go b/internal/mocks/connection_factory_mock.go index 5a265858..987b57c2 100644 --- a/internal/mocks/connection_factory_mock.go +++ b/internal/mocks/connection_factory_mock.go @@ -23,6 +23,7 @@ import ( graph "github.com/microsoft/azure-devops-go-api/azuredevops/v7/graph" identity "github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity" operations "github.com/microsoft/azure-devops-go-api/azuredevops/v7/operations" + pipelinepermissions "github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinepermissions" security "github.com/microsoft/azure-devops-go-api/azuredevops/v7/security" serviceendpoint "github.com/microsoft/azure-devops-go-api/azuredevops/v7/serviceendpoint" taskagent "github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent" @@ -406,6 +407,21 @@ func (mr *MockClientFactoryMockRecorder) Operations(ctx, organization any) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Operations", reflect.TypeOf((*MockClientFactory)(nil).Operations), ctx, organization) } +// PipelinePermissions mocks base method. +func (m *MockClientFactory) PipelinePermissions(ctx context.Context, organization string) (pipelinepermissions.Client, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PipelinePermissions", ctx, organization) + ret0, _ := ret[0].(pipelinepermissions.Client) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PipelinePermissions indicates an expected call of PipelinePermissions. +func (mr *MockClientFactoryMockRecorder) PipelinePermissions(ctx, organization any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PipelinePermissions", reflect.TypeOf((*MockClientFactory)(nil).PipelinePermissions), ctx, organization) +} + // Security mocks base method. func (m *MockClientFactory) Security(ctx context.Context, organization string) (security.Client, error) { m.ctrl.T.Helper() diff --git a/internal/mocks/pipelinepermissions_client_mock.go b/internal/mocks/pipelinepermissions_client_mock.go new file mode 100644 index 00000000..c2c99478 --- /dev/null +++ b/internal/mocks/pipelinepermissions_client_mock.go @@ -0,0 +1,87 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinepermissions (interfaces: Client) +// +// Generated by this command: +// +// mockgen -package=mocks -destination internal/mocks/pipelinepermissions_client_mock.go -mock_names Client=MockPipelinePermissionsClient github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinepermissions Client +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + pipelinepermissions "github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinepermissions" + gomock "go.uber.org/mock/gomock" +) + +// MockPipelinePermissionsClient is a mock of Client interface. +type MockPipelinePermissionsClient struct { + ctrl *gomock.Controller + recorder *MockPipelinePermissionsClientMockRecorder + isgomock struct{} +} + +// MockPipelinePermissionsClientMockRecorder is the mock recorder for MockPipelinePermissionsClient. +type MockPipelinePermissionsClientMockRecorder struct { + mock *MockPipelinePermissionsClient +} + +// NewMockPipelinePermissionsClient creates a new mock instance. +func NewMockPipelinePermissionsClient(ctrl *gomock.Controller) *MockPipelinePermissionsClient { + mock := &MockPipelinePermissionsClient{ctrl: ctrl} + mock.recorder = &MockPipelinePermissionsClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPipelinePermissionsClient) EXPECT() *MockPipelinePermissionsClientMockRecorder { + return m.recorder +} + +// GetPipelinePermissionsForResource mocks base method. +func (m *MockPipelinePermissionsClient) GetPipelinePermissionsForResource(arg0 context.Context, arg1 pipelinepermissions.GetPipelinePermissionsForResourceArgs) (*pipelinepermissions.ResourcePipelinePermissions, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPipelinePermissionsForResource", arg0, arg1) + ret0, _ := ret[0].(*pipelinepermissions.ResourcePipelinePermissions) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPipelinePermissionsForResource indicates an expected call of GetPipelinePermissionsForResource. +func (mr *MockPipelinePermissionsClientMockRecorder) GetPipelinePermissionsForResource(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPipelinePermissionsForResource", reflect.TypeOf((*MockPipelinePermissionsClient)(nil).GetPipelinePermissionsForResource), arg0, arg1) +} + +// UpdatePipelinePermisionsForResource mocks base method. +func (m *MockPipelinePermissionsClient) UpdatePipelinePermisionsForResource(arg0 context.Context, arg1 pipelinepermissions.UpdatePipelinePermisionsForResourceArgs) (*pipelinepermissions.ResourcePipelinePermissions, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdatePipelinePermisionsForResource", arg0, arg1) + ret0, _ := ret[0].(*pipelinepermissions.ResourcePipelinePermissions) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdatePipelinePermisionsForResource indicates an expected call of UpdatePipelinePermisionsForResource. +func (mr *MockPipelinePermissionsClientMockRecorder) UpdatePipelinePermisionsForResource(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePipelinePermisionsForResource", reflect.TypeOf((*MockPipelinePermissionsClient)(nil).UpdatePipelinePermisionsForResource), arg0, arg1) +} + +// UpdatePipelinePermisionsForResources mocks base method. +func (m *MockPipelinePermissionsClient) UpdatePipelinePermisionsForResources(arg0 context.Context, arg1 pipelinepermissions.UpdatePipelinePermisionsForResourcesArgs) (*[]pipelinepermissions.ResourcePipelinePermissions, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdatePipelinePermisionsForResources", arg0, arg1) + ret0, _ := ret[0].(*[]pipelinepermissions.ResourcePipelinePermissions) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdatePipelinePermisionsForResources indicates an expected call of UpdatePipelinePermisionsForResources. +func (mr *MockPipelinePermissionsClientMockRecorder) UpdatePipelinePermisionsForResources(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePipelinePermisionsForResources", reflect.TypeOf((*MockPipelinePermissionsClient)(nil).UpdatePipelinePermisionsForResources), arg0, arg1) +} diff --git a/internal/test/acc_helpers.go b/internal/test/acc_helpers.go index ecffc5c6..95118327 100644 --- a/internal/test/acc_helpers.go +++ b/internal/test/acc_helpers.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "os" + "strings" + "sync" "testing" "time" @@ -21,10 +23,10 @@ import ( const ( accToggleEnv = "AZDO_ACC_TEST" accOrgEnv = "AZDO_ACC_ORG" - accOrgURLEnv = "AZDO_ACC_ORG_URL" accPATEnv = "AZDO_ACC_PAT" accTimeoutSeconds = 60 accTimeoutEnv = "AZDO_ACC_TIMEOUT" + accProjectEnv = "AZDO_ACC_PROJECT" ) // nullPrinter is a no-op implementation of printer.Printer used for acceptance @@ -57,14 +59,18 @@ type TestContext interface { Org() string OrgUrl() string PAT() string + Project() string + SetValue(key, value any) + Value(key any) (any, bool) } type acceptanceCmdContext struct { baseCtx context.Context ios *iostreams.IOStreams - cfg config.Config connFactory azdo.ConnectionFactory + cfg config.Config clientFactory azdo.ClientFactory + prompter prompter.Prompter } var _ util.CmdContext = (*acceptanceCmdContext)(nil) @@ -74,7 +80,10 @@ func (a *acceptanceCmdContext) RepoContext() util.RepoContext { retu func (a *acceptanceCmdContext) ConnectionFactory() azdo.ConnectionFactory { return a.connFactory } func (a *acceptanceCmdContext) ClientFactory() azdo.ClientFactory { return a.clientFactory } func (a *acceptanceCmdContext) Prompter() (prompter.Prompter, error) { - return nil, fmt.Errorf("not implemented") + if a.prompter == nil { + a.prompter = &stubPrompter{} + } + return a.prompter, nil } func (a *acceptanceCmdContext) Config() (config.Config, error) { return a.cfg, nil } func (a *acceptanceCmdContext) IOStreams() (*iostreams.IOStreams, error) { return a.ios, nil } @@ -83,9 +92,11 @@ func (a *acceptanceCmdContext) Printer(string) (printer.Printer, error) { } type testContext struct { - org string - orgURL string - pat string + org string + orgURL string + pat string + project string + data sync.Map util.CmdContext } @@ -104,19 +115,36 @@ func (tc *testContext) PAT() string { return tc.pat } +func (tc *testContext) Project() string { + return tc.project +} + +func (tc *testContext) SetValue(key, value any) { + if key == nil { + return + } + tc.data.Store(key, value) +} + +func (tc *testContext) Value(key any) (any, bool) { + if key == nil { + return nil, false + } + return tc.data.Load(key) +} + // Precheck and context builder func newTestContext(t *testing.T) TestContext { org := os.Getenv(accOrgEnv) - orgurl := os.Getenv(accOrgURLEnv) pat := os.Getenv(accPATEnv) + project := os.Getenv(accProjectEnv) if org == "" || pat == "" { t.Fatalf("missing acceptance env variables: %q, %q", accOrgEnv, accPATEnv) } - if orgurl == "" { - orgurl = fmt.Sprintf("https://dev.azure.com/%s", org) - } + orgurl := fmt.Sprintf("https://dev.azure.com/%s", org) + // Build a safe YAML configuration using marshaling instead of fmt.Sprintf interpolation. // This avoids accidental YAML-breaking characters in env values. cfgData := map[string]any{ @@ -190,19 +218,55 @@ func newTestContext(t *testing.T) TestContext { t.Cleanup(cancel) return &testContext{ - org: org, - orgURL: orgurl, - pat: pat, + org: org, + orgURL: orgurl, + pat: pat, + project: strings.TrimSpace(project), CmdContext: &acceptanceCmdContext{ baseCtx: baseCtx, ios: ios, cfg: cfg, connFactory: connFactory, clientFactory: clientFactory, + prompter: &stubPrompter{}, }, } } +type stubPrompter struct{} + +func (stubPrompter) Select(string, string, []string) (int, error) { + return 0, fmt.Errorf("interactive prompts are disabled in acceptance tests") +} + +func (stubPrompter) MultiSelect(string, []string, []string) ([]int, error) { + return nil, fmt.Errorf("interactive prompts are disabled in acceptance tests") +} + +func (stubPrompter) Input(string, string) (string, error) { + return "", fmt.Errorf("interactive prompts are disabled in acceptance tests") +} + +func (stubPrompter) InputOrganizationName() (string, error) { + return "", fmt.Errorf("interactive prompts are disabled in acceptance tests") +} + +func (stubPrompter) Password(string) (string, error) { + return "", fmt.Errorf("interactive prompts are disabled in acceptance tests") +} + +func (stubPrompter) AuthToken() (string, error) { + return "", fmt.Errorf("interactive prompts are disabled in acceptance tests") +} + +func (stubPrompter) Confirm(string, bool) (bool, error) { + return true, nil +} + +func (stubPrompter) ConfirmDeletion(string) error { + return nil +} + // Compact acc runner type Step struct { PreRun func(TestContext) error diff --git a/internal/test/acc_helpers_test.go b/internal/test/acc_helpers_test.go index 7590cd17..98841d11 100644 --- a/internal/test/acc_helpers_test.go +++ b/internal/test/acc_helpers_test.go @@ -59,12 +59,12 @@ func TestAcceptanceCmdContext_Printer(t *testing.T) { func TestTestContext_OrgFields(t *testing.T) { // Prepare unique values and set them with t.Setenv so they're restored automatically. org := "example-org-env" - orgURL := "https://example.org/env" + orgURL := "https://dev.azure.com/example-org-env" pat := "env-secret-pat" t.Setenv(accOrgEnv, org) - t.Setenv(accOrgURLEnv, orgURL) t.Setenv(accPATEnv, pat) + t.Setenv(accProjectEnv, "proj-value") // newTestContext validates env vars and returns a TestContext built from them. tc := newTestContext(t) @@ -79,6 +79,9 @@ func TestTestContext_OrgFields(t *testing.T) { if got := tc.PAT(); got != pat { t.Fatalf("PAT() = %q, want %q", got, pat) } + if got := tc.Project(); got != "proj-value" { + t.Fatalf("Project() = %q, want proj-value", got) + } } // TestNewTestContext_Config ensures that newTestContext builds a config from @@ -86,13 +89,13 @@ func TestTestContext_OrgFields(t *testing.T) { func TestNewTestContext_Config(t *testing.T) { // Prepare unique values org := "test-org-for-unit" - orgURL := "https://dev.azure.test/test-org-for-unit" + orgURL := "https://dev.azure.com/test-org-for-unit" pat := "TEST_PAT_VALUE" // Use t.Setenv so the testing framework will automatically restore values. t.Setenv(accOrgEnv, org) - t.Setenv(accOrgURLEnv, orgURL) t.Setenv(accPATEnv, pat) + t.Setenv(accProjectEnv, "proj-alpha") // Call newTestContext which will build a config from the env vars. tc := newTestContext(t) @@ -114,6 +117,29 @@ func TestNewTestContext_Config(t *testing.T) { } } +// TestTestContextValueStore ensures SetValue/Value share data between steps. +func TestTestContextValueStore(t *testing.T) { + t.Setenv(accOrgEnv, "org") + t.Setenv(accPATEnv, "pat") + t.Setenv(accProjectEnv, "project") + + tc := newTestContext(t) + + tc.SetValue("key", 42) + if v, ok := tc.Value("missing"); ok || v != nil { + t.Fatalf("Value for missing key should be absent, got %v", v) + } + if v, ok := tc.Value("key"); !ok || v.(int) != 42 { + t.Fatalf("Value for key mismatch, got %v", v) + } + + // Overwrite and ensure latest wins + tc.SetValue("key", 99) + if v, ok := tc.Value("key"); !ok || v.(int) != 99 { + t.Fatalf("Value for key overwrite mismatch, got %v", v) + } +} + // TestRunStep_PostRunAlwaysRuns verifies that PostRun runs even when Run returns an error. func TestRunStep_PostRunAlwaysRuns_RunFails(t *testing.T) { called := struct { diff --git a/internal/test/files.go b/internal/test/files.go new file mode 100644 index 00000000..5857eb3c --- /dev/null +++ b/internal/test/files.go @@ -0,0 +1,75 @@ +package test + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "testing" +) + +// WriteTestFile writes the provided contents from the reader into a newly created +// random-named file inside t.TempDir(). The created file has restrictive permissions (0600). +// It returns the full path to the created file or an error. +func WriteTestFile(t *testing.T, contents io.Reader) (string, error) { + if t == nil { + return "", fmt.Errorf("t cannot be nil") + } + if contents == nil { + return "", fmt.Errorf("contents cannot be nil") + } + + dir := t.TempDir() + + // generate a short random filename + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate filename: %w", err) + } + name := hex.EncodeToString(b) + path := filepath.Join(dir, name) + + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return "", fmt.Errorf("failed to create file %s: %w", path, err) + } + defer f.Close() + + if _, err := io.Copy(f, contents); err != nil { + return "", fmt.Errorf("failed to write file %s: %w", path, err) + } + + return path, nil +} + +// WriteTestFileWithName writes the provided contents from the reader into a file with the specified +// name inside t.TempDir(). The created file has restrictive permissions (0600). +// It returns the full path to the created file or an error. +func WriteTestFileWithName(t *testing.T, filename string, contents io.Reader) (string, error) { + if t == nil { + return "", fmt.Errorf("t cannot be nil") + } + if filename == "" { + return "", fmt.Errorf("filename cannot be empty") + } + if contents == nil { + return "", fmt.Errorf("contents cannot be nil") + } + + dir := t.TempDir() + path := filepath.Join(dir, filename) + + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return "", fmt.Errorf("failed to create file %s: %w", path, err) + } + defer f.Close() + + if _, err := io.Copy(f, contents); err != nil { + return "", fmt.Errorf("failed to write file %s: %w", path, err) + } + + return path, nil +} diff --git a/internal/test/files_test.go b/internal/test/files_test.go new file mode 100644 index 00000000..c0669d21 --- /dev/null +++ b/internal/test/files_test.go @@ -0,0 +1,99 @@ +package test + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteTestFileCreatesFile(t *testing.T) { + content := "hello world" + + path, err := WriteTestFile(t, strings.NewReader(content)) + if err != nil { + t.Fatalf("WriteTestFile returned error: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + if string(data) != content { + t.Fatalf("file content mismatch: got %q", string(data)) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat error: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("expected perms 0600, got %o", perm) + } + // ensure the file is within the test temp dir + if dir := filepath.Dir(path); !strings.HasPrefix(dir, t.TempDir()) { + t.Logf("created file path: %s", path) + } +} + +func TestWriteTestFileNilContents(t *testing.T) { + if _, err := WriteTestFile(t, nil); err == nil { + t.Fatalf("expected error for nil contents") + } +} + +func TestWriteTestFileNilTestingT(t *testing.T) { + if _, err := WriteTestFile(nil, strings.NewReader("content")); err == nil { + t.Fatalf("expected error for nil testing.T") + } +} + +func TestWriteTestFileWithNameCreatesFile(t *testing.T) { + content := "hello world" + filename := "test-file.txt" + + path, err := WriteTestFileWithName(t, filename, strings.NewReader(content)) + if err != nil { + t.Fatalf("WriteTestFileWithName returned error: %v", err) + } + + // Check that the path ends with the requested filename + if filepath.Base(path) != filename { + t.Fatalf("expected filename %q, got %q", filename, filepath.Base(path)) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + if string(data) != content { + t.Fatalf("file content mismatch: got %q", string(data)) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat error: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("expected perms 0600, got %o", perm) + } + // ensure the file is within the test temp dir + if dir := filepath.Dir(path); !strings.HasPrefix(dir, t.TempDir()) { + t.Logf("created file path: %s", path) + } +} + +func TestWriteTestFileWithNameEmptyFilename(t *testing.T) { + if _, err := WriteTestFileWithName(t, "", strings.NewReader("content")); err == nil { + t.Fatalf("expected error for empty filename") + } +} + +func TestWriteTestFileWithNameNilContents(t *testing.T) { + if _, err := WriteTestFileWithName(t, "test.txt", nil); err == nil { + t.Fatalf("expected error for nil contents") + } +} + +func TestWriteTestFileWithNameNilTestingT(t *testing.T) { + if _, err := WriteTestFileWithName(nil, "test.txt", strings.NewReader("content")); err == nil { + t.Fatalf("expected error for nil testing.T") + } +} diff --git a/internal/types/slices.go b/internal/types/slices.go index 8c61006e..64073bfe 100644 --- a/internal/types/slices.go +++ b/internal/types/slices.go @@ -46,6 +46,24 @@ func UniqueFunc[T any](items []T, cmp func(T, T) bool) []T { }) } +func MapSlice[S any, T any](items []S, mapper func(S) T) []T { + if len(items) == 0 { + return []T{} + } + result := make([]T, len(items)) + for i, item := range items { + result[i] = mapper(item) + } + return result +} + +func MapSlicePtr[S any, T any](items *[]S, mapper func(S) T) []T { + if items == nil { + return []T{} + } + return MapSlice(*items, mapper) +} + // func UniqueErrors[T error](items []T) []T { // return UniqueFunc(items, func(e1 T, e2 T) bool { // return e1.Error() == e2.Error() diff --git a/scripts/generate_mocks.sh b/scripts/generate_mocks.sh index 7d19502a..ef783513 100644 --- a/scripts/generate_mocks.sh +++ b/scripts/generate_mocks.sh @@ -65,6 +65,12 @@ mockgen \ -mock_names Client=MockTaskAgentClient \ github.com/microsoft/azure-devops-go-api/azuredevops/v7/taskagent Client +echo "Generating Azure DevOps PipelinePermissions client mock..." +mockgen \ + -package=mocks -destination internal/mocks/pipelinepermissions_client_mock.go \ + -mock_names Client=MockPipelinePermissionsClient \ + github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinepermissions Client + echo "Generating Repository mock..." mockgen -source internal/azdo/repo.go \ -package=mocks -destination internal/mocks/repository_mock.go diff --git a/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinepermissions/client.go b/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinepermissions/client.go new file mode 100644 index 00000000..7916109d --- /dev/null +++ b/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinepermissions/client.go @@ -0,0 +1,160 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// -------------------------------------------------------------------------------------------- +// Generated file, DO NOT EDIT +// Changes may cause incorrect behavior and will be lost if the code is regenerated. +// -------------------------------------------------------------------------------------------- + +package pipelinepermissions + +import ( + "bytes" + "context" + "encoding/json" + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "net/http" +) + +var ResourceAreaId, _ = uuid.Parse("a81a0441-de52-4000-aa15-ff0e07bfbbaa") + +type Client interface { + // [Preview API] Given a ResourceType and ResourceId, returns authorized definitions for that resource. + GetPipelinePermissionsForResource(context.Context, GetPipelinePermissionsForResourceArgs) (*ResourcePipelinePermissions, error) + // [Preview API] Authorizes/Unauthorizes a list of definitions for a given resource. + UpdatePipelinePermisionsForResource(context.Context, UpdatePipelinePermisionsForResourceArgs) (*ResourcePipelinePermissions, error) + // [Preview API] Batch API to authorize/unauthorize a list of definitions for a multiple resources. + UpdatePipelinePermisionsForResources(context.Context, UpdatePipelinePermisionsForResourcesArgs) (*[]ResourcePipelinePermissions, error) +} + +type ClientImpl struct { + Client azuredevops.Client +} + +func NewClient(ctx context.Context, connection *azuredevops.Connection) (Client, error) { + client, err := connection.GetClientByResourceAreaId(ctx, ResourceAreaId) + if err != nil { + return nil, err + } + return &ClientImpl{ + Client: *client, + }, nil +} + +// [Preview API] Given a ResourceType and ResourceId, returns authorized definitions for that resource. +func (client *ClientImpl) GetPipelinePermissionsForResource(ctx context.Context, args GetPipelinePermissionsForResourceArgs) (*ResourcePipelinePermissions, error) { + routeValues := make(map[string]string) + if args.Project == nil || *args.Project == "" { + return nil, &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.Project"} + } + routeValues["project"] = *args.Project + if args.ResourceType == nil || *args.ResourceType == "" { + return nil, &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.ResourceType"} + } + routeValues["resourceType"] = *args.ResourceType + if args.ResourceId == nil || *args.ResourceId == "" { + return nil, &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.ResourceId"} + } + routeValues["resourceId"] = *args.ResourceId + + locationId, _ := uuid.Parse("b5b9a4a4-e6cd-4096-853c-ab7d8b0c4eb2") + resp, err := client.Client.Send(ctx, http.MethodGet, locationId, "7.1-preview.1", routeValues, nil, nil, "", "application/json", nil) + if err != nil { + return nil, err + } + + var responseValue ResourcePipelinePermissions + err = client.Client.UnmarshalBody(resp, &responseValue) + return &responseValue, err +} + +// Arguments for the GetPipelinePermissionsForResource function +type GetPipelinePermissionsForResourceArgs struct { + // (required) Project ID or project name + Project *string + // (required) + ResourceType *string + // (required) + ResourceId *string +} + +// [Preview API] Authorizes/Unauthorizes a list of definitions for a given resource. +func (client *ClientImpl) UpdatePipelinePermisionsForResource(ctx context.Context, args UpdatePipelinePermisionsForResourceArgs) (*ResourcePipelinePermissions, error) { + if args.ResourceAuthorization == nil { + return nil, &azuredevops.ArgumentNilError{ArgumentName: "args.ResourceAuthorization"} + } + routeValues := make(map[string]string) + if args.Project == nil || *args.Project == "" { + return nil, &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.Project"} + } + routeValues["project"] = *args.Project + if args.ResourceType == nil || *args.ResourceType == "" { + return nil, &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.ResourceType"} + } + routeValues["resourceType"] = *args.ResourceType + if args.ResourceId == nil || *args.ResourceId == "" { + return nil, &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.ResourceId"} + } + routeValues["resourceId"] = *args.ResourceId + + body, marshalErr := json.Marshal(*args.ResourceAuthorization) + if marshalErr != nil { + return nil, marshalErr + } + locationId, _ := uuid.Parse("b5b9a4a4-e6cd-4096-853c-ab7d8b0c4eb2") + resp, err := client.Client.Send(ctx, http.MethodPatch, locationId, "7.1-preview.1", routeValues, nil, bytes.NewReader(body), "application/json", "application/json", nil) + if err != nil { + return nil, err + } + + var responseValue ResourcePipelinePermissions + err = client.Client.UnmarshalBody(resp, &responseValue) + return &responseValue, err +} + +// Arguments for the UpdatePipelinePermisionsForResource function +type UpdatePipelinePermisionsForResourceArgs struct { + // (required) + ResourceAuthorization *ResourcePipelinePermissions + // (required) Project ID or project name + Project *string + // (required) + ResourceType *string + // (required) + ResourceId *string +} + +// [Preview API] Batch API to authorize/unauthorize a list of definitions for a multiple resources. +func (client *ClientImpl) UpdatePipelinePermisionsForResources(ctx context.Context, args UpdatePipelinePermisionsForResourcesArgs) (*[]ResourcePipelinePermissions, error) { + if args.ResourceAuthorizations == nil { + return nil, &azuredevops.ArgumentNilError{ArgumentName: "args.ResourceAuthorizations"} + } + routeValues := make(map[string]string) + if args.Project == nil || *args.Project == "" { + return nil, &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.Project"} + } + routeValues["project"] = *args.Project + + body, marshalErr := json.Marshal(*args.ResourceAuthorizations) + if marshalErr != nil { + return nil, marshalErr + } + locationId, _ := uuid.Parse("b5b9a4a4-e6cd-4096-853c-ab7d8b0c4eb2") + resp, err := client.Client.Send(ctx, http.MethodPatch, locationId, "7.1-preview.1", routeValues, nil, bytes.NewReader(body), "application/json", "application/json", nil) + if err != nil { + return nil, err + } + + var responseValue []ResourcePipelinePermissions + err = client.Client.UnmarshalCollectionBody(resp, &responseValue) + return &responseValue, err +} + +// Arguments for the UpdatePipelinePermisionsForResources function +type UpdatePipelinePermisionsForResourcesArgs struct { + // (required) + ResourceAuthorizations *[]ResourcePipelinePermissions + // (required) Project ID or project name + Project *string +} diff --git a/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinepermissions/models.go b/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinepermissions/models.go new file mode 100644 index 00000000..3aa31f3c --- /dev/null +++ b/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinepermissions/models.go @@ -0,0 +1,48 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// -------------------------------------------------------------------------------------------- +// Generated file, DO NOT EDIT +// Changes may cause incorrect behavior and will be lost if the code is regenerated. +// -------------------------------------------------------------------------------------------- + +package pipelinepermissions + +import ( + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelineschecks" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" +) + +type Permission struct { + Authorized *bool `json:"authorized,omitempty"` + AuthorizedBy *webapi.IdentityRef `json:"authorizedBy,omitempty"` + AuthorizedOn *azuredevops.Time `json:"authorizedOn,omitempty"` +} + +type PipelinePermission struct { + Authorized *bool `json:"authorized,omitempty"` + AuthorizedBy *webapi.IdentityRef `json:"authorizedBy,omitempty"` + AuthorizedOn *azuredevops.Time `json:"authorizedOn,omitempty"` + Id *int `json:"id,omitempty"` +} + +type PipelineProcessResources struct { + Resources *[]PipelineResourceReference `json:"resources,omitempty"` +} + +type PipelineResourceReference struct { + Authorized *bool `json:"authorized,omitempty"` + AuthorizedBy *uuid.UUID `json:"authorizedBy,omitempty"` + AuthorizedOn *azuredevops.Time `json:"authorizedOn,omitempty"` + DefinitionId *int `json:"definitionId,omitempty"` + Id *string `json:"id,omitempty"` + Type *string `json:"type,omitempty"` +} + +type ResourcePipelinePermissions struct { + AllPipelines *Permission `json:"allPipelines,omitempty"` + Pipelines *[]PipelinePermission `json:"pipelines,omitempty"` + Resource *pipelineschecks.Resource `json:"resource,omitempty"` +} diff --git a/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinesapproval/client.go b/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinesapproval/client.go new file mode 100644 index 00000000..b3d27fef --- /dev/null +++ b/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinesapproval/client.go @@ -0,0 +1,157 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// -------------------------------------------------------------------------------------------- +// Generated file, DO NOT EDIT +// Changes may cause incorrect behavior and will be lost if the code is regenerated. +// -------------------------------------------------------------------------------------------- + +package pipelinesapproval + +import ( + "bytes" + "context" + "encoding/json" + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "net/http" + "net/url" + "strings" +) + +var ResourceAreaId, _ = uuid.Parse("5b55a9b6-2e0f-40d7-829d-3741d2b8c4e4") + +type Client interface { + // [Preview API] Get an approval. + GetApproval(context.Context, GetApprovalArgs) (*Approval, error) + // [Preview API] List Approvals. This can be used to get a set of pending approvals in a pipeline, on an user or for a resource.. + QueryApprovals(context.Context, QueryApprovalsArgs) (*[]Approval, error) + // [Preview API] Update approvals. + UpdateApprovals(context.Context, UpdateApprovalsArgs) (*[]Approval, error) +} + +type ClientImpl struct { + Client azuredevops.Client +} + +func NewClient(ctx context.Context, connection *azuredevops.Connection) (Client, error) { + client, err := connection.GetClientByResourceAreaId(ctx, ResourceAreaId) + if err != nil { + return nil, err + } + return &ClientImpl{ + Client: *client, + }, nil +} + +// [Preview API] Get an approval. +func (client *ClientImpl) GetApproval(ctx context.Context, args GetApprovalArgs) (*Approval, error) { + routeValues := make(map[string]string) + if args.Project == nil || *args.Project == "" { + return nil, &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.Project"} + } + routeValues["project"] = *args.Project + if args.ApprovalId == nil { + return nil, &azuredevops.ArgumentNilError{ArgumentName: "args.ApprovalId"} + } + routeValues["approvalId"] = (*args.ApprovalId).String() + + queryParams := url.Values{} + if args.Expand != nil { + queryParams.Add("$expand", string(*args.Expand)) + } + locationId, _ := uuid.Parse("37794717-f36f-4d78-b2bf-4dc30d0cfbcd") + resp, err := client.Client.Send(ctx, http.MethodGet, locationId, "7.1-preview.1", routeValues, queryParams, nil, "", "application/json", nil) + if err != nil { + return nil, err + } + + var responseValue Approval + err = client.Client.UnmarshalBody(resp, &responseValue) + return &responseValue, err +} + +// Arguments for the GetApproval function +type GetApprovalArgs struct { + // (required) Project ID or project name + Project *string + // (required) Id of the approval. + ApprovalId *uuid.UUID + // (optional) + Expand *ApprovalDetailsExpandParameter +} + +// [Preview API] List Approvals. This can be used to get a set of pending approvals in a pipeline, on an user or for a resource.. +func (client *ClientImpl) QueryApprovals(ctx context.Context, args QueryApprovalsArgs) (*[]Approval, error) { + routeValues := make(map[string]string) + if args.Project == nil || *args.Project == "" { + return nil, &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.Project"} + } + routeValues["project"] = *args.Project + + queryParams := url.Values{} + if args.ApprovalIds != nil { + var stringList []string + for _, item := range *args.ApprovalIds { + stringList = append(stringList, item.String()) + } + listAsString := strings.Join((stringList)[:], ",") + queryParams.Add("approvalIds", listAsString) + } + if args.Expand != nil { + queryParams.Add("$expand", string(*args.Expand)) + } + locationId, _ := uuid.Parse("37794717-f36f-4d78-b2bf-4dc30d0cfbcd") + resp, err := client.Client.Send(ctx, http.MethodGet, locationId, "7.1-preview.1", routeValues, queryParams, nil, "", "application/json", nil) + if err != nil { + return nil, err + } + + var responseValue []Approval + err = client.Client.UnmarshalCollectionBody(resp, &responseValue) + return &responseValue, err +} + +// Arguments for the QueryApprovals function +type QueryApprovalsArgs struct { + // (required) Project ID or project name + Project *string + // (optional) + ApprovalIds *[]uuid.UUID + // (optional) + Expand *ApprovalDetailsExpandParameter +} + +// [Preview API] Update approvals. +func (client *ClientImpl) UpdateApprovals(ctx context.Context, args UpdateApprovalsArgs) (*[]Approval, error) { + if args.UpdateParameters == nil { + return nil, &azuredevops.ArgumentNilError{ArgumentName: "args.UpdateParameters"} + } + routeValues := make(map[string]string) + if args.Project == nil || *args.Project == "" { + return nil, &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.Project"} + } + routeValues["project"] = *args.Project + + body, marshalErr := json.Marshal(*args.UpdateParameters) + if marshalErr != nil { + return nil, marshalErr + } + locationId, _ := uuid.Parse("37794717-f36f-4d78-b2bf-4dc30d0cfbcd") + resp, err := client.Client.Send(ctx, http.MethodPatch, locationId, "7.1-preview.1", routeValues, nil, bytes.NewReader(body), "application/json", "application/json", nil) + if err != nil { + return nil, err + } + + var responseValue []Approval + err = client.Client.UnmarshalCollectionBody(resp, &responseValue) + return &responseValue, err +} + +// Arguments for the UpdateApprovals function +type UpdateApprovalsArgs struct { + // (required) + UpdateParameters *[]ApprovalUpdateParameters + // (required) Project ID or project name + Project *string +} diff --git a/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinesapproval/models.go b/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinesapproval/models.go new file mode 100644 index 00000000..36f6e6b7 --- /dev/null +++ b/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinesapproval/models.go @@ -0,0 +1,232 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// -------------------------------------------------------------------------------------------- +// Generated file, DO NOT EDIT +// Changes may cause incorrect behavior and will be lost if the code is regenerated. +// -------------------------------------------------------------------------------------------- + +package pipelinesapproval + +import ( + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" +) + +type Approval struct { + // /// Gets the links to access the approval object. + Links interface{} `json:"_links,omitempty"` + // Identities which are not allowed to approve. + BlockedApprovers *[]webapi.IdentityRef `json:"blockedApprovers,omitempty"` + // Date on which approval got created. + CreatedOn *azuredevops.Time `json:"createdOn,omitempty"` + // Order in which approvers will be actionable. + ExecutionOrder *ApprovalExecutionOrder `json:"executionOrder,omitempty"` + // Unique identifier of the approval. + Id *uuid.UUID `json:"id,omitempty"` + // Instructions for the approvers. + Instructions *string `json:"instructions,omitempty"` + // Date on which approval was last modified. + LastModifiedOn *azuredevops.Time `json:"lastModifiedOn,omitempty"` + // Minimum number of approvers that should approve for the entire approval to be considered approved. + MinRequiredApprovers *int `json:"minRequiredApprovers,omitempty"` + // Current user permissions for approval object. + Permissions *ApprovalPermissions `json:"permissions,omitempty"` + // Overall status of the approval. + Status *ApprovalStatus `json:"status,omitempty"` + // List of steps associated with the approval. + Steps *[]ApprovalStep `json:"steps,omitempty"` +} + +type ApprovalCompletedNotificationEvent struct { + Approval *Approval `json:"approval,omitempty"` + ProjectId *uuid.UUID `json:"projectId,omitempty"` +} + +// Config to create a new approval. +type ApprovalConfig struct { + // Ordered list of approvers. + Approvers *[]webapi.IdentityRef `json:"approvers,omitempty"` + // Identities which are not allowed to approve. + BlockedApprovers *[]webapi.IdentityRef `json:"blockedApprovers,omitempty"` + // Order in which approvers will be actionable. + ExecutionOrder *ApprovalExecutionOrder `json:"executionOrder,omitempty"` + // Instructions for the approver. + Instructions *string `json:"instructions,omitempty"` + // Minimum number of approvers that should approve for the entire approval to be considered approved. Defaults to all. + MinRequiredApprovers *int `json:"minRequiredApprovers,omitempty"` +} + +// Config to create a new approval. +type ApprovalConfigSettings struct { + // Ordered list of approvers. + Approvers *[]webapi.IdentityRef `json:"approvers,omitempty"` + // Identities which are not allowed to approve. + BlockedApprovers *[]webapi.IdentityRef `json:"blockedApprovers,omitempty"` + // Order in which approvers will be actionable. + ExecutionOrder *ApprovalExecutionOrder `json:"executionOrder,omitempty"` + // Instructions for the approver. + Instructions *string `json:"instructions,omitempty"` + // Minimum number of approvers that should approve for the entire approval to be considered approved. Defaults to all. + MinRequiredApprovers *int `json:"minRequiredApprovers,omitempty"` + // Determines whether check requester can approve the check. + RequesterCannotBeApprover *bool `json:"requesterCannotBeApprover,omitempty"` +} + +// [Flags] +type ApprovalDetailsExpandParameter string + +type approvalDetailsExpandParameterValuesType struct { + None ApprovalDetailsExpandParameter + Steps ApprovalDetailsExpandParameter + Permissions ApprovalDetailsExpandParameter +} + +var ApprovalDetailsExpandParameterValues = approvalDetailsExpandParameterValuesType{ + None: "none", + Steps: "steps", + Permissions: "permissions", +} + +type ApprovalExecutionOrder string + +type approvalExecutionOrderValuesType struct { + AnyOrder ApprovalExecutionOrder + InSequence ApprovalExecutionOrder +} + +var ApprovalExecutionOrderValues = approvalExecutionOrderValuesType{ + // Indicates that the approvers can approve in any order. + AnyOrder: "anyOrder", + // Indicates that the approvers can only approve in a sequential order(Order in which they were assigned). + InSequence: "inSequence", +} + +// Data for notification base class for approval events. +type ApprovalNotificationEventBase struct { + Approval *Approval `json:"approval,omitempty"` + ProjectId *uuid.UUID `json:"projectId,omitempty"` +} + +// [Flags] +type ApprovalPermissions string + +type approvalPermissionsValuesType struct { + None ApprovalPermissions + View ApprovalPermissions + Update ApprovalPermissions + Reassign ApprovalPermissions + ResourceAdmin ApprovalPermissions + QueueBuild ApprovalPermissions +} + +var ApprovalPermissionsValues = approvalPermissionsValuesType{ + None: "none", + View: "view", + Update: "update", + Reassign: "reassign", + ResourceAdmin: "resourceAdmin", + QueueBuild: "queueBuild", +} + +// Request to create a new approval. +type ApprovalRequest struct { + // Unique identifier with which the approval is to be registered. + ApprovalId *uuid.UUID `json:"approvalId,omitempty"` + // Configuration of the approval request. + Config *ApprovalConfig `json:"config,omitempty"` +} + +type ApprovalsQueryParameters struct { + // Query approvals based on list of approval IDs. + ApprovalIds *[]uuid.UUID `json:"approvalIds,omitempty"` +} + +// [Flags] Status of an approval as a whole or of an individual step. +type ApprovalStatus string + +type approvalStatusValuesType struct { + Undefined ApprovalStatus + Uninitiated ApprovalStatus + Pending ApprovalStatus + Approved ApprovalStatus + Rejected ApprovalStatus + Skipped ApprovalStatus + Canceled ApprovalStatus + TimedOut ApprovalStatus + Failed ApprovalStatus + Completed ApprovalStatus + All ApprovalStatus +} + +var ApprovalStatusValues = approvalStatusValuesType{ + Undefined: "undefined", + // Indicates the approval is Uninitiated. Used in case of in sequence order of execution where given approver is not yet actionable. + Uninitiated: "uninitiated", + // Indicates the approval is Pending. + Pending: "pending", + // Indicates the approval is Approved. + Approved: "approved", + // Indicates the approval is Rejected. + Rejected: "rejected", + // Indicates the approval is Skipped. + Skipped: "skipped", + // Indicates the approval is Canceled. + Canceled: "canceled", + // Indicates the approval is Timed out. + TimedOut: "timedOut", + Failed: "failed", + Completed: "completed", + All: "all", +} + +// Data for a single approval step. +type ApprovalStep struct { + // Identity who approved. + ActualApprover *webapi.IdentityRef `json:"actualApprover,omitempty"` + // Identity who should approve. + AssignedApprover *webapi.IdentityRef `json:"assignedApprover,omitempty"` + // Comment associated with this step. + Comment *string `json:"comment,omitempty"` + // History of the approval step + History *[]ApprovalStepHistory `json:"history,omitempty"` + // Timestamp at which this step was initiated. + InitiatedOn *azuredevops.Time `json:"initiatedOn,omitempty"` + // Identity by which this step was last modified. + LastModifiedBy *webapi.IdentityRef `json:"lastModifiedBy,omitempty"` + // Timestamp at which this step was last modified. + LastModifiedOn *azuredevops.Time `json:"lastModifiedOn,omitempty"` + // Order in which the approvers are allowed to approve. + Order *int `json:"order,omitempty"` + // Current user permissions for step. + Permissions *ApprovalPermissions `json:"permissions,omitempty"` + // Current status of this step. + Status *ApprovalStatus `json:"status,omitempty"` +} + +// Data for a single approval step history. +type ApprovalStepHistory struct { + // Identity who was assigned this approval + AssignedTo *webapi.IdentityRef `json:"assignedTo,omitempty"` + // Comment associated with this step history. + Comment *string `json:"comment,omitempty"` + // Identity by which this step history was created. + CreatedBy *webapi.IdentityRef `json:"createdBy,omitempty"` + // Timestamp at which this step history was created. + CreatedOn *azuredevops.Time `json:"createdOn,omitempty"` +} + +// Data to update an approval object or its individual step. +type ApprovalUpdateParameters struct { + // ID of the approval to be updated. + ApprovalId *uuid.UUID `json:"approvalId,omitempty"` + // Current approver. + AssignedApprover *webapi.IdentityRef `json:"assignedApprover,omitempty"` + // Gets or sets comment. + Comment *string `json:"comment,omitempty"` + // Reassigned Approver. + ReassignTo *webapi.IdentityRef `json:"reassignTo,omitempty"` + // Gets or sets status. + Status *ApprovalStatus `json:"status,omitempty"` +} diff --git a/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelineschecks/client.go b/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelineschecks/client.go new file mode 100644 index 00000000..b7ea3480 --- /dev/null +++ b/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelineschecks/client.go @@ -0,0 +1,353 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// -------------------------------------------------------------------------------------------- +// Generated file, DO NOT EDIT +// Changes may cause incorrect behavior and will be lost if the code is regenerated. +// -------------------------------------------------------------------------------------------- + +package pipelineschecks + +import ( + "bytes" + "context" + "encoding/json" + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "net/http" + "net/url" + "strconv" +) + +var ResourceAreaId, _ = uuid.Parse("4a933897-0488-45af-bd82-6fd3ad33f46a") + +type Client interface { + // [Preview API] Add a check configuration + AddCheckConfiguration(context.Context, AddCheckConfigurationArgs) (*CheckConfiguration, error) + // [Preview API] Delete check configuration by id + DeleteCheckConfiguration(context.Context, DeleteCheckConfigurationArgs) error + // [Preview API] Initiate an evaluation for a check in a pipeline + EvaluateCheckSuite(context.Context, EvaluateCheckSuiteArgs) (*CheckSuite, error) + // [Preview API] Get Check configuration by Id + GetCheckConfiguration(context.Context, GetCheckConfigurationArgs) (*CheckConfiguration, error) + // [Preview API] Get Check configuration by resource type and id + GetCheckConfigurationsOnResource(context.Context, GetCheckConfigurationsOnResourceArgs) (*[]CheckConfiguration, error) + // [Preview API] Get details for a specific check evaluation + GetCheckSuite(context.Context, GetCheckSuiteArgs) (*CheckSuite, error) + // [Preview API] Get check configurations for multiple resources by resource type and id. + QueryCheckConfigurationsOnResources(context.Context, QueryCheckConfigurationsOnResourcesArgs) (*[]CheckConfiguration, error) + // [Preview API] Update check configuration + UpdateCheckConfiguration(context.Context, UpdateCheckConfigurationArgs) (*CheckConfiguration, error) +} + +type ClientImpl struct { + Client azuredevops.Client +} + +func NewClient(ctx context.Context, connection *azuredevops.Connection) (Client, error) { + client, err := connection.GetClientByResourceAreaId(ctx, ResourceAreaId) + if err != nil { + return nil, err + } + return &ClientImpl{ + Client: *client, + }, nil +} + +// [Preview API] Add a check configuration +func (client *ClientImpl) AddCheckConfiguration(ctx context.Context, args AddCheckConfigurationArgs) (*CheckConfiguration, error) { + if args.Configuration == nil { + return nil, &azuredevops.ArgumentNilError{ArgumentName: "args.Configuration"} + } + routeValues := make(map[string]string) + if args.Project == nil || *args.Project == "" { + return nil, &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.Project"} + } + routeValues["project"] = *args.Project + + body, marshalErr := json.Marshal(*args.Configuration) + if marshalErr != nil { + return nil, marshalErr + } + locationId, _ := uuid.Parse("86c8381e-5aee-4cde-8ae4-25c0c7f5eaea") + resp, err := client.Client.Send(ctx, http.MethodPost, locationId, "7.1-preview.1", routeValues, nil, bytes.NewReader(body), "application/json", "application/json", nil) + if err != nil { + return nil, err + } + + var responseValue CheckConfiguration + err = client.Client.UnmarshalBody(resp, &responseValue) + return &responseValue, err +} + +// Arguments for the AddCheckConfiguration function +type AddCheckConfigurationArgs struct { + // (required) + Configuration *CheckConfiguration + // (required) Project ID or project name + Project *string +} + +// [Preview API] Delete check configuration by id +func (client *ClientImpl) DeleteCheckConfiguration(ctx context.Context, args DeleteCheckConfigurationArgs) error { + routeValues := make(map[string]string) + if args.Project == nil || *args.Project == "" { + return &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.Project"} + } + routeValues["project"] = *args.Project + if args.Id == nil { + return &azuredevops.ArgumentNilError{ArgumentName: "args.Id"} + } + routeValues["id"] = strconv.Itoa(*args.Id) + + locationId, _ := uuid.Parse("86c8381e-5aee-4cde-8ae4-25c0c7f5eaea") + _, err := client.Client.Send(ctx, http.MethodDelete, locationId, "7.1-preview.1", routeValues, nil, nil, "", "application/json", nil) + if err != nil { + return err + } + + return nil +} + +// Arguments for the DeleteCheckConfiguration function +type DeleteCheckConfigurationArgs struct { + // (required) Project ID or project name + Project *string + // (required) check configuration id + Id *int +} + +// [Preview API] Initiate an evaluation for a check in a pipeline +func (client *ClientImpl) EvaluateCheckSuite(ctx context.Context, args EvaluateCheckSuiteArgs) (*CheckSuite, error) { + if args.Request == nil { + return nil, &azuredevops.ArgumentNilError{ArgumentName: "args.Request"} + } + routeValues := make(map[string]string) + if args.Project == nil || *args.Project == "" { + return nil, &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.Project"} + } + routeValues["project"] = *args.Project + + queryParams := url.Values{} + if args.Expand != nil { + queryParams.Add("$expand", string(*args.Expand)) + } + body, marshalErr := json.Marshal(*args.Request) + if marshalErr != nil { + return nil, marshalErr + } + locationId, _ := uuid.Parse("91282c1d-c183-444f-9554-1485bfb3879d") + resp, err := client.Client.Send(ctx, http.MethodPost, locationId, "7.1-preview.1", routeValues, queryParams, bytes.NewReader(body), "application/json", "application/json", nil) + if err != nil { + return nil, err + } + + var responseValue CheckSuite + err = client.Client.UnmarshalBody(resp, &responseValue) + return &responseValue, err +} + +// Arguments for the EvaluateCheckSuite function +type EvaluateCheckSuiteArgs struct { + // (required) + Request *CheckSuiteRequest + // (required) Project ID or project name + Project *string + // (optional) + Expand *CheckSuiteExpandParameter +} + +// [Preview API] Get Check configuration by Id +func (client *ClientImpl) GetCheckConfiguration(ctx context.Context, args GetCheckConfigurationArgs) (*CheckConfiguration, error) { + routeValues := make(map[string]string) + if args.Project == nil || *args.Project == "" { + return nil, &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.Project"} + } + routeValues["project"] = *args.Project + if args.Id == nil { + return nil, &azuredevops.ArgumentNilError{ArgumentName: "args.Id"} + } + routeValues["id"] = strconv.Itoa(*args.Id) + + queryParams := url.Values{} + if args.Expand != nil { + queryParams.Add("$expand", string(*args.Expand)) + } + locationId, _ := uuid.Parse("86c8381e-5aee-4cde-8ae4-25c0c7f5eaea") + resp, err := client.Client.Send(ctx, http.MethodGet, locationId, "7.1-preview.1", routeValues, queryParams, nil, "", "application/json", nil) + if err != nil { + return nil, err + } + + var responseValue CheckConfiguration + err = client.Client.UnmarshalBody(resp, &responseValue) + return &responseValue, err +} + +// Arguments for the GetCheckConfiguration function +type GetCheckConfigurationArgs struct { + // (required) Project ID or project name + Project *string + // (required) + Id *int + // (optional) + Expand *CheckConfigurationExpandParameter +} + +// [Preview API] Get Check configuration by resource type and id +func (client *ClientImpl) GetCheckConfigurationsOnResource(ctx context.Context, args GetCheckConfigurationsOnResourceArgs) (*[]CheckConfiguration, error) { + routeValues := make(map[string]string) + if args.Project == nil || *args.Project == "" { + return nil, &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.Project"} + } + routeValues["project"] = *args.Project + + queryParams := url.Values{} + if args.ResourceType != nil { + queryParams.Add("resourceType", *args.ResourceType) + } + if args.ResourceId != nil { + queryParams.Add("resourceId", *args.ResourceId) + } + if args.Expand != nil { + queryParams.Add("$expand", string(*args.Expand)) + } + locationId, _ := uuid.Parse("86c8381e-5aee-4cde-8ae4-25c0c7f5eaea") + resp, err := client.Client.Send(ctx, http.MethodGet, locationId, "7.1-preview.1", routeValues, queryParams, nil, "", "application/json", nil) + if err != nil { + return nil, err + } + + var responseValue []CheckConfiguration + err = client.Client.UnmarshalCollectionBody(resp, &responseValue) + return &responseValue, err +} + +// Arguments for the GetCheckConfigurationsOnResource function +type GetCheckConfigurationsOnResourceArgs struct { + // (required) Project ID or project name + Project *string + // (optional) resource type + ResourceType *string + // (optional) resource id + ResourceId *string + // (optional) + Expand *CheckConfigurationExpandParameter +} + +// [Preview API] Get details for a specific check evaluation +func (client *ClientImpl) GetCheckSuite(ctx context.Context, args GetCheckSuiteArgs) (*CheckSuite, error) { + routeValues := make(map[string]string) + if args.Project == nil || *args.Project == "" { + return nil, &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.Project"} + } + routeValues["project"] = *args.Project + if args.CheckSuiteId == nil { + return nil, &azuredevops.ArgumentNilError{ArgumentName: "args.CheckSuiteId"} + } + routeValues["checkSuiteId"] = (*args.CheckSuiteId).String() + + queryParams := url.Values{} + if args.Expand != nil { + queryParams.Add("$expand", string(*args.Expand)) + } + locationId, _ := uuid.Parse("91282c1d-c183-444f-9554-1485bfb3879d") + resp, err := client.Client.Send(ctx, http.MethodGet, locationId, "7.1-preview.1", routeValues, queryParams, nil, "", "application/json", nil) + if err != nil { + return nil, err + } + + var responseValue CheckSuite + err = client.Client.UnmarshalBody(resp, &responseValue) + return &responseValue, err +} + +// Arguments for the GetCheckSuite function +type GetCheckSuiteArgs struct { + // (required) Project ID or project name + Project *string + // (required) + CheckSuiteId *uuid.UUID + // (optional) + Expand *CheckSuiteExpandParameter +} + +// [Preview API] Get check configurations for multiple resources by resource type and id. +func (client *ClientImpl) QueryCheckConfigurationsOnResources(ctx context.Context, args QueryCheckConfigurationsOnResourcesArgs) (*[]CheckConfiguration, error) { + if args.Resources == nil { + return nil, &azuredevops.ArgumentNilError{ArgumentName: "args.Resources"} + } + routeValues := make(map[string]string) + if args.Project == nil || *args.Project == "" { + return nil, &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.Project"} + } + routeValues["project"] = *args.Project + + queryParams := url.Values{} + if args.Expand != nil { + queryParams.Add("$expand", string(*args.Expand)) + } + body, marshalErr := json.Marshal(*args.Resources) + if marshalErr != nil { + return nil, marshalErr + } + locationId, _ := uuid.Parse("5f3d0e64-f943-4584-8811-77eb495e831e") + resp, err := client.Client.Send(ctx, http.MethodPost, locationId, "7.1-preview.1", routeValues, queryParams, bytes.NewReader(body), "application/json", "application/json", nil) + if err != nil { + return nil, err + } + + var responseValue []CheckConfiguration + err = client.Client.UnmarshalCollectionBody(resp, &responseValue) + return &responseValue, err +} + +// Arguments for the QueryCheckConfigurationsOnResources function +type QueryCheckConfigurationsOnResourcesArgs struct { + // (required) List of resources. + Resources *[]Resource + // (required) Project ID or project name + Project *string + // (optional) The properties that should be expanded in the list of check configurations. + Expand *CheckConfigurationExpandParameter +} + +// [Preview API] Update check configuration +func (client *ClientImpl) UpdateCheckConfiguration(ctx context.Context, args UpdateCheckConfigurationArgs) (*CheckConfiguration, error) { + if args.Configuration == nil { + return nil, &azuredevops.ArgumentNilError{ArgumentName: "args.Configuration"} + } + routeValues := make(map[string]string) + if args.Project == nil || *args.Project == "" { + return nil, &azuredevops.ArgumentNilOrEmptyError{ArgumentName: "args.Project"} + } + routeValues["project"] = *args.Project + if args.Id == nil { + return nil, &azuredevops.ArgumentNilError{ArgumentName: "args.Id"} + } + routeValues["id"] = strconv.Itoa(*args.Id) + + body, marshalErr := json.Marshal(*args.Configuration) + if marshalErr != nil { + return nil, marshalErr + } + locationId, _ := uuid.Parse("86c8381e-5aee-4cde-8ae4-25c0c7f5eaea") + resp, err := client.Client.Send(ctx, http.MethodPatch, locationId, "7.1-preview.1", routeValues, nil, bytes.NewReader(body), "application/json", "application/json", nil) + if err != nil { + return nil, err + } + + var responseValue CheckConfiguration + err = client.Client.UnmarshalBody(resp, &responseValue) + return &responseValue, err +} + +// Arguments for the UpdateCheckConfiguration function +type UpdateCheckConfigurationArgs struct { + // (required) check configuration + Configuration *CheckConfiguration + // (required) Project ID or project name + Project *string + // (required) check configuration id + Id *int +} diff --git a/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelineschecks/models.go b/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelineschecks/models.go new file mode 100644 index 00000000..2972c60b --- /dev/null +++ b/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelineschecks/models.go @@ -0,0 +1,325 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// -------------------------------------------------------------------------------------------- +// Generated file, DO NOT EDIT +// Changes may cause incorrect behavior and will be lost if the code is regenerated. +// -------------------------------------------------------------------------------------------- + +package pipelineschecks + +import ( + "github.com/google/uuid" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinesapproval" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinestaskcheck" + "github.com/microsoft/azure-devops-go-api/azuredevops/v7/webapi" +) + +type ApprovalCheckConfiguration struct { + // Check configuration id. + Id *int `json:"id,omitempty"` + // Resource on which check get configured. + Resource *Resource `json:"resource,omitempty"` + // Check configuration type + Type *CheckType `json:"type,omitempty"` + // The URL from which one can fetch the configured check. + Url *string `json:"url,omitempty"` + // Reference links. + Links interface{} `json:"_links,omitempty"` + // Identity of person who configured check. + CreatedBy *webapi.IdentityRef `json:"createdBy,omitempty"` + // Time when check got configured. + CreatedOn *azuredevops.Time `json:"createdOn,omitempty"` + // Issue connected to check configuration. + Issue *CheckIssue `json:"issue,omitempty"` + // Identity of person who modified the configured check. + ModifiedBy *webapi.IdentityRef `json:"modifiedBy,omitempty"` + // Time when configured check was modified. + ModifiedOn *azuredevops.Time `json:"modifiedOn,omitempty"` + // Timeout in minutes for the check. + Timeout *int `json:"timeout,omitempty"` + // Settings for the approval check configuration. + Settings *pipelinesapproval.ApprovalConfigSettings `json:"settings,omitempty"` +} + +type GenericCheckConfiguration struct { + // Check configuration id. + Id *int `json:"id,omitempty"` + // Resource on which check get configured. + Resource *Resource `json:"resource,omitempty"` + // Check configuration type + Type *CheckType `json:"type,omitempty"` + // The URL from which one can fetch the configured check. + Url *string `json:"url,omitempty"` + // Reference links. + Links interface{} `json:"_links,omitempty"` + // Identity of person who configured check. + CreatedBy *webapi.IdentityRef `json:"createdBy,omitempty"` + // Time when check got configured. + CreatedOn *azuredevops.Time `json:"createdOn,omitempty"` + // Issue connected to check configuration. + Issue *CheckIssue `json:"issue,omitempty"` + // Identity of person who modified the configured check. + ModifiedBy *webapi.IdentityRef `json:"modifiedBy,omitempty"` + // Time when configured check was modified. + ModifiedOn *azuredevops.Time `json:"modifiedOn,omitempty"` + // Timeout in minutes for the check. + Timeout *int `json:"timeout,omitempty"` + // Settings for the generic check configuration. + Settings interface{} `json:"settings,omitempty"` +} + +type CheckConfiguration struct { + // Check configuration id. + Id *int `json:"id,omitempty"` + // Resource on which check get configured. + Resource *Resource `json:"resource,omitempty"` + // Check configuration type + Type *CheckType `json:"type,omitempty"` + // The URL from which one can fetch the configured check. + Url *string `json:"url,omitempty"` + // Reference links. + Links interface{} `json:"_links,omitempty"` + // Identity of person who configured check. + CreatedBy *webapi.IdentityRef `json:"createdBy,omitempty"` + // Time when check got configured. + CreatedOn *azuredevops.Time `json:"createdOn,omitempty"` + // Issue connected to check configuration. + Issue *CheckIssue `json:"issue,omitempty"` + // Identity of person who modified the configured check. + ModifiedBy *webapi.IdentityRef `json:"modifiedBy,omitempty"` + // Time when configured check was modified. + ModifiedOn *azuredevops.Time `json:"modifiedOn,omitempty"` + // Timeout in minutes for the check. + Timeout *int `json:"timeout,omitempty"` +} + +type CheckConfigurationData struct { + // Definition Ref Id of the particular check. + DefinitionRefId *uuid.UUID `json:"definitionRefId,omitempty"` + // Check configuration of the check. + CheckConfiguration *CheckConfiguration `json:"checkConfiguration,omitempty"` +} + +// [Flags] +type CheckConfigurationExpandParameter string + +type checkConfigurationExpandParameterValuesType struct { + None CheckConfigurationExpandParameter + Settings CheckConfigurationExpandParameter +} + +var CheckConfigurationExpandParameterValues = checkConfigurationExpandParameterValuesType{ + None: "none", + Settings: "settings", +} + +type CheckConfigurationRef struct { + // Check configuration id. + Id *int `json:"id,omitempty"` + // Resource on which check get configured. + Resource *Resource `json:"resource,omitempty"` + // Check configuration type + Type *CheckType `json:"type,omitempty"` + // The URL from which one can fetch the configured check. + Url *string `json:"url,omitempty"` +} + +type CheckData struct { + // List of default check settings + DefaultCheckSettings *map[string]string `json:"defaultCheckSettings,omitempty"` + // List of check configuration data + CheckConfigurationDataList *[]CheckConfigurationData `json:"checkConfigurationDataList,omitempty"` + // List of check definitions + CheckDefinitions *[]CheckDefinitionData `json:"checkDefinitions,omitempty"` + // List of time zones. + TimeZoneList *[]TimeZone `json:"timeZoneList,omitempty"` +} + +type CheckDefinitionData struct { + // Flag to allow multiple configurations of a particular check on a resource. + AllowMultipleConfigurations *bool `json:"allowMultipleConfigurations,omitempty"` + // Check DefinitionRef Id + DefinitionRefId *uuid.UUID `json:"definitionRefId,omitempty"` + // Description about the check + Description *string `json:"description,omitempty"` + // Details about the check + CheckDefinition interface{} `json:"checkDefinition,omitempty"` + // Icon for the check + Icon *CheckIcon `json:"icon,omitempty"` + // Name of the check + Name *string `json:"name,omitempty"` + // Check UI contribution Dependencies + UiContributionDependencies *[]string `json:"uiContributionDependencies,omitempty"` + // Check UI contribution Type + UiContributionType *string `json:"uiContributionType,omitempty"` +} + +type CheckIcon struct { + // Asset Location of the icon + AssetLocation *string `json:"assetLocation,omitempty"` + // Name of the icon + Name *string `json:"name,omitempty"` + // Url of the icon + Url *string `json:"url,omitempty"` +} + +// An issue (error, warning) associated with a check configuration. +type CheckIssue struct { + // A more detailed description of issue. + DetailedMessage *string `json:"detailedMessage,omitempty"` + // A description of issue. + Message *string `json:"message,omitempty"` + // The type (error, warning) of the issue. + Type *CheckIssueType `json:"type,omitempty"` +} + +// The type of issue based on severity. +type CheckIssueType string + +type checkIssueTypeValuesType struct { + Error CheckIssueType + Warning CheckIssueType +} + +var CheckIssueTypeValues = checkIssueTypeValuesType{ + Error: "error", + Warning: "warning", +} + +type CheckRun struct { + ResultMessage *string `json:"resultMessage,omitempty"` + Status *CheckRunStatus `json:"status,omitempty"` + CompletedDate *azuredevops.Time `json:"completedDate,omitempty"` + CreatedDate *azuredevops.Time `json:"createdDate,omitempty"` + CheckConfigurationRef *CheckConfigurationRef `json:"checkConfigurationRef,omitempty"` + Id *uuid.UUID `json:"id,omitempty"` +} + +type CheckRunResult struct { + ResultMessage *string `json:"resultMessage,omitempty"` + Status *CheckRunStatus `json:"status,omitempty"` +} + +// [Flags] +type CheckRunStatus string + +type checkRunStatusValuesType struct { + None CheckRunStatus + Queued CheckRunStatus + Running CheckRunStatus + Approved CheckRunStatus + Rejected CheckRunStatus + Canceled CheckRunStatus + TimedOut CheckRunStatus + Failed CheckRunStatus + Completed CheckRunStatus + All CheckRunStatus +} + +var CheckRunStatusValues = checkRunStatusValuesType{ + None: "none", + Queued: "queued", + Running: "running", + Approved: "approved", + Rejected: "rejected", + Canceled: "canceled", + TimedOut: "timedOut", + Failed: "failed", + Completed: "completed", + All: "all", +} + +type CheckSuite struct { + // Evaluation context for the check suite request + Context interface{} `json:"context,omitempty"` + // Unique suite id generated by the pipeline orchestrator for the pipeline check runs request on the list of resources Pipeline orchestrator will used this identifier to map the check requests on a stage + Id *uuid.UUID `json:"id,omitempty"` + // Reference links. + Links interface{} `json:"_links,omitempty"` + // Completed date of the given check suite request + CompletedDate *azuredevops.Time `json:"completedDate,omitempty"` + // List of check runs associated with the given check suite request. + CheckRuns *[]CheckRun `json:"checkRuns,omitempty"` + // Optional message for the given check suite request + Message *string `json:"message,omitempty"` + // Overall check runs status for the given suite request. This is check suite status + Status *CheckRunStatus `json:"status,omitempty"` +} + +// [Flags] +type CheckSuiteExpandParameter string + +type checkSuiteExpandParameterValuesType struct { + None CheckSuiteExpandParameter + Resources CheckSuiteExpandParameter +} + +var CheckSuiteExpandParameterValues = checkSuiteExpandParameterValuesType{ + None: "none", + Resources: "resources", +} + +type CheckSuiteRef struct { + // Evaluation context for the check suite request + Context interface{} `json:"context,omitempty"` + // Unique suite id generated by the pipeline orchestrator for the pipeline check runs request on the list of resources Pipeline orchestrator will used this identifier to map the check requests on a stage + Id *uuid.UUID `json:"id,omitempty"` +} + +type CheckSuiteRequest struct { + Context interface{} `json:"context,omitempty"` + Id *uuid.UUID `json:"id,omitempty"` + Resources *[]Resource `json:"resources,omitempty"` +} + +type CheckType struct { + // Gets or sets check type id. + Id *uuid.UUID `json:"id,omitempty"` + // Name of the check type. + Name *string `json:"name,omitempty"` +} + +type Resource struct { + // Id of the resource. + Id *string `json:"id,omitempty"` + // Name of the resource. + Name *string `json:"name,omitempty"` + // Type of the resource. + Type *string `json:"type,omitempty"` +} + +type TaskCheckConfiguration struct { + // Check configuration id. + Id *int `json:"id,omitempty"` + // Resource on which check get configured. + Resource *Resource `json:"resource,omitempty"` + // Check configuration type + Type *CheckType `json:"type,omitempty"` + // The URL from which one can fetch the configured check. + Url *string `json:"url,omitempty"` + // Reference links. + Links interface{} `json:"_links,omitempty"` + // Identity of person who configured check. + CreatedBy *webapi.IdentityRef `json:"createdBy,omitempty"` + // Time when check got configured. + CreatedOn *azuredevops.Time `json:"createdOn,omitempty"` + // Issue connected to check configuration. + Issue *CheckIssue `json:"issue,omitempty"` + // Identity of person who modified the configured check. + ModifiedBy *webapi.IdentityRef `json:"modifiedBy,omitempty"` + // Time when configured check was modified. + ModifiedOn *azuredevops.Time `json:"modifiedOn,omitempty"` + // Timeout in minutes for the check. + Timeout *int `json:"timeout,omitempty"` + // Settings for the task check configuration. + Settings *pipelinestaskcheck.TaskCheckConfig `json:"settings,omitempty"` +} + +type TimeZone struct { + // Display name of the time zone. + DisplayName *string `json:"displayName,omitempty"` + // Id of the time zone. + Id *string `json:"id,omitempty"` +} diff --git a/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinestaskcheck/models.go b/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinestaskcheck/models.go new file mode 100644 index 00000000..93e0fd4c --- /dev/null +++ b/vendor/github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinestaskcheck/models.go @@ -0,0 +1,28 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// -------------------------------------------------------------------------------------------- +// Generated file, DO NOT EDIT +// Changes may cause incorrect behavior and will be lost if the code is regenerated. +// -------------------------------------------------------------------------------------------- + +package pipelinestaskcheck + +import ( + "github.com/google/uuid" +) + +// Config to facilitate task check +type TaskCheckConfig struct { + DefinitionRef *TaskCheckDefinitionReference `json:"definitionRef,omitempty"` + DisplayName *string `json:"displayName,omitempty"` + Inputs *map[string]string `json:"inputs,omitempty"` + LinkedVariableGroup *string `json:"linkedVariableGroup,omitempty"` + RetryInterval *int `json:"retryInterval,omitempty"` +} + +type TaskCheckDefinitionReference struct { + Id *uuid.UUID `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Version *string `json:"version,omitempty"` +} diff --git a/vendor/modules.txt b/vendor/modules.txt index b379c7b6..67dfce03 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -144,6 +144,10 @@ github.com/microsoft/azure-devops-go-api/azuredevops/v7/git github.com/microsoft/azure-devops-go-api/azuredevops/v7/graph github.com/microsoft/azure-devops-go-api/azuredevops/v7/identity github.com/microsoft/azure-devops-go-api/azuredevops/v7/operations +github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinepermissions +github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinesapproval +github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelineschecks +github.com/microsoft/azure-devops-go-api/azuredevops/v7/pipelinestaskcheck github.com/microsoft/azure-devops-go-api/azuredevops/v7/policy github.com/microsoft/azure-devops-go-api/azuredevops/v7/profile github.com/microsoft/azure-devops-go-api/azuredevops/v7/security