Skip to content

Commit 1aa6d2b

Browse files
authored
feat: Add forking functionality (#2678)
1 parent 3f9dc73 commit 1aa6d2b

File tree

3 files changed

+283
-27
lines changed

3 files changed

+283
-27
lines changed

github/resource_github_repository.go

Lines changed: 113 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,24 @@ func resourceGithubRepository() *schema.Resource {
6363
ValidateDiagFunc: toDiagFunc(validation.StringInSlice([]string{"public", "private", "internal"}, false), "visibility"),
6464
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'.",
6565
},
66+
"fork": {
67+
Type: schema.TypeBool,
68+
Optional: true,
69+
ForceNew: true,
70+
Description: "Set to 'true' to fork an existing repository.",
71+
},
72+
"source_owner": {
73+
Type: schema.TypeString,
74+
Optional: true,
75+
ForceNew: true,
76+
Description: "The owner of the source repository to fork from.",
77+
},
78+
"source_repo": {
79+
Type: schema.TypeString,
80+
Optional: true,
81+
ForceNew: true,
82+
Description: "The name of the source repository to fork from.",
83+
},
6684
"security_and_analysis": {
6785
Type: schema.TypeList,
6886
Optional: true,
@@ -561,37 +579,90 @@ func resourceGithubRepositoryCreate(d *schema.ResourceData, meta interface{}) er
561579
repoReq.Private = github.Bool(isPrivate)
562580

563581
if template, ok := d.GetOk("template"); ok {
564-
templateConfigBlocks := template.([]interface{})
565-
566-
for _, templateConfigBlock := range templateConfigBlocks {
567-
templateConfigMap, ok := templateConfigBlock.(map[string]interface{})
568-
if !ok {
569-
return errors.New("failed to unpack template configuration block")
582+
templateConfigBlocks := template.([]interface{})
583+
584+
for _, templateConfigBlock := range templateConfigBlocks {
585+
templateConfigMap, ok := templateConfigBlock.(map[string]interface{})
586+
if !ok {
587+
return errors.New("failed to unpack template configuration block")
588+
}
589+
590+
templateRepo := templateConfigMap["repository"].(string)
591+
templateRepoOwner := templateConfigMap["owner"].(string)
592+
includeAllBranches := templateConfigMap["include_all_branches"].(bool)
593+
594+
templateRepoReq := github.TemplateRepoRequest{
595+
Name: &repoName,
596+
Owner: &owner,
597+
Description: github.String(d.Get("description").(string)),
598+
Private: github.Bool(isPrivate),
599+
IncludeAllBranches: github.Bool(includeAllBranches),
600+
}
601+
602+
repo, _, err := client.Repositories.CreateFromTemplate(ctx,
603+
templateRepoOwner,
604+
templateRepo,
605+
&templateRepoReq,
606+
)
607+
if err != nil {
608+
return err
609+
}
610+
611+
d.SetId(*repo.Name)
612+
}
613+
} else if d.Get("fork").(bool) {
614+
// Handle repository forking
615+
sourceOwner := d.Get("source_owner").(string)
616+
sourceRepo := d.Get("source_repo").(string)
617+
requestedName := d.Get("name").(string)
618+
owner := meta.(*Owner).name
619+
log.Printf("[INFO] Creating fork of %s/%s in %s", sourceOwner, sourceRepo, owner)
620+
621+
if sourceOwner == "" || sourceRepo == "" {
622+
return fmt.Errorf("source_owner and source_repo must be provided when forking a repository")
570623
}
571-
572-
templateRepo := templateConfigMap["repository"].(string)
573-
templateRepoOwner := templateConfigMap["owner"].(string)
574-
includeAllBranches := templateConfigMap["include_all_branches"].(bool)
575-
576-
templateRepoReq := github.TemplateRepoRequest{
577-
Name: &repoName,
578-
Owner: &owner,
579-
Description: github.String(d.Get("description").(string)),
580-
Private: github.Bool(isPrivate),
581-
IncludeAllBranches: github.Bool(includeAllBranches),
624+
625+
// Create the fork using the GitHub client library
626+
opts := &github.RepositoryCreateForkOptions{
627+
Name: requestedName,
582628
}
583-
584-
repo, _, err := client.Repositories.CreateFromTemplate(ctx,
585-
templateRepoOwner,
586-
templateRepo,
587-
&templateRepoReq,
588-
)
629+
630+
if meta.(*Owner).IsOrganization {
631+
opts.Organization = owner
632+
}
633+
634+
fork, resp, err := client.Repositories.CreateFork(ctx, sourceOwner, sourceRepo, opts)
635+
589636
if err != nil {
590-
return err
637+
// Handle accepted error (202) which means the fork is being created asynchronously
638+
if _, ok := err.(*github.AcceptedError); ok {
639+
log.Printf("[INFO] Fork is being created asynchronously")
640+
// Despite the 202 status, the API should still return preliminary fork information
641+
if fork == nil {
642+
return fmt.Errorf("fork information not available after accepted status")
643+
}
644+
log.Printf("[DEBUG] Fork name: %s", fork.GetName())
645+
} else {
646+
return fmt.Errorf("failed to create fork: %v", err)
647+
}
648+
} else if resp != nil {
649+
log.Printf("[DEBUG] Fork response status: %d", resp.StatusCode)
591650
}
592-
593-
d.SetId(*repo.Name)
594-
}
651+
652+
if fork == nil {
653+
return fmt.Errorf("fork creation failed - no repository returned")
654+
}
655+
656+
log.Printf("[INFO] Fork created with name: %s", fork.GetName())
657+
d.SetId(fork.GetName())
658+
log.Printf("[DEBUG] Set resource ID to just the name: %s", d.Id())
659+
660+
d.Set("name", fork.GetName())
661+
d.Set("full_name", fork.GetFullName()) // Add the full name for reference
662+
d.Set("html_url", fork.GetHTMLURL())
663+
d.Set("ssh_clone_url", fork.GetSSHURL())
664+
d.Set("git_clone_url", fork.GetGitURL())
665+
d.Set("http_clone_url", fork.GetCloneURL())
595666
} else {
596667
// Create without a repository template
597668
var repo *github.Repository
@@ -715,6 +786,21 @@ func resourceGithubRepositoryRead(d *schema.ResourceData, meta interface{}) erro
715786
}
716787
}
717788

789+
// Set fork information if this is a fork
790+
if repo.GetFork() {
791+
d.Set("fork", true)
792+
793+
// If the repository has parent information, set the source details
794+
if repo.Parent != nil {
795+
d.Set("source_owner", repo.Parent.GetOwner().GetLogin())
796+
d.Set("source_repo", repo.Parent.GetName())
797+
}
798+
} else {
799+
d.Set("fork", false)
800+
d.Set("source_owner", "")
801+
d.Set("source_repo", "")
802+
}
803+
718804
if repo.TemplateRepository != nil {
719805
if err = d.Set("template", []interface{}{
720806
map[string]interface{}{

github/resource_github_repository_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1809,3 +1809,155 @@ func TestGithubRepositoryNameFailsValidationWithSpace(t *testing.T) {
18091809
t.Error(fmt.Errorf("unexpected name validation failure; expected=%s; action=%s", expectedFailure, actualFailure))
18101810
}
18111811
}
1812+
1813+
func TestAccGithubRepository_fork(t *testing.T) {
1814+
randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)
1815+
1816+
t.Run("forks a repository without error", func(t *testing.T) {
1817+
config := fmt.Sprintf(`
1818+
resource "github_repository" "forked" {
1819+
name = "terraform-provider-github-%s"
1820+
description = "Terraform acceptance test - forked repository %[1]s"
1821+
fork = true
1822+
source_owner = "integrations"
1823+
source_repo = "terraform-provider-github"
1824+
}
1825+
`, randomID)
1826+
1827+
check := resource.ComposeTestCheckFunc(
1828+
resource.TestCheckResourceAttr(
1829+
"github_repository.forked", "fork",
1830+
"true",
1831+
),
1832+
resource.TestCheckResourceAttrSet(
1833+
"github_repository.forked", "html_url",
1834+
),
1835+
resource.TestCheckResourceAttrSet(
1836+
"github_repository.forked", "ssh_clone_url",
1837+
),
1838+
resource.TestCheckResourceAttrSet(
1839+
"github_repository.forked", "git_clone_url",
1840+
),
1841+
resource.TestCheckResourceAttrSet(
1842+
"github_repository.forked", "http_clone_url",
1843+
),
1844+
)
1845+
1846+
testCase := func(t *testing.T, mode string) {
1847+
resource.Test(t, resource.TestCase{
1848+
PreCheck: func() { skipUnlessMode(t, mode) },
1849+
Providers: testAccProviders,
1850+
Steps: []resource.TestStep{
1851+
{
1852+
Config: config,
1853+
Check: check,
1854+
},
1855+
},
1856+
})
1857+
}
1858+
1859+
t.Run("with an individual account", func(t *testing.T) {
1860+
testCase(t, individual)
1861+
})
1862+
1863+
t.Run("with an organization account", func(t *testing.T) {
1864+
testCase(t, organization)
1865+
})
1866+
1867+
t.Run("with an anonymous account", func(t *testing.T) {
1868+
t.Skip("anonymous account not supported for this operation")
1869+
})
1870+
})
1871+
1872+
t.Run("can update forked repository properties", func(t *testing.T) {
1873+
initialConfig := fmt.Sprintf(`
1874+
resource "github_repository" "forked_update" {
1875+
name = "terraform-provider-github-update-%s"
1876+
description = "Initial description for forked repo"
1877+
fork = true
1878+
source_owner = "integrations"
1879+
source_repo = "terraform-provider-github"
1880+
has_wiki = true
1881+
has_issues = false
1882+
}
1883+
`, randomID)
1884+
1885+
updatedConfig := fmt.Sprintf(`
1886+
resource "github_repository" "forked_update" {
1887+
name = "terraform-provider-github-update-%s"
1888+
description = "Updated description for forked repo"
1889+
fork = true
1890+
source_owner = "integrations"
1891+
source_repo = "terraform-provider-github"
1892+
has_wiki = false
1893+
has_issues = true
1894+
}
1895+
`, randomID)
1896+
1897+
checks := map[string]resource.TestCheckFunc{
1898+
"before": resource.ComposeTestCheckFunc(
1899+
resource.TestCheckResourceAttr(
1900+
"github_repository.forked_update", "description",
1901+
"Initial description for forked repo",
1902+
),
1903+
resource.TestCheckResourceAttr(
1904+
"github_repository.forked_update", "has_wiki",
1905+
"true",
1906+
),
1907+
resource.TestCheckResourceAttr(
1908+
"github_repository.forked_update", "has_issues",
1909+
"false",
1910+
),
1911+
),
1912+
"after": resource.ComposeTestCheckFunc(
1913+
resource.TestCheckResourceAttr(
1914+
"github_repository.forked_update", "description",
1915+
"Updated description for forked repo",
1916+
),
1917+
resource.TestCheckResourceAttr(
1918+
"github_repository.forked_update", "has_wiki",
1919+
"false",
1920+
),
1921+
resource.TestCheckResourceAttr(
1922+
"github_repository.forked_update", "has_issues",
1923+
"true",
1924+
),
1925+
),
1926+
}
1927+
1928+
testCase := func(t *testing.T, mode string) {
1929+
resource.Test(t, resource.TestCase{
1930+
PreCheck: func() { skipUnlessMode(t, mode) },
1931+
Providers: testAccProviders,
1932+
Steps: []resource.TestStep{
1933+
{
1934+
Config: initialConfig,
1935+
Check: checks["before"],
1936+
},
1937+
{
1938+
Config: updatedConfig,
1939+
Check: checks["after"],
1940+
},
1941+
{
1942+
ResourceName: "github_repository.forked_update",
1943+
ImportState: true,
1944+
ImportStateVerify: true,
1945+
ImportStateVerifyIgnore: []string{ "auto_init"},
1946+
},
1947+
},
1948+
})
1949+
}
1950+
1951+
t.Run("with an individual account", func(t *testing.T) {
1952+
testCase(t, individual)
1953+
})
1954+
1955+
t.Run("with an organization account", func(t *testing.T) {
1956+
testCase(t, organization)
1957+
})
1958+
1959+
t.Run("with an anonymous account", func(t *testing.T) {
1960+
t.Skip("anonymous account not supported for this operation")
1961+
})
1962+
})
1963+
}

website/docs/r/repository.html.markdown

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ resource "github_repository" "example" {
4747
}
4848
```
4949

50+
## Example Usage with Repository Forking
51+
52+
```hcl
53+
resource "github_repository" "forked_repo" {
54+
name = "forked-repository"
55+
description = "This is a fork of another repository"
56+
fork = true
57+
source_owner = "some-org"
58+
source_repo = "original-repository"
59+
}
60+
```
61+
5062
## Argument Reference
5163

5264
The following arguments are supported:
@@ -57,6 +69,12 @@ The following arguments are supported:
5769

5870
* `homepage_url` - (Optional) URL of a page describing the project.
5971

72+
* `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.
73+
74+
* `source_owner` - (Optional) The GitHub username or organization that owns the repository being forked. Required when `fork` is `true`.
75+
76+
* `source_repo` - (Optional) The name of the repository to fork. Required when `fork` is `true`.
77+
6078
* `private` - (Optional) Set to `true` to create a private repository.
6179
Repositories are created as public (e.g. open source) by default.
6280

0 commit comments

Comments
 (0)