Skip to content

feat(core): add fork functionality #2674

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 15 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 113 additions & 27 deletions github/resource_github_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{}{
Expand Down
152 changes: 152 additions & 0 deletions github/resource_github_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
})
}
18 changes: 18 additions & 0 deletions website/docs/r/repository.html.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.

Expand Down