diff --git a/github/resource_github_repository.go b/github/resource_github_repository.go index f28cc4c6e8..6af9b173d7 100644 --- a/github/resource_github_repository.go +++ b/github/resource_github_repository.go @@ -63,6 +63,24 @@ func resourceGithubRepository() *schema.Resource { ValidateDiagFunc: toDiagFunc(validation.StringInSlice([]string{"public", "private", "internal"}, false), "visibility"), Description: "Can be 'public' or 'private'. If your organization is associated with an enterprise account using GitHub Enterprise Cloud or GitHub Enterprise Server 2.20+, visibility can also be 'internal'.", }, + "fork": { + Type: schema.TypeBool, + Optional: true, + ForceNew: true, + Description: "Set to 'true' to fork an existing repository.", + }, + "source_owner": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The owner of the source repository to fork from.", + }, + "source_repo": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The name of the source repository to fork from.", + }, "security_and_analysis": { Type: schema.TypeList, Optional: true, @@ -556,37 +574,90 @@ func resourceGithubRepositoryCreate(d *schema.ResourceData, meta interface{}) er repoReq.Private = github.Bool(isPrivate) if template, ok := d.GetOk("template"); ok { - templateConfigBlocks := template.([]interface{}) - - for _, templateConfigBlock := range templateConfigBlocks { - templateConfigMap, ok := templateConfigBlock.(map[string]interface{}) - if !ok { - return errors.New("failed to unpack template configuration block") + templateConfigBlocks := template.([]interface{}) + + for _, templateConfigBlock := range templateConfigBlocks { + templateConfigMap, ok := templateConfigBlock.(map[string]interface{}) + if !ok { + return errors.New("failed to unpack template configuration block") + } + + templateRepo := templateConfigMap["repository"].(string) + templateRepoOwner := templateConfigMap["owner"].(string) + includeAllBranches := templateConfigMap["include_all_branches"].(bool) + + templateRepoReq := github.TemplateRepoRequest{ + Name: &repoName, + Owner: &owner, + Description: github.String(d.Get("description").(string)), + Private: github.Bool(isPrivate), + IncludeAllBranches: github.Bool(includeAllBranches), + } + + repo, _, err := client.Repositories.CreateFromTemplate(ctx, + templateRepoOwner, + templateRepo, + &templateRepoReq, + ) + if err != nil { + return err + } + + d.SetId(*repo.Name) + } + } else if d.Get("fork").(bool) { + // Handle repository forking + sourceOwner := d.Get("source_owner").(string) + sourceRepo := d.Get("source_repo").(string) + requestedName := d.Get("name").(string) + owner := meta.(*Owner).name + log.Printf("[INFO] Creating fork of %s/%s in %s", sourceOwner, sourceRepo, owner) + + if sourceOwner == "" || sourceRepo == "" { + return fmt.Errorf("source_owner and source_repo must be provided when forking a repository") } - - templateRepo := templateConfigMap["repository"].(string) - templateRepoOwner := templateConfigMap["owner"].(string) - includeAllBranches := templateConfigMap["include_all_branches"].(bool) - - templateRepoReq := github.TemplateRepoRequest{ - Name: &repoName, - Owner: &owner, - Description: github.String(d.Get("description").(string)), - Private: github.Bool(isPrivate), - IncludeAllBranches: github.Bool(includeAllBranches), + + // Create the fork using the GitHub client library + opts := &github.RepositoryCreateForkOptions{ + Name: requestedName, } - - repo, _, err := client.Repositories.CreateFromTemplate(ctx, - templateRepoOwner, - templateRepo, - &templateRepoReq, - ) + + if meta.(*Owner).IsOrganization { + opts.Organization = owner + } + + fork, resp, err := client.Repositories.CreateFork(ctx, sourceOwner, sourceRepo, opts) + if err != nil { - return err + // Handle accepted error (202) which means the fork is being created asynchronously + if _, ok := err.(*github.AcceptedError); ok { + log.Printf("[INFO] Fork is being created asynchronously") + // Despite the 202 status, the API should still return preliminary fork information + if fork == nil { + return fmt.Errorf("fork information not available after accepted status") + } + log.Printf("[DEBUG] Fork name: %s", fork.GetName()) + } else { + return fmt.Errorf("failed to create fork: %v", err) + } + } else if resp != nil { + log.Printf("[DEBUG] Fork response status: %d", resp.StatusCode) } - - d.SetId(*repo.Name) - } + + if fork == nil { + return fmt.Errorf("fork creation failed - no repository returned") + } + + log.Printf("[INFO] Fork created with name: %s", fork.GetName()) + d.SetId(fork.GetName()) + log.Printf("[DEBUG] Set resource ID to just the name: %s", d.Id()) + + d.Set("name", fork.GetName()) + d.Set("full_name", fork.GetFullName()) // Add the full name for reference + d.Set("html_url", fork.GetHTMLURL()) + d.Set("ssh_clone_url", fork.GetSSHURL()) + d.Set("git_clone_url", fork.GetGitURL()) + d.Set("http_clone_url", fork.GetCloneURL()) } else { // Create without a repository template var repo *github.Repository @@ -705,6 +776,21 @@ func resourceGithubRepositoryRead(d *schema.ResourceData, meta interface{}) erro } } + // Set fork information if this is a fork + if repo.GetFork() { + d.Set("fork", true) + + // If the repository has parent information, set the source details + if repo.Parent != nil { + d.Set("source_owner", repo.Parent.GetOwner().GetLogin()) + d.Set("source_repo", repo.Parent.GetName()) + } + } else { + d.Set("fork", false) + d.Set("source_owner", "") + d.Set("source_repo", "") + } + if repo.TemplateRepository != nil { if err = d.Set("template", []interface{}{ map[string]interface{}{ diff --git a/github/resource_github_repository_test.go b/github/resource_github_repository_test.go index e09505dac9..aea4d7cb4d 100644 --- a/github/resource_github_repository_test.go +++ b/github/resource_github_repository_test.go @@ -1700,3 +1700,155 @@ func TestGithubRepositoryNameFailsValidationWithSpace(t *testing.T) { t.Error(fmt.Errorf("unexpected name validation failure; expected=%s; action=%s", expectedFailure, actualFailure)) } } + +func TestAccGithubRepository_fork(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + t.Run("forks a repository without error", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_repository" "forked" { + name = "terraform-provider-github-%s" + description = "Terraform acceptance test - forked repository %[1]s" + fork = true + source_owner = "integrations" + source_repo = "terraform-provider-github" + } + `, randomID) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_repository.forked", "fork", + "true", + ), + resource.TestCheckResourceAttrSet( + "github_repository.forked", "html_url", + ), + resource.TestCheckResourceAttrSet( + "github_repository.forked", "ssh_clone_url", + ), + resource.TestCheckResourceAttrSet( + "github_repository.forked", "git_clone_url", + ), + resource.TestCheckResourceAttrSet( + "github_repository.forked", "http_clone_url", + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + }, + }) + } + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + }) + + t.Run("can update forked repository properties", func(t *testing.T) { + initialConfig := fmt.Sprintf(` + resource "github_repository" "forked_update" { + name = "terraform-provider-github-update-%s" + description = "Initial description for forked repo" + fork = true + source_owner = "integrations" + source_repo = "terraform-provider-github" + has_wiki = true + has_issues = false + } + `, randomID) + + updatedConfig := fmt.Sprintf(` + resource "github_repository" "forked_update" { + name = "terraform-provider-github-update-%s" + description = "Updated description for forked repo" + fork = true + source_owner = "integrations" + source_repo = "terraform-provider-github" + has_wiki = false + has_issues = true + } + `, randomID) + + checks := map[string]resource.TestCheckFunc{ + "before": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_repository.forked_update", "description", + "Initial description for forked repo", + ), + resource.TestCheckResourceAttr( + "github_repository.forked_update", "has_wiki", + "true", + ), + resource.TestCheckResourceAttr( + "github_repository.forked_update", "has_issues", + "false", + ), + ), + "after": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_repository.forked_update", "description", + "Updated description for forked repo", + ), + resource.TestCheckResourceAttr( + "github_repository.forked_update", "has_wiki", + "false", + ), + resource.TestCheckResourceAttr( + "github_repository.forked_update", "has_issues", + "true", + ), + ), + } + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: initialConfig, + Check: checks["before"], + }, + { + Config: updatedConfig, + Check: checks["after"], + }, + { + ResourceName: "github_repository.forked_update", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{ "auto_init"}, + }, + }, + }) + } + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + }) +} \ No newline at end of file diff --git a/website/docs/r/repository.html.markdown b/website/docs/r/repository.html.markdown index dc18add1d6..e9ec145d20 100644 --- a/website/docs/r/repository.html.markdown +++ b/website/docs/r/repository.html.markdown @@ -47,6 +47,18 @@ resource "github_repository" "example" { } ``` +## Example Usage with Repository Forking + +```hcl +resource "github_repository" "forked_repo" { + name = "forked-repository" + description = "This is a fork of another repository" + fork = true + source_owner = "some-org" + source_repo = "original-repository" +} +``` + ## Argument Reference The following arguments are supported: @@ -57,6 +69,12 @@ The following arguments are supported: * `homepage_url` - (Optional) URL of a page describing the project. +* `fork` - (Optional) Set to `true` to create a fork of an existing repository. When set to `true`, both `source_owner` and `source_repo` must also be specified. + +* `source_owner` - (Optional) The GitHub username or organization that owns the repository being forked. Required when `fork` is `true`. + +* `source_repo` - (Optional) The name of the repository to fork. Required when `fork` is `true`. + * `private` - (Optional) Set to `true` to create a private repository. Repositories are created as public (e.g. open source) by default.