diff --git a/github/repository_utils.go b/github/repository_utils.go index 542da328dc..bc8e7ff7be 100644 --- a/github/repository_utils.go +++ b/github/repository_utils.go @@ -3,6 +3,7 @@ package github import ( "context" "fmt" + "log" "net/http" "strings" @@ -124,6 +125,38 @@ func listAutolinks(client *github.Client, owner, repo string) ([]*github.Autolin return allAutolinks, nil } +// isArchivedRepositoryError checks if an error is a 403 "repository archived" error. +// Returns true if the repository is archived. +func isArchivedRepositoryError(err error) bool { + if ghErr, ok := err.(*github.ErrorResponse); ok { + if ghErr.Response.StatusCode == http.StatusForbidden { + return strings.Contains(strings.ToLower(ghErr.Message), "archived") + } + } + return false +} + +// handleArchivedRepositoryError handles errors for operations on archived repositories. +// If the repository is archived, it logs a message and returns nil, otherwise, it returns the original error. +func handleArchivedRepositoryError(err error, operation, resource, owner, repo string) error { + if err == nil { + return nil + } + + if isArchivedRepositoryError(err) { + log.Printf("[INFO] Skipping %s of %s from archived repository %s/%s", operation, resource, owner, repo) + return nil + } + + return err +} + +// handleArchivedRepoDelete is a convenience wrapper for handleArchivedRepositoryError +// specifically for delete operations, which is the most common use case. +func handleArchivedRepoDelete(err error, resourceType, resourceName, owner, repo string) error { + return handleArchivedRepositoryError(err, "deletion", fmt.Sprintf("%s %s", resourceType, resourceName), owner, repo) +} + // get the list of retriable errors func getDefaultRetriableErrors() map[int]bool { return map[int]bool{ diff --git a/github/resource_github_issue_label.go b/github/resource_github_issue_label.go index e58ceb2ea8..b180ea26d6 100644 --- a/github/resource_github_issue_label.go +++ b/github/resource_github_issue_label.go @@ -197,7 +197,6 @@ func resourceGithubIssueLabelDelete(d *schema.ResourceData, meta interface{}) er name := d.Get("name").(string) ctx := context.WithValue(context.Background(), ctxId, d.Id()) - _, err := client.Issues.DeleteLabel(ctx, - orgName, repoName, name) - return err + _, err := client.Issues.DeleteLabel(ctx, orgName, repoName, name) + return handleArchivedRepoDelete(err, "issue label", name, orgName, repoName) } diff --git a/github/resource_github_issue_label_test.go b/github/resource_github_issue_label_test.go index 0e5e9bc1c0..1bcc779321 100644 --- a/github/resource_github_issue_label_test.go +++ b/github/resource_github_issue_label_test.go @@ -80,4 +80,72 @@ func TestAccGithubIssueLabel(t *testing.T) { }) }) + t.Run("can delete labels from archived repositories without error", func(t *testing.T) { + + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-archive-%s" + auto_init = true + } + + resource "github_issue_label" "test" { + repository = github_repository.test.name + name = "archived-test-label" + color = "ff0000" + description = "Test label for archived repo" + } + `, randomID) + + archivedConfig := strings.Replace(config, + `auto_init = true`, + `auto_init = true + archived = true`, 1) + + 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: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_issue_label.test", "name", + "archived-test-label", + ), + ), + }, + { + Config: archivedConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_repository.test", "archived", + "true", + ), + ), + }, + // This step should succeed - the label should be removed from state + // without trying to actually delete it from the archived repo + { + Config: fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-archive-%s" + auto_init = true + archived = true + } + `, randomID), + }, + }, + }) + } + + 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) + }) + }) + } diff --git a/github/resource_github_issue_labels.go b/github/resource_github_issue_labels.go index 63d5fdfc82..c8b7df68e1 100644 --- a/github/resource_github_issue_labels.go +++ b/github/resource_github_issue_labels.go @@ -189,6 +189,10 @@ func resourceGithubIssueLabelsDelete(d *schema.ResourceData, meta interface{}) e _, err := client.Issues.DeleteLabel(ctx, owner, repository, name) if err != nil { + if isArchivedRepositoryError(err) { + log.Printf("[INFO] Skipping deletion of remaining issue labels from archived repository %s/%s", owner, repository) + break // Skip deleting remaining labels + } return err } } diff --git a/github/resource_github_issue_labels_test.go b/github/resource_github_issue_labels_test.go index 5f1c3bfb5f..934927a1e1 100644 --- a/github/resource_github_issue_labels_test.go +++ b/github/resource_github_issue_labels_test.go @@ -3,6 +3,7 @@ package github import ( "context" "fmt" + "strings" "testing" "github.com/google/go-github/v67/github" @@ -159,3 +160,84 @@ func testAccGithubIssueLabelsAddLabel(repository, label string) error { _, _, err := client.Issues.CreateLabel(ctx, orgName, repository, &github.Label{Name: github.String(label)}) return err } + +func TestAccGithubIssueLabelsArchived(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + t.Run("can delete labels from archived repositories without error", func(t *testing.T) { + + repoName := fmt.Sprintf("tf-acc-test-labels-archive-%s", randomID) + + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "%s" + auto_init = true + } + + resource "github_issue_labels" "test" { + repository = github_repository.test.name + label { + name = "archived-label-1" + color = "ff0000" + description = "First test label" + } + label { + name = "archived-label-2" + color = "00ff00" + description = "Second test label" + } + } + `, repoName) + + archivedConfig := strings.Replace(config, + `auto_init = true`, + `auto_init = true + archived = true`, 1) + + 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: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_issue_labels.test", "label.#", + "2", + ), + ), + }, + { + Config: archivedConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_repository.test", "archived", + "true", + ), + ), + }, + // This step should succeed - the labels should be removed from state + // without trying to actually delete them from the archived repo + { + Config: fmt.Sprintf(` + resource "github_repository" "test" { + name = "%s" + auto_init = true + archived = true + } + `, repoName), + }, + }, + }) + } + + 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) + }) + }) +} diff --git a/github/resource_github_repository_file.go b/github/resource_github_repository_file.go index 058c18c5ae..576c25a892 100644 --- a/github/resource_github_repository_file.go +++ b/github/resource_github_repository_file.go @@ -504,11 +504,7 @@ func resourceGithubRepositoryFileDelete(d *schema.ResourceData, meta interface{} } _, _, err := client.Repositories.DeleteFile(ctx, owner, repo, file, opts) - if err != nil { - return nil - } - - return nil + return handleArchivedRepoDelete(err, "repository file", file, owner, repo) } func autoBranchDiffSuppressFunc(k, _, _ string, d *schema.ResourceData) bool { diff --git a/github/resource_github_repository_file_test.go b/github/resource_github_repository_file_test.go index 0a2da9aa5a..48a0376527 100644 --- a/github/resource_github_repository_file_test.go +++ b/github/resource_github_repository_file_test.go @@ -344,4 +344,76 @@ func TestAccGithubRepositoryFile(t *testing.T) { }) }) + + t.Run("can delete files from archived repositories without error", func(t *testing.T) { + + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-file-archive-%s" + auto_init = true + } + + resource "github_repository_file" "test" { + repository = github_repository.test.name + branch = "main" + file = "archived-test.md" + content = "# Test file for archived repo" + commit_message = "Add test file" + commit_author = "Terraform User" + commit_email = "terraform@example.com" + } + `, randomID) + + archivedConfig := strings.Replace(config, + `auto_init = true`, + `auto_init = true + archived = true`, 1) + + 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: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_repository_file.test", "file", + "archived-test.md", + ), + ), + }, + { + Config: archivedConfig, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_repository.test", "archived", + "true", + ), + ), + }, + // This step should succeed - the file should be removed from state + // without trying to actually delete it from the archived repo + { + Config: fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-file-archive-%s" + auto_init = true + archived = true + } + `, randomID), + }, + }, + }) + } + + 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) + }) + + }) } diff --git a/website/docs/r/issue_label.html.markdown b/website/docs/r/issue_label.html.markdown index d3989e5759..4dd02cb9db 100644 --- a/website/docs/r/issue_label.html.markdown +++ b/website/docs/r/issue_label.html.markdown @@ -20,6 +20,8 @@ and those labels easily conflict with custom ones. This resource will first check if the label exists, and then issue an update, otherwise it will create. +~> **Note:** When a repository is archived, Terraform will skip deletion of issue labels to avoid API errors, as archived repositories are read-only. The labels will be removed from Terraform state without attempting to delete them from GitHub. + ## Example Usage ```hcl diff --git a/website/docs/r/issue_labels.html.markdown b/website/docs/r/issue_labels.html.markdown index b912fd4d13..74e32fa29e 100644 --- a/website/docs/r/issue_labels.html.markdown +++ b/website/docs/r/issue_labels.html.markdown @@ -18,6 +18,8 @@ This resource is authoritative. For adding a label to a repo in a non-authoritat If you change the case of a label's name, its' color, or description, this resource will edit the existing label to match the new values. However, if you change the name of a label, this resource will create a new label with the new name and delete the old label. Beware that this will remove the label from any issues it was previously attached to. +~> **Note:** When a repository is archived, Terraform will skip deletion of issue labels to avoid API errors, as archived repositories are read-only. The labels will be removed from Terraform state without attempting to delete them from GitHub. + ## Example Usage ```hcl diff --git a/website/docs/r/repository_file.html.markdown b/website/docs/r/repository_file.html.markdown index 907c7209aa..cf614f9912 100644 --- a/website/docs/r/repository_file.html.markdown +++ b/website/docs/r/repository_file.html.markdown @@ -10,6 +10,7 @@ description: |- This resource allows you to create and manage files within a GitHub repository. +~> **Note:** When a repository is archived, Terraform will skip deletion of repository files to avoid API errors, as archived repositories are read-only. The files will be removed from Terraform state without attempting to delete them from GitHub. ## Example Usage