diff options
| author | Mike Vink <mike1994vink@gmail.com> | 2023-02-05 19:13:23 +0100 |
|---|---|---|
| committer | Mike Vink <mike1994vink@gmail.com> | 2023-02-28 10:14:28 +0100 |
| commit | afb357c9295f969765d7e4c76fbde2bb27224c15 (patch) | |
| tree | b9b0ab8b2a67e9cec46ad82a53f776e04d3de2bb | |
| parent | 6e180aae16b3e04cedcf0b5dd33a779072954e63 (diff) | |
feat: resource_azuredevops_git_repository_branch
9 files changed, 901 insertions, 8 deletions
diff --git a/azuredevops/internal/acceptancetests/resource_git_repository_branch_test.go b/azuredevops/internal/acceptancetests/resource_git_repository_branch_test.go new file mode 100644 index 00000000..4ca47695 --- /dev/null +++ b/azuredevops/internal/acceptancetests/resource_git_repository_branch_test.go @@ -0,0 +1,225 @@ +//go:build (all || core || resource_git_repository_branch) && !exclude_resource_git_repository_branch +// +build all core resource_git_repository_branch +// +build !exclude_resource_git_repository_branch + +package acceptancetests + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/microsoft/azure-devops-go-api/azuredevops/v6/git" + "github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/acceptancetests/testutils" + "github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/client" + "github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/utils/tfhelper" +) + +// TestAccGitRepoBranch_CreateUpdateDelete verifies that a branch can +// be added to a repository and that it can be replaced +func TestAccGitRepoBranch_CreateAndUpdate(t *testing.T) { + var gotBranch git.GitBranchStats + var gotBranch2 git.GitBranchStats + var gotBranch3 git.GitBranchStats + projectName := testutils.GenerateResourceName() + gitRepoName := testutils.GenerateResourceName() + branchName := testutils.GenerateResourceName() + branchNameChanged := testutils.GenerateResourceName() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testutils.PreCheck(t, nil) }, + Providers: testutils.GetProviders(), + Steps: []resource.TestStep{ + { + Config: hclGitRepoBranches(projectName, gitRepoName, "Uninitialized", branchName), + Check: resource.ComposeTestCheckFunc( + testAccGitRepoBranchExists("foo_orphan", &gotBranch), + testAccGitRepoBranchExists("foo_from_ref", &gotBranch2), + testAccGitRepoBranchExists("foo_from_sha", &gotBranch3), + testAccGitRepoBranchAttributes("foo_orphan", &gotBranch, &testAccGitRepoBranchExpectedAttributes{ + Name: fmt.Sprintf("testbranch-%s", branchName), + }, &testAccGitRepoBranchExpectedStateAttrs{ + source_ref: "", + source_sha: false, + is_default_branch: true, + ref: fmt.Sprintf("refs/heads/testbranch-%s", branchName), + sha: true, + }), + testAccGitRepoBranchAttributes("foo_from_ref", &gotBranch2, &testAccGitRepoBranchExpectedAttributes{ + Name: fmt.Sprintf("testbranch2-%s", branchName), + }, &testAccGitRepoBranchExpectedStateAttrs{ + source_ref: fmt.Sprintf("refs/heads/testbranch-%s", branchName), + source_sha: true, + ref: fmt.Sprintf("refs/heads/testbranch2-%s", branchName), + sha: true, + }), + testAccGitRepoBranchAttributes("foo_from_sha", &gotBranch3, &testAccGitRepoBranchExpectedAttributes{ + Name: fmt.Sprintf("testbranch3-%s", branchName), + }, &testAccGitRepoBranchExpectedStateAttrs{ + source_ref: "", + source_sha: true, + ref: fmt.Sprintf("refs/heads/testbranch3-%s", branchName), + sha: true, + }), + ), + }, + // Test import branch created from ref + { + ResourceName: "azuredevops_git_repository_branch.foo_from_ref", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"source_ref", "source_sha"}, + }, + // Test replace/update branch when name changes + { + Config: hclGitRepoBranches(projectName, gitRepoName, "Uninitialized", branchNameChanged), + Check: resource.ComposeTestCheckFunc( + testAccGitRepoBranchExists("foo_orphan", &gotBranch), + testAccGitRepoBranchExists("foo_from_ref", &gotBranch2), + testAccGitRepoBranchExists("foo_from_sha", &gotBranch3), + testAccGitRepoBranchAttributes("foo_orphan", &gotBranch, &testAccGitRepoBranchExpectedAttributes{ + Name: fmt.Sprintf("testbranch-%s", branchNameChanged), + }, &testAccGitRepoBranchExpectedStateAttrs{ + source_ref: "", + source_sha: false, + is_default_branch: true, + ref: fmt.Sprintf("refs/heads/testbranch-%s", branchNameChanged), + sha: true, + }), + testAccGitRepoBranchAttributes("foo_from_ref", &gotBranch2, &testAccGitRepoBranchExpectedAttributes{ + Name: fmt.Sprintf("testbranch2-%s", branchNameChanged), + }, &testAccGitRepoBranchExpectedStateAttrs{ + source_ref: fmt.Sprintf("refs/heads/testbranch-%s", branchNameChanged), + source_sha: true, + ref: fmt.Sprintf("refs/heads/testbranch2-%s", branchNameChanged), + sha: true, + }), + testAccGitRepoBranchAttributes("foo_from_sha", &gotBranch3, &testAccGitRepoBranchExpectedAttributes{ + Name: fmt.Sprintf("testbranch3-%s", branchNameChanged), + }, &testAccGitRepoBranchExpectedStateAttrs{ + source_ref: "", + source_sha: true, + ref: fmt.Sprintf("refs/heads/testbranch3-%s", branchNameChanged), + sha: true, + }), + ), + }, + // Test invalid ref + { + Config: fmt.Sprintf(` +%s + +resource "azuredevops_git_repository_branch" "foo_nonexistent_tag" { + repository_id = azuredevops_git_repository.repository.id + name = "testbranch2-non-existent-tag" + source_ref = "refs/tags/non-existent" +} +`, hclGitRepoBranches(projectName, gitRepoName, "Clean", branchNameChanged)), + ExpectError: regexp.MustCompile(`No refs found that match source_ref "refs/tags/non-existent"`), + }, + }, + }, + ) +} + +func testAccGitRepoBranchAttributes(node string, branch *git.GitBranchStats, want *testAccGitRepoBranchExpectedAttributes, wantState *testAccGitRepoBranchExpectedStateAttrs) resource.TestCheckFunc { + return func(s *terraform.State) error { + if *branch.Name != want.Name { + return fmt.Errorf("Error got name %s, want %s", *branch.Name, want.Name) + } + + rs, ok := s.RootModule().Resources[fmt.Sprintf("azuredevops_git_repository_branch.%s", node)] + if !ok { + return fmt.Errorf("Not found: %s", node) + } + + sourceRef := rs.Primary.Attributes["source_ref"] + if wantState.source_ref != sourceRef { + return fmt.Errorf("azuredevops_git_repository_branch.%s.source_ref = %s, want %s", node, sourceRef, wantState.source_ref) + } + + sourceSha := rs.Primary.Attributes["source_sha"] + if wantState.source_sha && sourceSha == "" { + return fmt.Errorf("azuredevops_git_repository_branch.%s.source_sha is not set", node) + } + + isDefaultBranch := rs.Primary.Attributes["is_default_branch"] + if wantState.is_default_branch && isDefaultBranch != "true" { + return fmt.Errorf("azuredevops_git_repository_branch.%s.is_default_branch = %s, want %v", node, isDefaultBranch, wantState.is_default_branch) + } + + ref := rs.Primary.Attributes["ref"] + if wantState.ref != ref { + return fmt.Errorf("azuredevops_git_repository_branch.%s.ref = %s, want %s", node, ref, wantState.ref) + } + + sha := rs.Primary.Attributes["sha"] + if wantState.sha && sha == "" { + return fmt.Errorf("azuredevops_git_repository_branch.%s.ref = %s, want %s", node, ref, wantState.ref) + } + + return nil + } +} + +func testAccGitRepoBranchExists(node string, gotBranch *git.GitBranchStats) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[fmt.Sprintf("azuredevops_git_repository_branch.%s", node)] + if !ok { + return fmt.Errorf("Not found: %s", node) + } + + repoID, branchName, err := tfhelper.ParseGitRepoBranchID(rs.Primary.ID) + if err != nil { + return fmt.Errorf("Error in parsing branch ID: %w", err) + } + + clients := testutils.GetProvider().Meta().(*client.AggregatedClient) + branch, err := clients.GitReposClient.GetBranch(clients.Ctx, git.GetBranchArgs{ + RepositoryId: &repoID, + Name: &branchName, + }) + if err != nil { + return err + } + *gotBranch = *branch + + return nil + } +} + +func hclGitRepoBranches(projectName, gitRepoName, initType, branchName string) string { + gitRepoResource := testutils.HclGitRepoResource(projectName, gitRepoName, initType) + return fmt.Sprintf(` +%[1]s + +resource "azuredevops_git_repository_branch" "foo_orphan" { + repository_id = azuredevops_git_repository.repository.id + name = "testbranch-%[2]s" +} +resource "azuredevops_git_repository_branch" "foo_from_ref" { + repository_id = azuredevops_git_repository.repository.id + name = "testbranch2-%[2]s" + source_ref = azuredevops_git_repository_branch.foo_orphan.ref +} +resource "azuredevops_git_repository_branch" "foo_from_sha" { + repository_id = azuredevops_git_repository.repository.id + name = "testbranch3-%[2]s" + source_sha = azuredevops_git_repository_branch.foo_orphan.sha +} + `, gitRepoResource, branchName) +} + +type testAccGitRepoBranchExpectedStateAttrs struct { + source_ref string + source_sha bool + is_default_branch bool + ref string + sha bool +} + +type testAccGitRepoBranchExpectedAttributes struct { + Name string +} diff --git a/azuredevops/internal/acceptancetests/resource_git_repository_file_test.go b/azuredevops/internal/acceptancetests/resource_git_repository_file_test.go index 6ebbe60e..00dded6b 100644 --- a/azuredevops/internal/acceptancetests/resource_git_repository_file_test.go +++ b/azuredevops/internal/acceptancetests/resource_git_repository_file_test.go @@ -8,9 +8,8 @@ import ( "bytes" "context" "fmt" - "strings" - "regexp" + "strings" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" diff --git a/azuredevops/internal/service/git/resource_git_repository_branch.go b/azuredevops/internal/service/git/resource_git_repository_branch.go new file mode 100644 index 00000000..ef200e2f --- /dev/null +++ b/azuredevops/internal/service/git/resource_git_repository_branch.go @@ -0,0 +1,285 @@ +package git + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/microsoft/azure-devops-go-api/azuredevops/v6/git" + "github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/client" + "github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/utils" + "github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/utils/converter" + "github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/utils/tfhelper" +) + +// ResourceGitRepositoryBranch schema to manage the lifecycle of a git repository branch +func ResourceGitRepositoryBranch() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceGitRepositoryBranchCreate, + ReadContext: resourceGitRepositoryBranchRead, + DeleteContext: resourceGitRepositoryBranchDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceGitRepositoryBranchImport, + }, + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.NoZeroValues, + Sensitive: false, + }, + "repository_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.IsUUID, + }, + "source_ref": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + "source_sha": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Computed: true, + ValidateFunc: validation.StringIsNotEmpty, + }, + "is_default_branch": { + Type: schema.TypeBool, + Computed: true, + }, + "ref": { + Type: schema.TypeString, + Computed: true, + }, + "sha": { + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func resourceGitRepositoryBranchCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + clients := m.(*client.AggregatedClient) + repoId := d.Get("repository_id").(string) + branchName := d.Get("name").(string) + branchRef := withRefsHeadsPrefix(branchName) + sourceRef, hasSourceRef := d.GetOk("source_ref") + _, hasSourceSha := d.GetOk("source_sha") + + // Initialise new orphan branch + if !hasSourceRef && !hasSourceSha { + args := branchCreatePushArgs(branchRef, repoId) + + _, err := clients.GitReposClient.CreatePush(clients.Ctx, args) + if err != nil { + return diag.FromErr(fmt.Errorf("Error initialising new branch: %w", err)) + } + + d.SetId(fmt.Sprintf("%s:%s", repoId, branchName)) + + return resourceGitRepositoryBranchRead(ctx, d, m) + } + + // Get sha from source ref which can be a branch or a tag + if !hasSourceSha { + // Azuredevops GetRefs api returns refs whose "prefix" matches Filter sorted from shortest to longest + // Top1 should return best match + sourceRefName := sourceRef.(string) + filter := strings.TrimPrefix(sourceRefName, "refs/") + + gotRefs, err := clients.GitReposClient.GetRefs(clients.Ctx, git.GetRefsArgs{ + RepositoryId: converter.String(repoId), + Filter: converter.String(filter), + Top: converter.Int(1), + PeelTags: converter.Bool(true), + }) + if err != nil { + return diag.FromErr(fmt.Errorf("Error getting refs matching %q: %w", filter, err)) + } + + if len(gotRefs.Value) == 0 { + return diag.FromErr(fmt.Errorf("No refs found that match source_ref %q.", sourceRefName)) + } + + gotRef := gotRefs.Value[0] + if gotRef.Name == nil { + return diag.FromErr(fmt.Errorf("Got unexpected GetRefs response, a ref without a name was returned.")) + } + + // Check for complete match. Sometimes refs exist that match prefix with Ref, but do not match completely. + if *gotRef.Name != sourceRefName { + return diag.FromErr(fmt.Errorf("Ref %q not found, closest match is %q.", filter, *gotRef.Name)) + } + + // Check if ref was a tag and we need to use PeeledObjectId to get the commit id of the tag + var refObjectIdSha *string + if gotRef.PeeledObjectId != nil { + refObjectIdSha = gotRef.PeeledObjectId + } else if gotRef.ObjectId != nil { + refObjectIdSha = gotRef.ObjectId + } else { + return diag.FromErr(fmt.Errorf("GetRefs response doesn't have a valid commit id.")) + } + d.Set("source_sha", *refObjectIdSha) + } + newObjectId := d.Get("source_sha").(string) + + _, err := updateRefs(clients, git.UpdateRefsArgs{ + RefUpdates: &[]git.GitRefUpdate{{ + Name: &branchRef, + NewObjectId: &newObjectId, + OldObjectId: converter.String("0000000000000000000000000000000000000000"), + }}, + RepositoryId: converter.String(repoId), + }) + if err != nil { + return diag.FromErr(fmt.Errorf("Error creating branch %q: %w", branchName, err)) + } + + d.SetId(fmt.Sprintf("%s:%s", repoId, branchName)) + + return resourceGitRepositoryBranchRead(ctx, d, m) +} + +func resourceGitRepositoryBranchRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + clients := m.(*client.AggregatedClient) + + repoId, name, err := tfhelper.ParseGitRepoBranchID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + branchStats, err := clients.GitReposClient.GetBranch(clients.Ctx, git.GetBranchArgs{ + RepositoryId: converter.String(repoId), + Name: converter.String(name), + }) + if err != nil { + if utils.ResponseWasNotFound(err) { + d.SetId("") + return nil + } + return diag.FromErr(fmt.Errorf("Error reading branch %q: %w", name, err)) + } + + d.SetId(fmt.Sprintf("%s:%s", repoId, name)) + d.Set("name", name) + d.Set("repository_id", repoId) + d.Set("is_default_branch", *branchStats.IsBaseVersion) + d.Set("ref", converter.String(withRefsHeadsPrefix(name))) + d.Set("sha", *branchStats.Commit.CommitId) + + return nil +} + +func resourceGitRepositoryBranchDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + clients := m.(*client.AggregatedClient) + + repoId, name, err := tfhelper.ParseGitRepoBranchID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + branchStats, err := clients.GitReposClient.GetBranch(clients.Ctx, git.GetBranchArgs{ + RepositoryId: converter.String(repoId), + Name: converter.String(name), + }) + if err != nil { + return diag.FromErr(fmt.Errorf("Error getting latest commit of %q: %w", name, err)) + } + + _, err = updateRefs(clients, git.UpdateRefsArgs{ + RefUpdates: &[]git.GitRefUpdate{{ + Name: converter.String(withRefsHeadsPrefix(name)), + OldObjectId: branchStats.Commit.CommitId, + NewObjectId: converter.String("0000000000000000000000000000000000000000"), + }}, + RepositoryId: converter.String(repoId), + }) + if err != nil { + return diag.FromErr(fmt.Errorf("Error deleting branch %q: %w", name, err)) + } + + return nil +} + +func resourceGitRepositoryBranchImport(ctx context.Context, d *schema.ResourceData, m interface{}) ([]*schema.ResourceData, error) { + _, branchName, err := tfhelper.ParseGitRepoBranchID(d.Id()) + if err != nil { + return nil, err + } + + diags := resourceGitRepositoryBranchRead(ctx, d, m) + if diags.HasError() { + return nil, fmt.Errorf(diags[0].Summary) + } + + if d.Id() == "" { + return nil, fmt.Errorf("Branch %q not found", branchName) + } + + return []*schema.ResourceData{d}, nil +} + +func branchCreatePushArgs(name, repoId string) git.CreatePushArgs { + args := git.CreatePushArgs{ + RepositoryId: converter.String(repoId), + Push: &git.GitPush{ + RefUpdates: &[]git.GitRefUpdate{ + { + Name: converter.String(name), + OldObjectId: converter.String("0000000000000000000000000000000000000000"), + }, + }, + Commits: &[]git.GitCommitRef{ + { + Comment: converter.String("Initial commit."), + Changes: &[]interface{}{ + git.Change{ + ChangeType: &git.VersionControlChangeTypeValues.Add, + Item: git.GitItem{ + Path: converter.String("/readme.md"), + }, + NewContent: &git.ItemContent{ + ContentType: &git.ItemContentTypeValues.RawText, + Content: converter.String("Branch initialized with azuredevops terraform provider"), + }, + }, + }, + }, + }, + }, + } + return args +} + +func updateRefs(clients *client.AggregatedClient, args git.UpdateRefsArgs) (*[]git.GitRefUpdateResult, error) { + updateRefResults, err := clients.GitReposClient.UpdateRefs(clients.Ctx, args) + if err != nil { + return nil, err + } + + for _, refUpdate := range *updateRefResults { + if !*refUpdate.Success { + return nil, fmt.Errorf("Error got invalid GitRefUpdate.UpdateStatus: %s", *refUpdate.UpdateStatus) + } + } + + return updateRefResults, nil +} + +func withRefsHeadsPrefix(branchName string) string { + prefix := "refs/heads/" + if strings.HasPrefix(branchName, prefix) { + return branchName + } + return prefix + branchName +} diff --git a/azuredevops/internal/service/git/resource_git_repository_branch_test.go b/azuredevops/internal/service/git/resource_git_repository_branch_test.go new file mode 100644 index 00000000..51a90441 --- /dev/null +++ b/azuredevops/internal/service/git/resource_git_repository_branch_test.go @@ -0,0 +1,293 @@ +package git + +import ( + "context" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/golang/mock/gomock" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/microsoft/azure-devops-go-api/azuredevops/v6/git" + "github.com/microsoft/terraform-provider-azuredevops/azdosdkmocks" + "github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/client" + "github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/utils/converter" +) + +func TestGitRepositoryBranch_Create(t *testing.T) { + type args struct { + ctx context.Context + d *schema.ResourceData + m interface{} + } + tests := []struct { + name string + args func(g *azdosdkmocks.MockGitClient) args + want diag.Diagnostics + }{ + { + "When source_ref is not given, create push does not swallow error", + func(g *azdosdkmocks.MockGitClient) args { + clients := &client.AggregatedClient{ + GitReposClient: g, + Ctx: context.Background(), + } + expectedArgs := branchCreatePushArgs(withRefsHeadsPrefix("a-branch"), "a-repo") + d := schema.TestResourceDataRaw(t, ResourceGitRepositoryBranch().Schema, nil) + d.Set("name", "a-branch") + d.Set("repository_id", "a-repo") + + g.EXPECT(). + CreatePush(clients.Ctx, expectedArgs). + Return(nil, fmt.Errorf("an-error")) + return args{ + context.Background(), + d, + clients, + } + }, + diag.FromErr(fmt.Errorf("Error initialising new branch: an-error")), + }, + { + "When source_ref is given, refs update does not swallow error", + func(g *azdosdkmocks.MockGitClient) args { + clients := &client.AggregatedClient{ + GitReposClient: g, + Ctx: context.Background(), + } + d := schema.TestResourceDataRaw(t, ResourceGitRepositoryBranch().Schema, nil) + source_ref := "refs/heads/another-branch" + commit := "a-commit" + branchName := "a-branch" + repoId := "a-repo" + d.Set("source_ref", source_ref) + d.Set("name", branchName) + d.Set("repository_id", repoId) + + g.EXPECT(). + GetRefs(clients.Ctx, git.GetRefsArgs{ + RepositoryId: &repoId, + Filter: converter.String(strings.TrimPrefix(source_ref, "refs/")), + Top: converter.Int(1), + PeelTags: converter.Bool(true), + }). + Return(&git.GetRefsResponseValue{ + Value: []git.GitRef{{ + Name: &source_ref, + ObjectId: &commit, + }}, + }, nil) + + g.EXPECT(). + UpdateRefs(clients.Ctx, git.UpdateRefsArgs{ + RefUpdates: &[]git.GitRefUpdate{{ + Name: converter.String(withRefsHeadsPrefix("a-branch")), + NewObjectId: &commit, + OldObjectId: converter.String("0000000000000000000000000000000000000000"), + }}, + RepositoryId: converter.String("a-repo"), + }). + Return(nil, fmt.Errorf("an-error")) + return args{ + context.Background(), + d, + clients, + } + }, + diag.FromErr(fmt.Errorf("Error creating branch \"a-branch\": an-error")), + }, + { + "When invalid RefUpdate UpdateStatus, throw error", + func(g *azdosdkmocks.MockGitClient) args { + clients := &client.AggregatedClient{ + GitReposClient: g, + Ctx: context.Background(), + } + d := schema.TestResourceDataRaw(t, ResourceGitRepositoryBranch().Schema, nil) + source_ref := "refs/heads/another-branch" + commit := "a-commit" + branchName := "a-branch" + repoId := "a-repo" + d.Set("source_ref", source_ref) + d.Set("name", branchName) + d.Set("repository_id", repoId) + + g.EXPECT(). + GetRefs(clients.Ctx, git.GetRefsArgs{ + RepositoryId: &repoId, + Filter: converter.String(strings.TrimPrefix(source_ref, "refs/")), + Top: converter.Int(1), + PeelTags: converter.Bool(true), + }). + Return(&git.GetRefsResponseValue{ + Value: []git.GitRef{{ + Name: &source_ref, + ObjectId: &commit, + }}, + }, nil) + + g.EXPECT(). + UpdateRefs(clients.Ctx, git.UpdateRefsArgs{ + RefUpdates: &[]git.GitRefUpdate{{ + Name: converter.String(withRefsHeadsPrefix("a-branch")), + NewObjectId: &commit, + OldObjectId: converter.String("0000000000000000000000000000000000000000"), + }}, + RepositoryId: converter.String("a-repo"), + }). + Return(&[]git.GitRefUpdateResult{{ + Success: converter.Bool(false), + UpdateStatus: &git.GitRefUpdateStatusValues.InvalidRefName, + }}, nil) + return args{ + context.Background(), + d, + clients, + } + }, + diag.FromErr(fmt.Errorf("Error creating branch \"a-branch\": Error got invalid GitRefUpdate.UpdateStatus: invalidRefName")), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + gitClient := azdosdkmocks.NewMockGitClient(ctrl) + testArgs := tt.args(gitClient) + + if got := resourceGitRepositoryBranchCreate(testArgs.ctx, testArgs.d, testArgs.m); !reflect.DeepEqual(got, tt.want) { + t.Errorf("resourceGitRepositoryBranchCreate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGitRepositoryBranch_Read(t *testing.T) { + type args struct { + ctx context.Context + d *schema.ResourceData + m interface{} + } + tests := []struct { + name string + args func(g *azdosdkmocks.MockGitClient) args + want diag.Diagnostics + }{ + { + "Read does not swallow error.", + func(g *azdosdkmocks.MockGitClient) args { + clients := &client.AggregatedClient{ + GitReposClient: g, + Ctx: context.Background(), + } + + d := schema.TestResourceDataRaw(t, ResourceGitRepositoryBranch().Schema, nil) + d.Set("ref", "another-branch") + d.Set("name", "a-branch") + d.Set("repository_id", "a-repo") + d.SetId("a-repo:a-branch") + + g.EXPECT(). + GetBranch(clients.Ctx, git.GetBranchArgs{ + RepositoryId: converter.String("a-repo"), + Name: converter.String("a-branch"), + }). + Return(nil, fmt.Errorf("an-error")) + + return args{ + ctx: context.Background(), + d: d, + m: clients, + } + }, + diag.FromErr(fmt.Errorf("Error reading branch \"a-branch\": an-error")), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + gitClient := azdosdkmocks.NewMockGitClient(ctrl) + testArgs := tt.args(gitClient) + + if got := resourceGitRepositoryBranchRead(testArgs.ctx, testArgs.d, testArgs.m); !reflect.DeepEqual(got, tt.want) { + t.Errorf("resourceGitRepositoryBranchCreate() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGitRepositoryBranch_Delete(t *testing.T) { + type args struct { + ctx context.Context + d *schema.ResourceData + m interface{} + } + tests := []struct { + name string + args func(g *azdosdkmocks.MockGitClient) args + want diag.Diagnostics + }{ + { + "Delete based on repositoryId:branchName does not swallow error.", + func(g *azdosdkmocks.MockGitClient) args { + clients := &client.AggregatedClient{ + GitReposClient: g, + Ctx: context.Background(), + } + + d := schema.TestResourceDataRaw(t, ResourceGitRepositoryBranch().Schema, nil) + d.Set("ref", "another-branch") + d.Set("name", "a-branch") + d.Set("repository_id", "a-repo") + d.SetId("a-repo:a-branch") + + g.EXPECT(). + GetBranch(clients.Ctx, git.GetBranchArgs{ + RepositoryId: converter.String("a-repo"), + Name: converter.String("a-branch"), + }). + Return(&git.GitBranchStats{ + Commit: &git.GitCommitRef{ + CommitId: converter.String("a-commit"), + }, + }, nil) + + g.EXPECT(). + UpdateRefs(clients.Ctx, git.UpdateRefsArgs{ + RefUpdates: &[]git.GitRefUpdate{{ + Name: converter.String(withRefsHeadsPrefix("a-branch")), + OldObjectId: converter.String("a-commit"), + NewObjectId: converter.String("0000000000000000000000000000000000000000"), + }}, + RepositoryId: converter.String("a-repo"), + }). + Return(nil, fmt.Errorf("an-error")) + + return args{ + ctx: clients.Ctx, + d: d, + m: clients, + } + }, + diag.FromErr(fmt.Errorf("Error deleting branch \"a-branch\": an-error")), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + gitClient := azdosdkmocks.NewMockGitClient(ctrl) + testArgs := tt.args(gitClient) + + if got := resourceGitRepositoryBranchDelete(testArgs.ctx, testArgs.d, testArgs.m); !reflect.DeepEqual(got, tt.want) { + t.Errorf("resourceGitRepositoryBranchDelete() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/azuredevops/internal/service/git/resource_git_repository_test.go b/azuredevops/internal/service/git/resource_git_repository_test.go index 07973cf4..e1f5bb75 100644 --- a/azuredevops/internal/service/git/resource_git_repository_test.go +++ b/azuredevops/internal/service/git/resource_git_repository_test.go @@ -21,8 +21,10 @@ import ( "github.com/stretchr/testify/require" ) -var testRepoProjectID = uuid.New() -var testRepoID = uuid.New() +var ( + testRepoProjectID = uuid.New() + testRepoID = uuid.New() +) // This definition matches the overall structure of what a configured git repository would // look like. Note that the ID and Name attributes match -- this is the service-side behavior diff --git a/azuredevops/internal/utils/tfhelper/tfhelper.go b/azuredevops/internal/utils/tfhelper/tfhelper.go index c4c88646..f28a0ac5 100644 --- a/azuredevops/internal/utils/tfhelper/tfhelper.go +++ b/azuredevops/internal/utils/tfhelper/tfhelper.go @@ -110,6 +110,18 @@ func ParseProjectIDAndResourceID(d *schema.ResourceData) (string, int, error) { return projectID, resourceID, err } +func ParseGitRepoBranchID(id string) (string, string, error) { + return parseTwoPartID(id, ":", "repositoryID:branchName") +} + +func parseTwoPartID(id, sep, want string) (string, string, error) { + parts := strings.SplitN(id, sep, 2) + if len(parts) != 2 || strings.EqualFold(parts[0], "") || strings.EqualFold(parts[1], "") { + return "", "", fmt.Errorf("unexpected format of ID (%s), expected %s", id, want) + } + return parts[0], parts[1], nil +} + // ParseImportedID parse the imported int Id from the terraform import func ParseImportedID(id string) (string, int, error) { parts := strings.SplitN(id, "/", 2) @@ -175,7 +187,6 @@ func ImportProjectQualifiedResource() *schema.ResourceImporter { return &schema.ResourceImporter{ State: func(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { projectNameOrID, resourceID, err := ParseImportedName(d.Id()) - if err != nil { return nil, fmt.Errorf("error parsing the resource ID from the Terraform resource data: %v", err) } @@ -198,7 +209,6 @@ func ImportProjectQualifiedResourceInteger() *schema.ResourceImporter { return &schema.ResourceImporter{ State: func(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { projectNameOrID, resourceID, err := ParseImportedName(d.Id()) - if err != nil { return nil, fmt.Errorf("error parsing the resource ID from the Terraform resource data: %v", err) } @@ -226,7 +236,6 @@ func ImportProjectQualifiedResourceUUID() *schema.ResourceImporter { return &schema.ResourceImporter{ State: func(d *schema.ResourceData, meta interface{}) ([]*schema.ResourceData, error) { projectNameOrID, resourceID, err := ParseImportedUUID(d.Id()) - if err != nil { return nil, fmt.Errorf("error parsing the resource ID from the Terraform resource data: %v", err) } @@ -243,7 +252,7 @@ func ImportProjectQualifiedResourceUUID() *schema.ResourceImporter { // Get real project ID func GetRealProjectId(projectNameOrID string, meta interface{}) (string, error) { - //If request params is project name, try get the project ID + // If request params is project name, try get the project ID if _, err := uuid.ParseUUID(projectNameOrID); err != nil { clients := meta.(*client.AggregatedClient) project, err := clients.CoreClient.GetProject(clients.Ctx, core.GetProjectArgs{ diff --git a/azuredevops/provider.go b/azuredevops/provider.go index 9aa3726b..fbd21e0c 100644 --- a/azuredevops/provider.go +++ b/azuredevops/provider.go @@ -68,6 +68,7 @@ func Provider() *schema.Provider { "azuredevops_serviceendpoint_generic_git": serviceendpoint.ResourceServiceEndpointGenericGit(), "azuredevops_serviceendpoint_externaltfs": serviceendpoint.ResourceServiceEndpointExternalTFS(), "azuredevops_git_repository": git.ResourceGitRepository(), + "azuredevops_git_repository_branch": git.ResourceGitRepositoryBranch(), "azuredevops_git_repository_file": git.ResourceGitRepositoryFile(), "azuredevops_user_entitlement": memberentitlementmanagement.ResourceUserEntitlement(), "azuredevops_group_membership": graph.ResourceGroupMembership(), diff --git a/azuredevops/provider_test.go b/azuredevops/provider_test.go index 92d264c7..b8774385 100644 --- a/azuredevops/provider_test.go +++ b/azuredevops/provider_test.go @@ -53,6 +53,7 @@ func TestProvider_HasChildResources(t *testing.T) { "azuredevops_repository_policy_reserved_names", "azuredevops_repository_policy_check_credentials", "azuredevops_git_repository", + "azuredevops_git_repository_branch", "azuredevops_git_repository_file", "azuredevops_user_entitlement", "azuredevops_group_membership", diff --git a/website/docs/r/git_repository_branch.html.markdown b/website/docs/r/git_repository_branch.html.markdown new file mode 100644 index 00000000..dbf51c59 --- /dev/null +++ b/website/docs/r/git_repository_branch.html.markdown @@ -0,0 +1,78 @@ +--- +layout: "azuredevops" +page_title: "AzureDevops: azuredevops_git_repository_branch" +description: |- + Manages a Git Repository Branch. +--- + +# azuredevops_git_repository_branch + +Manages a Git Repository Branch. + +## Example Usage + +```hcl +resource "azuredevops_project" "example" { + name = "Example Project" + visibility = "private" + version_control = "Git" + work_item_template = "Agile" +} + +resource "azuredevops_git_repository" "example" { + project_id = azuredevops_project.example.id + name = "Example Git Repository" + initialization { + init_type = "Uninitialized" + } +} + +resource "azuredevops_git_repository_branch" "example_orphan" { + repository_id = azuredevops_git_repository.example.id + name = "master" +} + +resource "azuredevops_git_repository_branch" "example_from_ref" { + repository_id = azuredevops_git_repository.example.id + name = "develop" + source_ref = azuredevops_git_repository_branch.example_orphan.ref +} + +resource "azuredevops_git_repository_branch" "example_from_sha" { + repository_id = azuredevops_git_repository.example.id + name = "somebranch" + source_sha = azuredevops_git_repository_branch.example_orphan.sha +} +``` + +## Arguments Reference + +The following arguments are supported: + +- `name` - (Required) The name of the branch (not prefixed with `refs/heads/`). + +- `repository_id` - (Required) The ID of the repository the branch is created against. + +- `source_ref` - (Optional) The ref the branch is created from. (prefixed with `refs/heads/` or `refs/tags/`) + +- `source_sha` - (Optional) The commit object id the branch is created from. Set to commit object id of `source_ref` if not given. Otherwise, `source_ref` is ignored. + +## Attributes Reference + +In addition to the Arguments listed above - the following Attributes are exported: + +- `id` - The ID of the Git Repository Branch. + +- `is_default_branch` - True if the branch is the default branch of the git repository. + +- `ref` - The branch reference in `refs/heads/<name>` format. + +- `sha` - The commit SHA1 object id of the branch tip. + +## Import + +Git Repository Branches can be imported using the `resource id`, e.g. + +```shell +terraform import azuredevops_git_repository_branch.example 00000000-0000-0000-0000-000000000000:master +``` |
