From ec6408ea49fd6c4811304d1011ccb1750052e5f2 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 13 Jul 2026 23:34:48 +0100 Subject: [PATCH 01/12] feat: Add github_enterprise_app_installation resource Adds a new resource for installing a GitHub App on an enterprise-owned organization via the enterprise organization-installations API, including support for repository_selection = "all". Available on GitHub Enterprise Cloud and GitHub Enterprise Server 3.19+. Resolves #3460 Co-Authored-By: Claude Fable 5 --- RESOURCES.md | 1 + docs/resources/enterprise_app_installation.md | 56 +++ .../enterprise_app_installation/example_1.tf | 14 + github/provider.go | 1 + ...urce_github_enterprise_app_installation.go | 364 ++++++++++++++++++ ...github_enterprise_app_installation_test.go | 116 ++++++ .../enterprise_app_installation.md.tmpl | 41 ++ 7 files changed, 593 insertions(+) create mode 100644 docs/resources/enterprise_app_installation.md create mode 100644 examples/resources/enterprise_app_installation/example_1.tf create mode 100644 github/resource_github_enterprise_app_installation.go create mode 100644 github/resource_github_enterprise_app_installation_test.go create mode 100644 templates/resources/enterprise_app_installation.md.tmpl diff --git a/RESOURCES.md b/RESOURCES.md index b1b1c3e2b0..04370b5cc4 100644 --- a/RESOURCES.md +++ b/RESOURCES.md @@ -160,6 +160,7 @@ The overall status of each resource or data source is captured in this document | `github_enterprise_actions_permissions` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_enterprise_actions_runner_group` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_enterprise_actions_workflow_permissions` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | +| `github_enterprise_app_installation` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_enterprise_ip_allow_list_entry` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_enterprise_organization` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_enterprise_security_analysis_settings` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | diff --git a/docs/resources/enterprise_app_installation.md b/docs/resources/enterprise_app_installation.md new file mode 100644 index 0000000000..1ec0c657c4 --- /dev/null +++ b/docs/resources/enterprise_app_installation.md @@ -0,0 +1,56 @@ +--- +page_title: "github_enterprise_app_installation (Resource) - GitHub" +description: |- + Manages a GitHub App installation on an enterprise-owned organization. +--- + +# github_enterprise_app_installation (Resource) + +This resource allows you to install a GitHub App on an organization owned by a GitHub Enterprise, including granting the app access to **all** repositories in the organization. + +~> **Note**: This resource is only available on GitHub Enterprise Cloud and GitHub Enterprise Server 3.19 or later. The authenticated user must be an [enterprise owner](https://docs.github.com/en/enterprise-cloud@latest/admin/managing-accounts-and-repositories/managing-users-in-your-enterprise/roles-in-an-enterprise#enterprise-owners), and the API is not available for organizations that are not part of an enterprise. To manage the repositories of an app installation on a non-enterprise organization, see the `github_app_installation_repositories` resource. + +## Example Usage + +```terraform +resource "github_enterprise_app_installation" "all_repos" { + enterprise_slug = "my-enterprise" + organization = "my-org" + client_id = "Iv1.abc123def456" + repository_selection = "all" +} + +resource "github_enterprise_app_installation" "selected_repos" { + enterprise_slug = "my-enterprise" + organization = "my-org" + client_id = "Iv1.789ghi012jkl" + repository_selection = "selected" + selected_repositories = ["my-repo-1", "my-repo-2"] +} +``` + +## Argument Reference + +The following arguments are supported: + +- `enterprise_slug` - (Required) The slug of the enterprise that owns the organization. +- `organization` - (Required) The login of the enterprise-owned organization to install the app on. +- `client_id` - (Required) The client ID of the GitHub App to install. +- `repository_selection` - (Required) The repositories the installation can access. Can be one of `all`, `selected` or `none`. Changing between `all` and `selected` is applied in place; changing from or to `none` will recreate the installation. +- `selected_repositories` - (Optional) The names of the repositories the installation can access. Required when `repository_selection` is `selected`, and must not be set otherwise. + +## Attributes Reference + +The following additional attributes are exported: + +- `id` - The ID of the resource in the format `::`. +- `installation_id` - The ID of the app installation. +- `app_slug` - The slug of the installed app. + +## Import + +GitHub enterprise app installations can be imported using the enterprise slug, the organization login and the app client ID, separated by `:` characters. + +```shell +terraform import github_enterprise_app_installation.all_repos my-enterprise:my-org:Iv1.abc123def456 +``` diff --git a/examples/resources/enterprise_app_installation/example_1.tf b/examples/resources/enterprise_app_installation/example_1.tf new file mode 100644 index 0000000000..d3acb06efe --- /dev/null +++ b/examples/resources/enterprise_app_installation/example_1.tf @@ -0,0 +1,14 @@ +resource "github_enterprise_app_installation" "all_repos" { + enterprise_slug = "my-enterprise" + organization = "my-org" + client_id = "Iv1.abc123def456" + repository_selection = "all" +} + +resource "github_enterprise_app_installation" "selected_repos" { + enterprise_slug = "my-enterprise" + organization = "my-org" + client_id = "Iv1.789ghi012jkl" + repository_selection = "selected" + selected_repositories = ["my-repo-1", "my-repo-2"] +} diff --git a/github/provider.go b/github/provider.go index 0bd2720b05..6cf62d8c02 100644 --- a/github/provider.go +++ b/github/provider.go @@ -240,6 +240,7 @@ func NewProvider(version, commit string) func() *schema.Provider { "github_user_gpg_key": resourceGithubUserGpgKey(), "github_user_invitation_accepter": resourceGithubUserInvitationAccepter(), "github_user_ssh_key": resourceGithubUserSshKey(), + "github_enterprise_app_installation": resourceGithubEnterpriseAppInstallation(), "github_enterprise_organization": resourceGithubEnterpriseOrganization(), "github_enterprise_actions_runner_group": resourceGithubActionsEnterpriseRunnerGroup(), "github_enterprise_ip_allow_list_entry": resourceGithubEnterpriseIpAllowListEntry(), diff --git a/github/resource_github_enterprise_app_installation.go b/github/resource_github_enterprise_app_installation.go new file mode 100644 index 0000000000..721e55b546 --- /dev/null +++ b/github/resource_github_enterprise_app_installation.go @@ -0,0 +1,364 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "slices" + "strconv" + + "github.com/google/go-github/v89/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// maxInstallationRepositoriesPerRequest is the maximum number of repositories +// the enterprise organization-installations endpoints accept per request. +const maxInstallationRepositoriesPerRequest = 50 + +func resourceGithubEnterpriseAppInstallation() *schema.Resource { + return &schema.Resource{ + Description: "Manage a GitHub App installation on an enterprise-owned organization. " + + "This resource requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner.", + CreateContext: resourceGithubEnterpriseAppInstallationCreate, + ReadContext: resourceGithubEnterpriseAppInstallationRead, + UpdateContext: resourceGithubEnterpriseAppInstallationUpdate, + DeleteContext: resourceGithubEnterpriseAppInstallationDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug of the enterprise that owns the organization.", + }, + "organization": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The login of the enterprise-owned organization to install the app on.", + }, + "client_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The client ID of the GitHub App to install.", + }, + "repository_selection": { + Type: schema.TypeString, + Required: true, + ValidateDiagFunc: validateValueFunc([]string{"all", "selected", "none"}), + Description: "The repositories the installation can access. Can be one of 'all', 'selected' or 'none'.", + }, + "selected_repositories": { + Type: schema.TypeSet, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + Optional: true, + Description: "The names of the repositories the installation can access when 'repository_selection' is 'selected'.", + }, + "installation_id": { + Type: schema.TypeString, + Computed: true, + Description: "The ID of the installation.", + }, + "app_slug": { + Type: schema.TypeString, + Computed: true, + Description: "The slug of the installed app.", + }, + }, + + CustomizeDiff: customdiff.All( + // The API can only toggle an existing installation between 'all' and + // 'selected'; transitions involving 'none' require a reinstall. + customdiff.ForceNewIfChange("repository_selection", func(ctx context.Context, oldValue, newValue, meta any) bool { + return oldValue.(string) == "none" || newValue.(string) == "none" + }), + func(ctx context.Context, d *schema.ResourceDiff, meta any) error { + selection := d.Get("repository_selection").(string) + repoCount := d.Get("selected_repositories").(*schema.Set).Len() + if selection == "selected" && repoCount == 0 { + return fmt.Errorf("'selected_repositories' must be set when 'repository_selection' is 'selected'") + } + if selection != "selected" && repoCount > 0 { + return fmt.Errorf("'selected_repositories' can only be set when 'repository_selection' is 'selected'") + } + return nil + }, + ), + } +} + +func resourceGithubEnterpriseAppInstallationCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterpriseSlug := d.Get("enterprise_slug").(string) + org := d.Get("organization").(string) + clientID := d.Get("client_id").(string) + + repositories := expandStringList(d.Get("selected_repositories").(*schema.Set).List()) + + // The install endpoint accepts at most maxInstallationRepositoriesPerRequest + // repositories; any remainder is granted with follow-up requests. + initial := repositories + var remainder []string + if len(repositories) > maxInstallationRepositoriesPerRequest { + initial = repositories[:maxInstallationRepositoriesPerRequest] + remainder = repositories[maxInstallationRepositoriesPerRequest:] + } + + req := github.InstallAppRequest{ + ClientID: clientID, + RepositorySelection: d.Get("repository_selection").(string), + Repositories: initial, + } + + tflog.Debug(ctx, "Installing app on enterprise-owned organization", map[string]any{ + "client_id": clientID, + "org": org, + "enterprise": enterpriseSlug, + }) + installation, _, err := client.Enterprise.InstallApp(ctx, enterpriseSlug, org, req) + if err != nil { + return diag.FromErr(err) + } + + if err := addEnterpriseAppInstallationRepositories(ctx, client, enterpriseSlug, org, installation.GetID(), remainder); err != nil { + return diag.FromErr(err) + } + + id, err := buildID(enterpriseSlug, org, clientID) + if err != nil { + return diag.FromErr(err) + } + d.SetId(id) + + return resourceGithubEnterpriseAppInstallationRead(ctx, d, meta) +} + +func resourceGithubEnterpriseAppInstallationRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterpriseSlug, org, clientID, err := parseID3(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + installation, err := findEnterpriseAppInstallation(ctx, client, enterpriseSlug, org, clientID) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Removing enterprise app installation from state because it no longer exists in GitHub", map[string]any{ + "id": d.Id(), + }) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + if installation == nil { + tflog.Info(ctx, "Removing enterprise app installation from state because it no longer exists in GitHub", map[string]any{ + "id": d.Id(), + }) + d.SetId("") + return nil + } + + if err := d.Set("enterprise_slug", enterpriseSlug); err != nil { + return diag.FromErr(err) + } + if err := d.Set("organization", org); err != nil { + return diag.FromErr(err) + } + if err := d.Set("client_id", clientID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("repository_selection", installation.GetRepositorySelection()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("installation_id", strconv.FormatInt(installation.GetID(), 10)); err != nil { + return diag.FromErr(err) + } + if err := d.Set("app_slug", installation.GetAppSlug()); err != nil { + return diag.FromErr(err) + } + + selectedRepositories := []string{} + if installation.GetRepositorySelection() == "selected" { + opts := &github.ListOptions{PerPage: maxPerPage} + for { + repos, resp, err := client.Enterprise.ListRepositoriesForOrgAppInstallation(ctx, enterpriseSlug, org, installation.GetID(), opts) + if err != nil { + return diag.FromErr(err) + } + for _, repo := range repos { + selectedRepositories = append(selectedRepositories, repo.GetName()) + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + } + if err := d.Set("selected_repositories", flattenStringList(selectedRepositories)); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubEnterpriseAppInstallationUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterpriseSlug, org, _, err := parseID3(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + installationID, err := strconv.ParseInt(d.Get("installation_id").(string), 10, 64) + if err != nil { + return diag.FromErr(unconvertibleIdErr(d.Get("installation_id").(string), err)) + } + + if d.HasChange("repository_selection") { + selection := d.Get("repository_selection").(string) + req := github.UpdateAppInstallationRepositoriesRequest{ + RepositorySelection: new(selection), + } + var remainder []string + if selection == "selected" { + repositories := expandStringList(d.Get("selected_repositories").(*schema.Set).List()) + // The toggle endpoint accepts at most + // maxInstallationRepositoriesPerRequest repositories; any remainder + // is granted with follow-up requests. + req.Repositories = repositories + if len(repositories) > maxInstallationRepositoriesPerRequest { + req.Repositories = repositories[:maxInstallationRepositoriesPerRequest] + remainder = repositories[maxInstallationRepositoriesPerRequest:] + } + } + + tflog.Debug(ctx, "Updating repository selection for enterprise app installation", map[string]any{ + "selection": selection, + "installation_id": installationID, + "org": org, + "enterprise": enterpriseSlug, + }) + _, _, err := client.Enterprise.UpdateAppInstallationRepositories(ctx, enterpriseSlug, org, installationID, req) + if err != nil { + return diag.FromErr(err) + } + + if err := addEnterpriseAppInstallationRepositories(ctx, client, enterpriseSlug, org, installationID, remainder); err != nil { + return diag.FromErr(err) + } + } else if d.HasChange("selected_repositories") { + oldRepos, newRepos := d.GetChange("selected_repositories") + oldSet := oldRepos.(*schema.Set) + newSet := newRepos.(*schema.Set) + + // Add before removing so the installation never has an empty selection. + toAdd := expandStringList(newSet.Difference(oldSet).List()) + if err := addEnterpriseAppInstallationRepositories(ctx, client, enterpriseSlug, org, installationID, toAdd); err != nil { + return diag.FromErr(err) + } + + toRemove := expandStringList(oldSet.Difference(newSet).List()) + for chunk := range slices.Chunk(toRemove, maxInstallationRepositoriesPerRequest) { + tflog.Debug(ctx, "Revoking enterprise app installation access to repositories", map[string]any{ + "installation_id": installationID, + "org": org, + "enterprise": enterpriseSlug, + "repositories": chunk, + }) + _, _, err := client.Enterprise.RemoveRepositoriesFromAppInstallation(ctx, enterpriseSlug, org, installationID, github.AppInstallationRepositoriesRequest{Repositories: chunk}) + if err != nil { + return diag.FromErr(err) + } + } + } + + return resourceGithubEnterpriseAppInstallationRead(ctx, d, meta) +} + +func resourceGithubEnterpriseAppInstallationDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + client := meta.(*Owner).v3client + + enterpriseSlug, org, clientID, err := parseID3(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + installationID, err := strconv.ParseInt(d.Get("installation_id").(string), 10, 64) + if err != nil { + return diag.FromErr(unconvertibleIdErr(d.Get("installation_id").(string), err)) + } + + tflog.Debug(ctx, "Uninstalling app from enterprise-owned organization", map[string]any{ + "client_id": clientID, + "installation_id": installationID, + "org": org, + "enterprise": enterpriseSlug, + }) + _, err = client.Enterprise.UninstallApp(ctx, enterpriseSlug, org, installationID) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { + return nil + } + return diag.FromErr(err) + } + + return nil +} + +// addEnterpriseAppInstallationRepositories grants an installation access to +// the given repositories, in chunks the API accepts. +func addEnterpriseAppInstallationRepositories(ctx context.Context, client *github.Client, enterpriseSlug, org string, installationID int64, repositories []string) error { + for chunk := range slices.Chunk(repositories, maxInstallationRepositoriesPerRequest) { + tflog.Debug(ctx, "Granting enterprise app installation access to repositories", map[string]any{ + "installation_id": installationID, + "org": org, + "enterprise": enterpriseSlug, + "repositories": chunk, + }) + _, _, err := client.Enterprise.AddRepositoriesToAppInstallation(ctx, enterpriseSlug, org, installationID, github.AppInstallationRepositoriesRequest{Repositories: chunk}) + if err != nil { + return err + } + } + + return nil +} + +// findEnterpriseAppInstallation returns the installation of the app with the +// given client ID on an enterprise-owned organization, or nil if the app is +// not installed. +func findEnterpriseAppInstallation(ctx context.Context, client *github.Client, enterpriseSlug, org, clientID string) (*github.Installation, error) { + opts := &github.ListOptions{PerPage: maxPerPage} + for { + installations, resp, err := client.Enterprise.ListAppInstallations(ctx, enterpriseSlug, org, opts) + if err != nil { + return nil, err + } + for _, installation := range installations { + if installation.GetClientID() == clientID { + return installation, nil + } + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + return nil, nil +} diff --git a/github/resource_github_enterprise_app_installation_test.go b/github/resource_github_enterprise_app_installation_test.go new file mode 100644 index 0000000000..a3ca3586cf --- /dev/null +++ b/github/resource_github_enterprise_app_installation_test.go @@ -0,0 +1,116 @@ +package github + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccGithubEnterpriseAppInstallation(t *testing.T) { + appClientID := os.Getenv("GH_TEST_ENTERPRISE_APP_CLIENT_ID") + skipUnlessEnterpriseAppClientID := func(t *testing.T) { + t.Helper() + if appClientID == "" { + t.Skip("Skipping because GH_TEST_ENTERPRISE_APP_CLIENT_ID is not set") + } + } + + t.Run("installs an app on all repositories", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_enterprise_app_installation" "test" { + enterprise_slug = "%s" + organization = "%s" + client_id = "%s" + repository_selection = "all" + } + `, testAccConf.enterpriseSlug, testAccConf.owner, appClientID) + + check := resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_app_installation.test", "enterprise_slug", testAccConf.enterpriseSlug), + resource.TestCheckResourceAttr("github_enterprise_app_installation.test", "organization", testAccConf.owner), + resource.TestCheckResourceAttr("github_enterprise_app_installation.test", "client_id", appClientID), + resource.TestCheckResourceAttr("github_enterprise_app_installation.test", "repository_selection", "all"), + resource.TestCheckResourceAttrSet("github_enterprise_app_installation.test", "installation_id"), + resource.TestCheckResourceAttrSet("github_enterprise_app_installation.test", "app_slug"), + ) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + skipUnlessEnterprise(t) + skipUnlessEnterpriseAppClientID(t) + }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: check, + }, + { + ResourceName: "github_enterprise_app_installation.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) + + t.Run("installs an app on selected repositories and toggles to all", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + configSelected := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-eai-%[4]s" + auto_init = true + } + + resource "github_enterprise_app_installation" "test" { + enterprise_slug = "%[1]s" + organization = "%[2]s" + client_id = "%[3]s" + repository_selection = "selected" + selected_repositories = [github_repository.test.name] + } + `, testAccConf.enterpriseSlug, testAccConf.owner, appClientID, randomID) + + configAll := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-eai-%[4]s" + auto_init = true + } + + resource "github_enterprise_app_installation" "test" { + enterprise_slug = "%[1]s" + organization = "%[2]s" + client_id = "%[3]s" + repository_selection = "all" + } + `, testAccConf.enterpriseSlug, testAccConf.owner, appClientID, randomID) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + skipUnlessEnterprise(t) + skipUnlessEnterpriseAppClientID(t) + }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: configSelected, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_app_installation.test", "repository_selection", "selected"), + resource.TestCheckResourceAttr("github_enterprise_app_installation.test", "selected_repositories.#", "1"), + ), + }, + { + Config: configAll, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_enterprise_app_installation.test", "repository_selection", "all"), + resource.TestCheckResourceAttr("github_enterprise_app_installation.test", "selected_repositories.#", "0"), + ), + }, + }, + }) + }) +} diff --git a/templates/resources/enterprise_app_installation.md.tmpl b/templates/resources/enterprise_app_installation.md.tmpl new file mode 100644 index 0000000000..f47bf6ac15 --- /dev/null +++ b/templates/resources/enterprise_app_installation.md.tmpl @@ -0,0 +1,41 @@ +--- +page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +description: |- + Manages a GitHub App installation on an enterprise-owned organization. +--- + +# {{.Name}} ({{.Type}}) + +This resource allows you to install a GitHub App on an organization owned by a GitHub Enterprise, including granting the app access to **all** repositories in the organization. + +~> **Note**: This resource is only available on GitHub Enterprise Cloud and GitHub Enterprise Server 3.19 or later. The authenticated user must be an [enterprise owner](https://docs.github.com/en/enterprise-cloud@latest/admin/managing-accounts-and-repositories/managing-users-in-your-enterprise/roles-in-an-enterprise#enterprise-owners), and the API is not available for organizations that are not part of an enterprise. To manage the repositories of an app installation on a non-enterprise organization, see the `github_app_installation_repositories` resource. + +## Example Usage + +{{tffile "examples/resources/enterprise_app_installation/example_1.tf"}} + +## Argument Reference + +The following arguments are supported: + +- `enterprise_slug` - (Required) The slug of the enterprise that owns the organization. +- `organization` - (Required) The login of the enterprise-owned organization to install the app on. +- `client_id` - (Required) The client ID of the GitHub App to install. +- `repository_selection` - (Required) The repositories the installation can access. Can be one of `all`, `selected` or `none`. Changing between `all` and `selected` is applied in place; changing from or to `none` will recreate the installation. +- `selected_repositories` - (Optional) The names of the repositories the installation can access. Required when `repository_selection` is `selected`, and must not be set otherwise. + +## Attributes Reference + +The following additional attributes are exported: + +- `id` - The ID of the resource in the format `::`. +- `installation_id` - The ID of the app installation. +- `app_slug` - The slug of the installed app. + +## Import + +GitHub enterprise app installations can be imported using the enterprise slug, the organization login and the app client ID, separated by `:` characters. + +```shell +terraform import github_enterprise_app_installation.all_repos my-enterprise:my-org:Iv1.abc123def456 +``` From 1fe33df3bb150f1780132ad7f6a86976bbfb3ad0 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Tue, 14 Jul 2026 00:17:57 +0100 Subject: [PATCH 02/12] fix: Set resource ID before granting remaining repositories Record the enterprise app installation in state as soon as the app is installed, so a failure while granting the remaining (>50) repositories leaves a recoverable resource rather than an orphaned installation. Co-Authored-By: Claude Opus 4.8 --- github/resource_github_enterprise_app_installation.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/github/resource_github_enterprise_app_installation.go b/github/resource_github_enterprise_app_installation.go index 721e55b546..ce30223a56 100644 --- a/github/resource_github_enterprise_app_installation.go +++ b/github/resource_github_enterprise_app_installation.go @@ -131,16 +131,19 @@ func resourceGithubEnterpriseAppInstallationCreate(ctx context.Context, d *schem return diag.FromErr(err) } - if err := addEnterpriseAppInstallationRepositories(ctx, client, enterpriseSlug, org, installation.GetID(), remainder); err != nil { - return diag.FromErr(err) - } - + // Record the resource in state as soon as the app is installed, so a + // failure while granting the remaining repositories leaves a recoverable + // resource rather than an orphaned installation. id, err := buildID(enterpriseSlug, org, clientID) if err != nil { return diag.FromErr(err) } d.SetId(id) + if err := addEnterpriseAppInstallationRepositories(ctx, client, enterpriseSlug, org, installation.GetID(), remainder); err != nil { + return diag.FromErr(err) + } + return resourceGithubEnterpriseAppInstallationRead(ctx, d, meta) } From 4708b77faedc1d8e753fdf4cbf96b0b4f6f18ab3 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Fri, 31 Jul 2026 23:41:24 +0200 Subject: [PATCH 03/12] chore: Use Owner maxPerPage after per-page refactor The global maxPerPage constant was moved to the Owner struct in #3555. Co-Authored-By: Claude Fable 5 --- .../resource_github_enterprise_app_installation.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/github/resource_github_enterprise_app_installation.go b/github/resource_github_enterprise_app_installation.go index ce30223a56..6f991c4e86 100644 --- a/github/resource_github_enterprise_app_installation.go +++ b/github/resource_github_enterprise_app_installation.go @@ -148,14 +148,15 @@ func resourceGithubEnterpriseAppInstallationCreate(ctx context.Context, d *schem } func resourceGithubEnterpriseAppInstallationRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { - client := meta.(*Owner).v3client + owner := meta.(*Owner) + client := owner.v3client enterpriseSlug, org, clientID, err := parseID3(d.Id()) if err != nil { return diag.FromErr(err) } - installation, err := findEnterpriseAppInstallation(ctx, client, enterpriseSlug, org, clientID) + installation, err := findEnterpriseAppInstallation(ctx, owner, enterpriseSlug, org, clientID) if err != nil { var ghErr *github.ErrorResponse if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { @@ -196,7 +197,7 @@ func resourceGithubEnterpriseAppInstallationRead(ctx context.Context, d *schema. selectedRepositories := []string{} if installation.GetRepositorySelection() == "selected" { - opts := &github.ListOptions{PerPage: maxPerPage} + opts := &github.ListOptions{PerPage: owner.maxPerPage} for { repos, resp, err := client.Enterprise.ListRepositoriesForOrgAppInstallation(ctx, enterpriseSlug, org, installation.GetID(), opts) if err != nil { @@ -345,10 +346,10 @@ func addEnterpriseAppInstallationRepositories(ctx context.Context, client *githu // findEnterpriseAppInstallation returns the installation of the app with the // given client ID on an enterprise-owned organization, or nil if the app is // not installed. -func findEnterpriseAppInstallation(ctx context.Context, client *github.Client, enterpriseSlug, org, clientID string) (*github.Installation, error) { - opts := &github.ListOptions{PerPage: maxPerPage} +func findEnterpriseAppInstallation(ctx context.Context, owner *Owner, enterpriseSlug, org, clientID string) (*github.Installation, error) { + opts := &github.ListOptions{PerPage: owner.maxPerPage} for { - installations, resp, err := client.Enterprise.ListAppInstallations(ctx, enterpriseSlug, org, opts) + installations, resp, err := owner.v3client.Enterprise.ListAppInstallations(ctx, enterpriseSlug, org, opts) if err != nil { return nil, err } From 2f3d5f120b339a012646a67c6b309dd6aab9e4c0 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Fri, 31 Jul 2026 23:58:10 +0200 Subject: [PATCH 04/12] feat: Add enterprise app installation data sources Adds github_enterprise_app_installations, github_enterprise_app_installable_organizations and github_enterprise_app_accessible_organization_repositories. Co-Authored-By: Claude Fable 5 --- RESOURCES.md | 3 + ...pp_accessible_organization_repositories.md | 63 ++++++++ ...nterprise_app_installable_organizations.md | 60 ++++++++ .../enterprise_app_installations.md | 83 +++++++++++ .../data-source_1.tf | 4 + .../data-source_1.tf | 3 + .../data-source_1.tf | 4 + ...pp_accessible_organization_repositories.go | 94 ++++++++++++ ...cessible_organization_repositories_test.go | 55 +++++++ ...nterprise_app_installable_organizations.go | 88 +++++++++++ ...rise_app_installable_organizations_test.go | 43 ++++++ ...rce_github_enterprise_app_installations.go | 141 ++++++++++++++++++ ...ithub_enterprise_app_installations_test.go | 40 +++++ github/provider.go | 3 + ...cessible_organization_repositories.md.tmpl | 49 ++++++ ...rise_app_installable_organizations.md.tmpl | 48 ++++++ .../enterprise_app_installations.md.tmpl | 59 ++++++++ 17 files changed, 840 insertions(+) create mode 100644 docs/data-sources/enterprise_app_accessible_organization_repositories.md create mode 100644 docs/data-sources/enterprise_app_installable_organizations.md create mode 100644 docs/data-sources/enterprise_app_installations.md create mode 100644 examples/data-sources/github_enterprise_app_accessible_organization_repositories/data-source_1.tf create mode 100644 examples/data-sources/github_enterprise_app_installable_organizations/data-source_1.tf create mode 100644 examples/data-sources/github_enterprise_app_installations/data-source_1.tf create mode 100644 github/data_source_github_enterprise_app_accessible_organization_repositories.go create mode 100644 github/data_source_github_enterprise_app_accessible_organization_repositories_test.go create mode 100644 github/data_source_github_enterprise_app_installable_organizations.go create mode 100644 github/data_source_github_enterprise_app_installable_organizations_test.go create mode 100644 github/data_source_github_enterprise_app_installations.go create mode 100644 github/data_source_github_enterprise_app_installations_test.go create mode 100644 templates/data-sources/enterprise_app_accessible_organization_repositories.md.tmpl create mode 100644 templates/data-sources/enterprise_app_installable_organizations.md.tmpl create mode 100644 templates/data-sources/enterprise_app_installations.md.tmpl diff --git a/RESOURCES.md b/RESOURCES.md index 04370b5cc4..96d6872b59 100644 --- a/RESOURCES.md +++ b/RESOURCES.md @@ -69,6 +69,9 @@ The overall status of each resource or data source is captured in this document | `github_dependabot_public_key` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | | `github_dependabot_secrets` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | | `github_enterprise` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | +| `github_enterprise_app_accessible_organization_repositories` | ✅ | ✅ | ✅ | ✅ | ❓ | ✅ | +| `github_enterprise_app_installable_organizations` | ✅ | ✅ | ✅ | ✅ | ❓ | ✅ | +| `github_enterprise_app_installations` | ✅ | ✅ | ✅ | ✅ | ❓ | ✅ | | `github_external_groups` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `github_ip_ranges` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | | `github_issue_labels` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | diff --git a/docs/data-sources/enterprise_app_accessible_organization_repositories.md b/docs/data-sources/enterprise_app_accessible_organization_repositories.md new file mode 100644 index 0000000000..535d925a37 --- /dev/null +++ b/docs/data-sources/enterprise_app_accessible_organization_repositories.md @@ -0,0 +1,63 @@ +--- +page_title: "github_enterprise_app_accessible_organization_repositories (Data Source) - GitHub" +subcategory: "" +description: |- + Use this data source to retrieve the repositories of an enterprise-owned organization that GitHub Apps can be granted access to. This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. +--- + +# github_enterprise_app_accessible_organization_repositories (Data Source) + +Use this data source to retrieve the repositories of an enterprise-owned organization that GitHub Apps can be granted access to. This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. + +## Example Usage + +```terraform +data "github_enterprise_app_accessible_organization_repositories" "example" { + enterprise_slug = "my-enterprise" + organization = "my-org" +} +``` + + + +## Schema + +### Required + +- `enterprise_slug` (String) The slug of the enterprise that owns the organization. +- `organization` (String) The login of the enterprise-owned organization. + +### Read-Only + +- `id` (String) The ID of this resource. +- `repositories` (List of Object) List of repositories of the organization that GitHub Apps can be granted access to. (see [below for nested schema](#nestedatt--repositories)) + + +### Nested Schema for `repositories` + +Read-Only: + +- `full_name` (String) The full name of the repository, in the format `/`. +- `id` (Number) The ID of the repository. +- `name` (String) The name of the repository. diff --git a/docs/data-sources/enterprise_app_installable_organizations.md b/docs/data-sources/enterprise_app_installable_organizations.md new file mode 100644 index 0000000000..77f5dcdb45 --- /dev/null +++ b/docs/data-sources/enterprise_app_installable_organizations.md @@ -0,0 +1,60 @@ +--- +page_title: "github_enterprise_app_installable_organizations (Data Source) - GitHub" +subcategory: "" +description: |- + Use this data source to retrieve the enterprise-owned organizations that GitHub Apps can be installed on. This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. +--- + +# github_enterprise_app_installable_organizations (Data Source) + +Use this data source to retrieve the enterprise-owned organizations that GitHub Apps can be installed on. This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. + +## Example Usage + +```terraform +data "github_enterprise_app_installable_organizations" "example" { + enterprise_slug = "my-enterprise" +} +``` + + + +## Schema + +### Required + +- `enterprise_slug` (String) The slug of the enterprise. + +### Read-Only + +- `id` (String) The ID of this resource. +- `organizations` (List of Object) List of organizations in the enterprise that GitHub Apps can be installed on. (see [below for nested schema](#nestedatt--organizations)) + + +### Nested Schema for `organizations` + +Read-Only: + +- `accessible_repositories_url` (String) The API URL listing the repositories that can be made accessible to a GitHub App installed on the organization. +- `id` (Number) The ID of the organization. +- `login` (String) The login of the organization. diff --git a/docs/data-sources/enterprise_app_installations.md b/docs/data-sources/enterprise_app_installations.md new file mode 100644 index 0000000000..88c539a3b5 --- /dev/null +++ b/docs/data-sources/enterprise_app_installations.md @@ -0,0 +1,83 @@ +--- +page_title: "github_enterprise_app_installations (Data Source) - GitHub" +subcategory: "" +description: |- + Use this data source to retrieve the GitHub App installations on an enterprise-owned organization. This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. +--- + +# github_enterprise_app_installations (Data Source) + +Use this data source to retrieve the GitHub App installations on an enterprise-owned organization. This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. + +## Example Usage + +```terraform +data "github_enterprise_app_installations" "example" { + enterprise_slug = "my-enterprise" + organization = "my-org" +} +``` + + + +## Schema + +### Required + +- `enterprise_slug` (String) The slug of the enterprise that owns the organization. +- `organization` (String) The login of the enterprise-owned organization. + +### Read-Only + +- `id` (String) The ID of this resource. +- `installations` (List of Object) List of GitHub App installations on the organization. (see [below for nested schema](#nestedatt--installations)) + + +### Nested Schema for `installations` + +Read-Only: + +- `app_id` (Number) The ID of the GitHub App. +- `app_slug` (String) The URL-friendly name of the GitHub App. +- `client_id` (String) The OAuth client ID of the GitHub App. +- `created_at` (String) The date the GitHub App installation was created. +- `events` (List of String) The list of events the GitHub App installation subscribes to. +- `id` (Number) The ID of the GitHub App installation. +- `permissions` (Map of String) The permissions granted to the GitHub App installation. +- `repository_selection` (String) Whether the installation has access to all repositories or only selected ones. Possible values are `all` or `selected`. +- `single_file_paths` (List of String) The list of single file paths the GitHub App installation has access to. +- `suspended` (Boolean) Whether the GitHub App installation is currently suspended. +- `target_id` (Number) The ID of the account the GitHub App is installed on. +- `target_type` (String) The type of account the GitHub App is installed on. Possible values are `Organization` or `User`. +- `updated_at` (String) The date the GitHub App installation was last updated. diff --git a/examples/data-sources/github_enterprise_app_accessible_organization_repositories/data-source_1.tf b/examples/data-sources/github_enterprise_app_accessible_organization_repositories/data-source_1.tf new file mode 100644 index 0000000000..29e387abf7 --- /dev/null +++ b/examples/data-sources/github_enterprise_app_accessible_organization_repositories/data-source_1.tf @@ -0,0 +1,4 @@ +data "github_enterprise_app_accessible_organization_repositories" "example" { + enterprise_slug = "my-enterprise" + organization = "my-org" +} diff --git a/examples/data-sources/github_enterprise_app_installable_organizations/data-source_1.tf b/examples/data-sources/github_enterprise_app_installable_organizations/data-source_1.tf new file mode 100644 index 0000000000..51fc4906fd --- /dev/null +++ b/examples/data-sources/github_enterprise_app_installable_organizations/data-source_1.tf @@ -0,0 +1,3 @@ +data "github_enterprise_app_installable_organizations" "example" { + enterprise_slug = "my-enterprise" +} diff --git a/examples/data-sources/github_enterprise_app_installations/data-source_1.tf b/examples/data-sources/github_enterprise_app_installations/data-source_1.tf new file mode 100644 index 0000000000..5879719cd6 --- /dev/null +++ b/examples/data-sources/github_enterprise_app_installations/data-source_1.tf @@ -0,0 +1,4 @@ +data "github_enterprise_app_installations" "example" { + enterprise_slug = "my-enterprise" + organization = "my-org" +} diff --git a/github/data_source_github_enterprise_app_accessible_organization_repositories.go b/github/data_source_github_enterprise_app_accessible_organization_repositories.go new file mode 100644 index 0000000000..df22007ef7 --- /dev/null +++ b/github/data_source_github_enterprise_app_accessible_organization_repositories.go @@ -0,0 +1,94 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v89/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubEnterpriseAppAccessibleOrganizationRepositories() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceGithubEnterpriseAppAccessibleOrganizationRepositoriesRead, + Description: "Use this data source to retrieve the repositories of an enterprise-owned organization that GitHub Apps can be granted access to. " + + "This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner.", + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise that owns the organization.", + }, + "organization": { + Type: schema.TypeString, + Required: true, + Description: "The login of the enterprise-owned organization.", + }, + "repositories": { + Type: schema.TypeList, + Computed: true, + Description: "List of repositories of the organization that GitHub Apps can be granted access to.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the repository.", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the repository.", + }, + "full_name": { + Type: schema.TypeString, + Computed: true, + Description: "The full name of the repository, in the format `/`.", + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubEnterpriseAppAccessibleOrganizationRepositoriesRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + + enterpriseSlug := d.Get("enterprise_slug").(string) + org := d.Get("organization").(string) + + opts := &github.ListOptions{ + PerPage: meta.maxPerPage, + } + + results := make([]map[string]any, 0) + for { + repositories, resp, err := client.Enterprise.ListAppAccessibleOrganizationRepositories(ctx, enterpriseSlug, org, opts) + if err != nil { + return diag.FromErr(err) + } + + for _, repository := range repositories { + results = append(results, map[string]any{ + "id": repository.ID, + "name": repository.Name, + "full_name": repository.FullName, + }) + } + if resp.NextPage == 0 { + break + } + + opts.Page = resp.NextPage + } + + d.SetId(buildTwoPartID(enterpriseSlug, org)) + if err := d.Set("repositories", results); err != nil { + return diag.FromErr(err) + } + + return nil +} diff --git a/github/data_source_github_enterprise_app_accessible_organization_repositories_test.go b/github/data_source_github_enterprise_app_accessible_organization_repositories_test.go new file mode 100644 index 0000000000..24dcf84f4b --- /dev/null +++ b/github/data_source_github_enterprise_app_accessible_organization_repositories_test.go @@ -0,0 +1,55 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccDataSourceGithubEnterpriseAppAccessibleOrganizationRepositories(t *testing.T) { + t.Parallel() + + skipUnlessEnterprise(t) + + t.Run("queries_accessible_repositories", func(t *testing.T) { + t.Parallel() + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + repoName := fmt.Sprintf("tf-acc-test-eaaor-%s", randomID) + + config := fmt.Sprintf(` +resource "github_repository" "test" { + name = "%s" + auto_init = true +} + +data "github_enterprise_app_accessible_organization_repositories" "test" { + enterprise_slug = "%s" + organization = "%s" + + depends_on = [github_repository.test] +} +`, repoName, testAccConf.enterpriseSlug, testAccConf.owner) + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.github_enterprise_app_accessible_organization_repositories.test", tfjsonpath.New("repositories"), knownvalue.SetPartial([]knownvalue.Check{ + knownvalue.MapPartial(map[string]knownvalue.Check{ + "name": knownvalue.StringExact(repoName), + }), + })), + }, + }, + }, + }) + }) +} diff --git a/github/data_source_github_enterprise_app_installable_organizations.go b/github/data_source_github_enterprise_app_installable_organizations.go new file mode 100644 index 0000000000..b260286080 --- /dev/null +++ b/github/data_source_github_enterprise_app_installable_organizations.go @@ -0,0 +1,88 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v89/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubEnterpriseAppInstallableOrganizations() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceGithubEnterpriseAppInstallableOrganizationsRead, + Description: "Use this data source to retrieve the enterprise-owned organizations that GitHub Apps can be installed on. " + + "This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner.", + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise.", + }, + "organizations": { + Type: schema.TypeList, + Computed: true, + Description: "List of organizations in the enterprise that GitHub Apps can be installed on.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the organization.", + }, + "login": { + Type: schema.TypeString, + Computed: true, + Description: "The login of the organization.", + }, + "accessible_repositories_url": { + Type: schema.TypeString, + Computed: true, + Description: "The API URL listing the repositories that can be made accessible to a GitHub App installed on the organization.", + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubEnterpriseAppInstallableOrganizationsRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + + enterpriseSlug := d.Get("enterprise_slug").(string) + + opts := &github.ListOptions{ + PerPage: meta.maxPerPage, + } + + results := make([]map[string]any, 0) + for { + organizations, resp, err := client.Enterprise.ListAppInstallableOrganizations(ctx, enterpriseSlug, opts) + if err != nil { + return diag.FromErr(err) + } + + for _, organization := range organizations { + results = append(results, map[string]any{ + "id": organization.ID, + "login": organization.Login, + "accessible_repositories_url": organization.GetAccessibleRepositoriesURL(), + }) + } + if resp.NextPage == 0 { + break + } + + opts.Page = resp.NextPage + } + + d.SetId(enterpriseSlug) + if err := d.Set("organizations", results); err != nil { + return diag.FromErr(err) + } + + return nil +} diff --git a/github/data_source_github_enterprise_app_installable_organizations_test.go b/github/data_source_github_enterprise_app_installable_organizations_test.go new file mode 100644 index 0000000000..12555ce6bc --- /dev/null +++ b/github/data_source_github_enterprise_app_installable_organizations_test.go @@ -0,0 +1,43 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccDataSourceGithubEnterpriseAppInstallableOrganizations(t *testing.T) { + t.Parallel() + + skipUnlessEnterprise(t) + + t.Run("queries_installable_organizations", func(t *testing.T) { + t.Parallel() + + config := fmt.Sprintf(` +data "github_enterprise_app_installable_organizations" "test" { + enterprise_slug = "%s" +} +`, testAccConf.enterpriseSlug) + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.github_enterprise_app_installable_organizations.test", tfjsonpath.New("organizations"), knownvalue.SetPartial([]knownvalue.Check{ + knownvalue.MapPartial(map[string]knownvalue.Check{ + "login": knownvalue.StringExact(testAccConf.owner), + }), + })), + }, + }, + }, + }) + }) +} diff --git a/github/data_source_github_enterprise_app_installations.go b/github/data_source_github_enterprise_app_installations.go new file mode 100644 index 0000000000..eecd5b4d66 --- /dev/null +++ b/github/data_source_github_enterprise_app_installations.go @@ -0,0 +1,141 @@ +package github + +import ( + "context" + + "github.com/google/go-github/v89/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubEnterpriseAppInstallations() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceGithubEnterpriseAppInstallationsRead, + Description: "Use this data source to retrieve the GitHub App installations on an enterprise-owned organization. " + + "This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner.", + + Schema: map[string]*schema.Schema{ + "enterprise_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the enterprise that owns the organization.", + }, + "organization": { + Type: schema.TypeString, + Required: true, + Description: "The login of the enterprise-owned organization.", + }, + "installations": { + Type: schema.TypeList, + Computed: true, + Description: "List of GitHub App installations on the organization.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the GitHub App installation.", + }, + "app_slug": { + Type: schema.TypeString, + Computed: true, + Description: "The URL-friendly name of the GitHub App.", + }, + "app_id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the GitHub App.", + }, + "repository_selection": { + Type: schema.TypeString, + Computed: true, + Description: "Whether the installation has access to all repositories or only selected ones. Possible values are 'all' or 'selected'.", + }, + "permissions": { + Type: schema.TypeMap, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The permissions granted to the GitHub App installation.", + }, + "events": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The list of events the GitHub App installation subscribes to.", + }, + "client_id": { + Type: schema.TypeString, + Computed: true, + Description: "The OAuth client ID of the GitHub App.", + }, + "target_id": { + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the account the GitHub App is installed on.", + }, + "target_type": { + Type: schema.TypeString, + Computed: true, + Description: "The type of account the GitHub App is installed on. Possible values are 'Organization' or 'User'.", + }, + "suspended": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether the GitHub App installation is currently suspended.", + }, + "single_file_paths": { + Type: schema.TypeList, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + Description: "The list of single file paths the GitHub App installation has access to.", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "The date the GitHub App installation was created.", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "The date the GitHub App installation was last updated.", + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubEnterpriseAppInstallationsRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta, _ := m.(*Owner) + client := meta.v3client + + enterpriseSlug := d.Get("enterprise_slug").(string) + org := d.Get("organization").(string) + + opts := &github.ListOptions{ + PerPage: meta.maxPerPage, + } + + results := make([]map[string]any, 0) + for { + installations, resp, err := client.Enterprise.ListAppInstallations(ctx, enterpriseSlug, org, opts) + if err != nil { + return diag.FromErr(err) + } + + results = append(results, flattenGitHubAppInstallations(installations)...) + if resp.NextPage == 0 { + break + } + + opts.Page = resp.NextPage + } + + d.SetId(buildTwoPartID(enterpriseSlug, org)) + if err := d.Set("installations", results); err != nil { + return diag.FromErr(err) + } + + return nil +} diff --git a/github/data_source_github_enterprise_app_installations_test.go b/github/data_source_github_enterprise_app_installations_test.go new file mode 100644 index 0000000000..cb1042b67d --- /dev/null +++ b/github/data_source_github_enterprise_app_installations_test.go @@ -0,0 +1,40 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +func TestAccDataSourceGithubEnterpriseAppInstallations(t *testing.T) { + t.Parallel() + + skipUnlessEnterprise(t) + + t.Run("queries_app_installations", func(t *testing.T) { + t.Parallel() + + config := fmt.Sprintf(` +data "github_enterprise_app_installations" "test" { + enterprise_slug = "%s" + organization = "%s" +} +`, testAccConf.enterpriseSlug, testAccConf.owner) + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("data.github_enterprise_app_installations.test", tfjsonpath.New("installations"), knownvalue.NotNull()), + }, + }, + }, + }) + }) +} diff --git a/github/provider.go b/github/provider.go index 6cf62d8c02..1fe21e87cb 100644 --- a/github/provider.go +++ b/github/provider.go @@ -329,6 +329,9 @@ func NewProvider(version, commit string) func() *schema.Provider { "github_user_external_identity": dataSourceGithubUserExternalIdentity(), "github_users": dataSourceGithubUsers(), "github_enterprise": dataSourceGithubEnterprise(), + "github_enterprise_app_accessible_organization_repositories": dataSourceGithubEnterpriseAppAccessibleOrganizationRepositories(), + "github_enterprise_app_installable_organizations": dataSourceGithubEnterpriseAppInstallableOrganizations(), + "github_enterprise_app_installations": dataSourceGithubEnterpriseAppInstallations(), "github_repository_environment_deployment_policies": dataSourceGithubRepositoryEnvironmentDeploymentPolicies(), }, diff --git a/templates/data-sources/enterprise_app_accessible_organization_repositories.md.tmpl b/templates/data-sources/enterprise_app_accessible_organization_repositories.md.tmpl new file mode 100644 index 0000000000..832312f3a9 --- /dev/null +++ b/templates/data-sources/enterprise_app_accessible_organization_repositories.md.tmpl @@ -0,0 +1,49 @@ +--- +page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +{{ if .HasExamples -}} +## Example Usage + +{{- range .ExampleFiles }} + +{{ tffile . }} +{{- end }} +{{- end }} + +" }} +{{ $line }} +{{- end }} +{{- end }} +--> + +## Schema + +### Required + +- `enterprise_slug` (String) The slug of the enterprise that owns the organization. +- `organization` (String) The login of the enterprise-owned organization. + +### Read-Only + +- `id` (String) The ID of this resource. +- `repositories` (List of Object) List of repositories of the organization that GitHub Apps can be granted access to. (see [below for nested schema](#nestedatt--repositories)) + + +### Nested Schema for `repositories` + +Read-Only: + +- `full_name` (String) The full name of the repository, in the format `/`. +- `id` (Number) The ID of the repository. +- `name` (String) The name of the repository. diff --git a/templates/data-sources/enterprise_app_installable_organizations.md.tmpl b/templates/data-sources/enterprise_app_installable_organizations.md.tmpl new file mode 100644 index 0000000000..948d993e18 --- /dev/null +++ b/templates/data-sources/enterprise_app_installable_organizations.md.tmpl @@ -0,0 +1,48 @@ +--- +page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +{{ if .HasExamples -}} +## Example Usage + +{{- range .ExampleFiles }} + +{{ tffile . }} +{{- end }} +{{- end }} + +" }} +{{ $line }} +{{- end }} +{{- end }} +--> + +## Schema + +### Required + +- `enterprise_slug` (String) The slug of the enterprise. + +### Read-Only + +- `id` (String) The ID of this resource. +- `organizations` (List of Object) List of organizations in the enterprise that GitHub Apps can be installed on. (see [below for nested schema](#nestedatt--organizations)) + + +### Nested Schema for `organizations` + +Read-Only: + +- `accessible_repositories_url` (String) The API URL listing the repositories that can be made accessible to a GitHub App installed on the organization. +- `id` (Number) The ID of the organization. +- `login` (String) The login of the organization. diff --git a/templates/data-sources/enterprise_app_installations.md.tmpl b/templates/data-sources/enterprise_app_installations.md.tmpl new file mode 100644 index 0000000000..96065996ba --- /dev/null +++ b/templates/data-sources/enterprise_app_installations.md.tmpl @@ -0,0 +1,59 @@ +--- +page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +subcategory: "" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +{{ if .HasExamples -}} +## Example Usage + +{{- range .ExampleFiles }} + +{{ tffile . }} +{{- end }} +{{- end }} + +" }} +{{ $line }} +{{- end }} +{{- end }} +--> + +## Schema + +### Required + +- `enterprise_slug` (String) The slug of the enterprise that owns the organization. +- `organization` (String) The login of the enterprise-owned organization. + +### Read-Only + +- `id` (String) The ID of this resource. +- `installations` (List of Object) List of GitHub App installations on the organization. (see [below for nested schema](#nestedatt--installations)) + + +### Nested Schema for `installations` + +Read-Only: + +- `app_id` (Number) The ID of the GitHub App. +- `app_slug` (String) The URL-friendly name of the GitHub App. +- `client_id` (String) The OAuth client ID of the GitHub App. +- `created_at` (String) The date the GitHub App installation was created. +- `events` (List of String) The list of events the GitHub App installation subscribes to. +- `id` (Number) The ID of the GitHub App installation. +- `permissions` (Map of String) The permissions granted to the GitHub App installation. +- `repository_selection` (String) Whether the installation has access to all repositories or only selected ones. Possible values are `all` or `selected`. +- `single_file_paths` (List of String) The list of single file paths the GitHub App installation has access to. +- `suspended` (Boolean) Whether the GitHub App installation is currently suspended. +- `target_id` (Number) The ID of the account the GitHub App is installed on. +- `target_type` (String) The type of account the GitHub App is installed on. Possible values are `Organization` or `User`. +- `updated_at` (String) The date the GitHub App installation was last updated. From ea41514574e55e3250d1b0383fcd7745732a4aa9 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Sun, 9 Aug 2026 19:18:26 +0100 Subject: [PATCH 05/12] docs: Generate enterprise app installation docs from the default template Drop the hand-written per-resource templates in favour of the default tfplugindocs template, so the docs stay in sync with the schema. Move the resource example to the path tfplugindocs discovers (examples/resources/github_enterprise_app_installation/resource.tf) and add import.sh so the import section still renders; the previous path only worked because the hand-written template referenced it by name. Nested attribute descriptions on the data sources' computed lists are lost in the process: SDKv2 collapses computed-only Elem resources to a plain object type, so tfplugindocs renders them as "List of Object". Co-Authored-By: Claude Opus 5 (1M context) --- ...pp_accessible_organization_repositories.md | 24 +------- ...nterprise_app_installable_organizations.md | 23 +------- .../enterprise_app_installations.md | 34 +---------- docs/resources/enterprise_app_installation.md | 37 ++++++------ .../import.sh | 1 + .../resource.tf} | 0 ...cessible_organization_repositories.md.tmpl | 49 --------------- ...rise_app_installable_organizations.md.tmpl | 48 --------------- .../enterprise_app_installations.md.tmpl | 59 ------------------- .../enterprise_app_installation.md.tmpl | 41 ------------- 10 files changed, 24 insertions(+), 292 deletions(-) create mode 100644 examples/resources/github_enterprise_app_installation/import.sh rename examples/resources/{enterprise_app_installation/example_1.tf => github_enterprise_app_installation/resource.tf} (100%) delete mode 100644 templates/data-sources/enterprise_app_accessible_organization_repositories.md.tmpl delete mode 100644 templates/data-sources/enterprise_app_installable_organizations.md.tmpl delete mode 100644 templates/data-sources/enterprise_app_installations.md.tmpl delete mode 100644 templates/resources/enterprise_app_installation.md.tmpl diff --git a/docs/data-sources/enterprise_app_accessible_organization_repositories.md b/docs/data-sources/enterprise_app_accessible_organization_repositories.md index 535d925a37..90fc357885 100644 --- a/docs/data-sources/enterprise_app_accessible_organization_repositories.md +++ b/docs/data-sources/enterprise_app_accessible_organization_repositories.md @@ -18,7 +18,7 @@ data "github_enterprise_app_accessible_organization_repositories" "example" { } ``` - ## Schema ### Required @@ -39,25 +39,3 @@ Read-Only: - `full_name` (String) - `id` (Number) - `name` (String) ---> - -## Schema - -### Required - -- `enterprise_slug` (String) The slug of the enterprise that owns the organization. -- `organization` (String) The login of the enterprise-owned organization. - -### Read-Only - -- `id` (String) The ID of this resource. -- `repositories` (List of Object) List of repositories of the organization that GitHub Apps can be granted access to. (see [below for nested schema](#nestedatt--repositories)) - - -### Nested Schema for `repositories` - -Read-Only: - -- `full_name` (String) The full name of the repository, in the format `/`. -- `id` (Number) The ID of the repository. -- `name` (String) The name of the repository. diff --git a/docs/data-sources/enterprise_app_installable_organizations.md b/docs/data-sources/enterprise_app_installable_organizations.md index 77f5dcdb45..e0ce62e545 100644 --- a/docs/data-sources/enterprise_app_installable_organizations.md +++ b/docs/data-sources/enterprise_app_installable_organizations.md @@ -17,7 +17,7 @@ data "github_enterprise_app_installable_organizations" "example" { } ``` - ## Schema ### Required @@ -37,24 +37,3 @@ Read-Only: - `accessible_repositories_url` (String) - `id` (Number) - `login` (String) ---> - -## Schema - -### Required - -- `enterprise_slug` (String) The slug of the enterprise. - -### Read-Only - -- `id` (String) The ID of this resource. -- `organizations` (List of Object) List of organizations in the enterprise that GitHub Apps can be installed on. (see [below for nested schema](#nestedatt--organizations)) - - -### Nested Schema for `organizations` - -Read-Only: - -- `accessible_repositories_url` (String) The API URL listing the repositories that can be made accessible to a GitHub App installed on the organization. -- `id` (Number) The ID of the organization. -- `login` (String) The login of the organization. diff --git a/docs/data-sources/enterprise_app_installations.md b/docs/data-sources/enterprise_app_installations.md index 88c539a3b5..244d2c2587 100644 --- a/docs/data-sources/enterprise_app_installations.md +++ b/docs/data-sources/enterprise_app_installations.md @@ -18,7 +18,7 @@ data "github_enterprise_app_installations" "example" { } ``` - ## Schema ### Required @@ -49,35 +49,3 @@ Read-Only: - `target_id` (Number) - `target_type` (String) - `updated_at` (String) ---> - -## Schema - -### Required - -- `enterprise_slug` (String) The slug of the enterprise that owns the organization. -- `organization` (String) The login of the enterprise-owned organization. - -### Read-Only - -- `id` (String) The ID of this resource. -- `installations` (List of Object) List of GitHub App installations on the organization. (see [below for nested schema](#nestedatt--installations)) - - -### Nested Schema for `installations` - -Read-Only: - -- `app_id` (Number) The ID of the GitHub App. -- `app_slug` (String) The URL-friendly name of the GitHub App. -- `client_id` (String) The OAuth client ID of the GitHub App. -- `created_at` (String) The date the GitHub App installation was created. -- `events` (List of String) The list of events the GitHub App installation subscribes to. -- `id` (Number) The ID of the GitHub App installation. -- `permissions` (Map of String) The permissions granted to the GitHub App installation. -- `repository_selection` (String) Whether the installation has access to all repositories or only selected ones. Possible values are `all` or `selected`. -- `single_file_paths` (List of String) The list of single file paths the GitHub App installation has access to. -- `suspended` (Boolean) Whether the GitHub App installation is currently suspended. -- `target_id` (Number) The ID of the account the GitHub App is installed on. -- `target_type` (String) The type of account the GitHub App is installed on. Possible values are `Organization` or `User`. -- `updated_at` (String) The date the GitHub App installation was last updated. diff --git a/docs/resources/enterprise_app_installation.md b/docs/resources/enterprise_app_installation.md index 1ec0c657c4..f7b7beebb4 100644 --- a/docs/resources/enterprise_app_installation.md +++ b/docs/resources/enterprise_app_installation.md @@ -1,14 +1,13 @@ --- page_title: "github_enterprise_app_installation (Resource) - GitHub" +subcategory: "" description: |- - Manages a GitHub App installation on an enterprise-owned organization. + Manage a GitHub App installation on an enterprise-owned organization. This resource requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. --- # github_enterprise_app_installation (Resource) -This resource allows you to install a GitHub App on an organization owned by a GitHub Enterprise, including granting the app access to **all** repositories in the organization. - -~> **Note**: This resource is only available on GitHub Enterprise Cloud and GitHub Enterprise Server 3.19 or later. The authenticated user must be an [enterprise owner](https://docs.github.com/en/enterprise-cloud@latest/admin/managing-accounts-and-repositories/managing-users-in-your-enterprise/roles-in-an-enterprise#enterprise-owners), and the API is not available for organizations that are not part of an enterprise. To manage the repositories of an app installation on a non-enterprise organization, see the `github_app_installation_repositories` resource. +Manage a GitHub App installation on an enterprise-owned organization. This resource requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. ## Example Usage @@ -29,27 +28,31 @@ resource "github_enterprise_app_installation" "selected_repos" { } ``` -## Argument Reference + +## Schema + +### Required -The following arguments are supported: +- `client_id` (String) The client ID of the GitHub App to install. +- `enterprise_slug` (String) The slug of the enterprise that owns the organization. +- `organization` (String) The login of the enterprise-owned organization to install the app on. +- `repository_selection` (String) The repositories the installation can access. Can be one of 'all', 'selected' or 'none'. -- `enterprise_slug` - (Required) The slug of the enterprise that owns the organization. -- `organization` - (Required) The login of the enterprise-owned organization to install the app on. -- `client_id` - (Required) The client ID of the GitHub App to install. -- `repository_selection` - (Required) The repositories the installation can access. Can be one of `all`, `selected` or `none`. Changing between `all` and `selected` is applied in place; changing from or to `none` will recreate the installation. -- `selected_repositories` - (Optional) The names of the repositories the installation can access. Required when `repository_selection` is `selected`, and must not be set otherwise. +### Optional -## Attributes Reference +- `selected_repositories` (Set of String) The names of the repositories the installation can access when 'repository_selection' is 'selected'. -The following additional attributes are exported: +### Read-Only -- `id` - The ID of the resource in the format `::`. -- `installation_id` - The ID of the app installation. -- `app_slug` - The slug of the installed app. +- `app_slug` (String) The slug of the installed app. +- `id` (String) The ID of this resource. +- `installation_id` (String) The ID of the installation. ## Import -GitHub enterprise app installations can be imported using the enterprise slug, the organization login and the app client ID, separated by `:` characters. +Import is supported using the following syntax: + +The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: ```shell terraform import github_enterprise_app_installation.all_repos my-enterprise:my-org:Iv1.abc123def456 diff --git a/examples/resources/github_enterprise_app_installation/import.sh b/examples/resources/github_enterprise_app_installation/import.sh new file mode 100644 index 0000000000..acad6d01f0 --- /dev/null +++ b/examples/resources/github_enterprise_app_installation/import.sh @@ -0,0 +1 @@ +terraform import github_enterprise_app_installation.all_repos my-enterprise:my-org:Iv1.abc123def456 diff --git a/examples/resources/enterprise_app_installation/example_1.tf b/examples/resources/github_enterprise_app_installation/resource.tf similarity index 100% rename from examples/resources/enterprise_app_installation/example_1.tf rename to examples/resources/github_enterprise_app_installation/resource.tf diff --git a/templates/data-sources/enterprise_app_accessible_organization_repositories.md.tmpl b/templates/data-sources/enterprise_app_accessible_organization_repositories.md.tmpl deleted file mode 100644 index 832312f3a9..0000000000 --- a/templates/data-sources/enterprise_app_accessible_organization_repositories.md.tmpl +++ /dev/null @@ -1,49 +0,0 @@ ---- -page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" -subcategory: "" -description: |- -{{ .Description | plainmarkdown | trimspace | prefixlines " " }} ---- - -# {{.Name}} ({{.Type}}) - -{{ .Description | trimspace }} - -{{ if .HasExamples -}} -## Example Usage - -{{- range .ExampleFiles }} - -{{ tffile . }} -{{- end }} -{{- end }} - -" }} -{{ $line }} -{{- end }} -{{- end }} ---> - -## Schema - -### Required - -- `enterprise_slug` (String) The slug of the enterprise that owns the organization. -- `organization` (String) The login of the enterprise-owned organization. - -### Read-Only - -- `id` (String) The ID of this resource. -- `repositories` (List of Object) List of repositories of the organization that GitHub Apps can be granted access to. (see [below for nested schema](#nestedatt--repositories)) - - -### Nested Schema for `repositories` - -Read-Only: - -- `full_name` (String) The full name of the repository, in the format `/`. -- `id` (Number) The ID of the repository. -- `name` (String) The name of the repository. diff --git a/templates/data-sources/enterprise_app_installable_organizations.md.tmpl b/templates/data-sources/enterprise_app_installable_organizations.md.tmpl deleted file mode 100644 index 948d993e18..0000000000 --- a/templates/data-sources/enterprise_app_installable_organizations.md.tmpl +++ /dev/null @@ -1,48 +0,0 @@ ---- -page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" -subcategory: "" -description: |- -{{ .Description | plainmarkdown | trimspace | prefixlines " " }} ---- - -# {{.Name}} ({{.Type}}) - -{{ .Description | trimspace }} - -{{ if .HasExamples -}} -## Example Usage - -{{- range .ExampleFiles }} - -{{ tffile . }} -{{- end }} -{{- end }} - -" }} -{{ $line }} -{{- end }} -{{- end }} ---> - -## Schema - -### Required - -- `enterprise_slug` (String) The slug of the enterprise. - -### Read-Only - -- `id` (String) The ID of this resource. -- `organizations` (List of Object) List of organizations in the enterprise that GitHub Apps can be installed on. (see [below for nested schema](#nestedatt--organizations)) - - -### Nested Schema for `organizations` - -Read-Only: - -- `accessible_repositories_url` (String) The API URL listing the repositories that can be made accessible to a GitHub App installed on the organization. -- `id` (Number) The ID of the organization. -- `login` (String) The login of the organization. diff --git a/templates/data-sources/enterprise_app_installations.md.tmpl b/templates/data-sources/enterprise_app_installations.md.tmpl deleted file mode 100644 index 96065996ba..0000000000 --- a/templates/data-sources/enterprise_app_installations.md.tmpl +++ /dev/null @@ -1,59 +0,0 @@ ---- -page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" -subcategory: "" -description: |- -{{ .Description | plainmarkdown | trimspace | prefixlines " " }} ---- - -# {{.Name}} ({{.Type}}) - -{{ .Description | trimspace }} - -{{ if .HasExamples -}} -## Example Usage - -{{- range .ExampleFiles }} - -{{ tffile . }} -{{- end }} -{{- end }} - -" }} -{{ $line }} -{{- end }} -{{- end }} ---> - -## Schema - -### Required - -- `enterprise_slug` (String) The slug of the enterprise that owns the organization. -- `organization` (String) The login of the enterprise-owned organization. - -### Read-Only - -- `id` (String) The ID of this resource. -- `installations` (List of Object) List of GitHub App installations on the organization. (see [below for nested schema](#nestedatt--installations)) - - -### Nested Schema for `installations` - -Read-Only: - -- `app_id` (Number) The ID of the GitHub App. -- `app_slug` (String) The URL-friendly name of the GitHub App. -- `client_id` (String) The OAuth client ID of the GitHub App. -- `created_at` (String) The date the GitHub App installation was created. -- `events` (List of String) The list of events the GitHub App installation subscribes to. -- `id` (Number) The ID of the GitHub App installation. -- `permissions` (Map of String) The permissions granted to the GitHub App installation. -- `repository_selection` (String) Whether the installation has access to all repositories or only selected ones. Possible values are `all` or `selected`. -- `single_file_paths` (List of String) The list of single file paths the GitHub App installation has access to. -- `suspended` (Boolean) Whether the GitHub App installation is currently suspended. -- `target_id` (Number) The ID of the account the GitHub App is installed on. -- `target_type` (String) The type of account the GitHub App is installed on. Possible values are `Organization` or `User`. -- `updated_at` (String) The date the GitHub App installation was last updated. diff --git a/templates/resources/enterprise_app_installation.md.tmpl b/templates/resources/enterprise_app_installation.md.tmpl deleted file mode 100644 index f47bf6ac15..0000000000 --- a/templates/resources/enterprise_app_installation.md.tmpl +++ /dev/null @@ -1,41 +0,0 @@ ---- -page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" -description: |- - Manages a GitHub App installation on an enterprise-owned organization. ---- - -# {{.Name}} ({{.Type}}) - -This resource allows you to install a GitHub App on an organization owned by a GitHub Enterprise, including granting the app access to **all** repositories in the organization. - -~> **Note**: This resource is only available on GitHub Enterprise Cloud and GitHub Enterprise Server 3.19 or later. The authenticated user must be an [enterprise owner](https://docs.github.com/en/enterprise-cloud@latest/admin/managing-accounts-and-repositories/managing-users-in-your-enterprise/roles-in-an-enterprise#enterprise-owners), and the API is not available for organizations that are not part of an enterprise. To manage the repositories of an app installation on a non-enterprise organization, see the `github_app_installation_repositories` resource. - -## Example Usage - -{{tffile "examples/resources/enterprise_app_installation/example_1.tf"}} - -## Argument Reference - -The following arguments are supported: - -- `enterprise_slug` - (Required) The slug of the enterprise that owns the organization. -- `organization` - (Required) The login of the enterprise-owned organization to install the app on. -- `client_id` - (Required) The client ID of the GitHub App to install. -- `repository_selection` - (Required) The repositories the installation can access. Can be one of `all`, `selected` or `none`. Changing between `all` and `selected` is applied in place; changing from or to `none` will recreate the installation. -- `selected_repositories` - (Optional) The names of the repositories the installation can access. Required when `repository_selection` is `selected`, and must not be set otherwise. - -## Attributes Reference - -The following additional attributes are exported: - -- `id` - The ID of the resource in the format `::`. -- `installation_id` - The ID of the app installation. -- `app_slug` - The slug of the installed app. - -## Import - -GitHub enterprise app installations can be imported using the enterprise slug, the organization login and the app client ID, separated by `:` characters. - -```shell -terraform import github_enterprise_app_installation.all_repos my-enterprise:my-org:Iv1.abc123def456 -``` From 964d64b313bc21771b0e2796c1c9c8fd1f003b99 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 10 Aug 2026 13:38:30 +0100 Subject: [PATCH 06/12] fix: Persist installation identifiers and drop read-after-write Create ended by calling the read function, which resolves the installation through a list endpoint. An eventually consistent list that does not yet include the new installation would clear the resource ID, leaving the installation orphaned outside state. Update had the same problem in reverse, writing a stale repository selection back over the value that was just applied. Both endpoints return the installation, so set the computed fields from the install response and return without a read, in line with the provider's no-read-after-write convention (#2892). 'installation_id' is now written before the remaining repositories are granted, so a failure there still leaves a resource that Delete can uninstall. Also skip the repository selection diff validation while either value is unknown; unknown values read back as their zero value, so a valid 'selected_repositories' computed from another resource failed planning with "must be set". Co-Authored-By: Claude Opus 5 (1M context) --- ...urce_github_enterprise_app_installation.go | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/github/resource_github_enterprise_app_installation.go b/github/resource_github_enterprise_app_installation.go index 6f991c4e86..7ebe0bd04d 100644 --- a/github/resource_github_enterprise_app_installation.go +++ b/github/resource_github_enterprise_app_installation.go @@ -83,6 +83,12 @@ func resourceGithubEnterpriseAppInstallation() *schema.Resource { return oldValue.(string) == "none" || newValue.(string) == "none" }), func(ctx context.Context, d *schema.ResourceDiff, meta any) error { + // Unknown values read back as their zero value, which would + // fail this check for configurations that are still valid. + if !d.NewValueKnown("repository_selection") || !d.NewValueKnown("selected_repositories") { + return nil + } + selection := d.Get("repository_selection").(string) repoCount := d.Get("selected_repositories").(*schema.Set).Len() if selection == "selected" && repoCount == 0 { @@ -131,20 +137,27 @@ func resourceGithubEnterpriseAppInstallationCreate(ctx context.Context, d *schem return diag.FromErr(err) } - // Record the resource in state as soon as the app is installed, so a + // Record the installation in state as soon as the app is installed, so a // failure while granting the remaining repositories leaves a recoverable - // resource rather than an orphaned installation. + // resource rather than an orphaned installation. Delete resolves the + // installation through 'installation_id', so it has to be set here too. id, err := buildID(enterpriseSlug, org, clientID) if err != nil { return diag.FromErr(err) } d.SetId(id) + if err := d.Set("installation_id", strconv.FormatInt(installation.GetID(), 10)); err != nil { + return diag.FromErr(err) + } + if err := d.Set("app_slug", installation.GetAppSlug()); err != nil { + return diag.FromErr(err) + } if err := addEnterpriseAppInstallationRepositories(ctx, client, enterpriseSlug, org, installation.GetID(), remainder); err != nil { return diag.FromErr(err) } - return resourceGithubEnterpriseAppInstallationRead(ctx, d, meta) + return nil } func resourceGithubEnterpriseAppInstallationRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { @@ -290,7 +303,7 @@ func resourceGithubEnterpriseAppInstallationUpdate(ctx context.Context, d *schem } } - return resourceGithubEnterpriseAppInstallationRead(ctx, d, meta) + return nil } func resourceGithubEnterpriseAppInstallationDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { From f2c0cab289e3aae44e4b0bc186f9bd4755fba123 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 10 Aug 2026 13:38:34 +0100 Subject: [PATCH 07/12] test: Use ConfigStateChecks for app installation assertions New tests are expected to assert with ConfigStateChecks rather than the legacy Check and TestCheckResourceAttr pattern. Co-Authored-By: Claude Opus 5 (1M context) --- ...github_enterprise_app_installation_test.go | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/github/resource_github_enterprise_app_installation_test.go b/github/resource_github_enterprise_app_installation_test.go index a3ca3586cf..47c96f8310 100644 --- a/github/resource_github_enterprise_app_installation_test.go +++ b/github/resource_github_enterprise_app_installation_test.go @@ -7,6 +7,9 @@ import ( "github.com/hashicorp/terraform-plugin-testing/helper/acctest" "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" ) func TestAccGithubEnterpriseAppInstallation(t *testing.T) { @@ -28,15 +31,6 @@ func TestAccGithubEnterpriseAppInstallation(t *testing.T) { } `, testAccConf.enterpriseSlug, testAccConf.owner, appClientID) - check := resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_enterprise_app_installation.test", "enterprise_slug", testAccConf.enterpriseSlug), - resource.TestCheckResourceAttr("github_enterprise_app_installation.test", "organization", testAccConf.owner), - resource.TestCheckResourceAttr("github_enterprise_app_installation.test", "client_id", appClientID), - resource.TestCheckResourceAttr("github_enterprise_app_installation.test", "repository_selection", "all"), - resource.TestCheckResourceAttrSet("github_enterprise_app_installation.test", "installation_id"), - resource.TestCheckResourceAttrSet("github_enterprise_app_installation.test", "app_slug"), - ) - resource.Test(t, resource.TestCase{ PreCheck: func() { skipUnlessEnterprise(t) @@ -46,7 +40,14 @@ func TestAccGithubEnterpriseAppInstallation(t *testing.T) { Steps: []resource.TestStep{ { Config: config, - Check: check, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("enterprise_slug"), knownvalue.StringExact(testAccConf.enterpriseSlug)), + statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("organization"), knownvalue.StringExact(testAccConf.owner)), + statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("client_id"), knownvalue.StringExact(appClientID)), + statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("repository_selection"), knownvalue.StringExact("all")), + statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("installation_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("app_slug"), knownvalue.NotNull()), + }, }, { ResourceName: "github_enterprise_app_installation.test", @@ -98,17 +99,17 @@ func TestAccGithubEnterpriseAppInstallation(t *testing.T) { Steps: []resource.TestStep{ { Config: configSelected, - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_enterprise_app_installation.test", "repository_selection", "selected"), - resource.TestCheckResourceAttr("github_enterprise_app_installation.test", "selected_repositories.#", "1"), - ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("repository_selection"), knownvalue.StringExact("selected")), + statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("selected_repositories"), knownvalue.SetSizeExact(1)), + }, }, { Config: configAll, - Check: resource.ComposeTestCheckFunc( - resource.TestCheckResourceAttr("github_enterprise_app_installation.test", "repository_selection", "all"), - resource.TestCheckResourceAttr("github_enterprise_app_installation.test", "selected_repositories.#", "0"), - ), + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("repository_selection"), knownvalue.StringExact("all")), + statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("selected_repositories"), knownvalue.SetSizeExact(0)), + }, }, }, }) From 4a18917cd4561e3451e05e780a02e346c360d2fb Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Mon, 10 Aug 2026 16:21:08 +0100 Subject: [PATCH 08/12] refactor: Rename resources to name the organization they target 'github_enterprise_app_installation' reads as though it installs an app on an enterprise account. It does not: every endpoint behind it is scoped to an organization the enterprise owns, and GitHub has no API for installing an app on the enterprise account itself. The only enterprise-level operation GitHub exposes is 'GET /enterprises/{enterprise}/installation', which lets an app find its own installation, so the distinction cannot be resolved by adding a second resource. Name the target instead: github_enterprise_app_installation -> github_enterprise_organization_app_installation github_enterprise_app_installations -> github_enterprise_organization_app_installations github_enterprise_app_accessible_organization_repositories -> github_enterprise_organization_app_accessible_repositories 'github_enterprise_app_installable_organizations' keeps its name; it is the one API here that is genuinely enterprise-scoped. The resource description now states that the app is installed on the organization rather than the enterprise account. Nothing here has been released, so no state migration or 'moved' block is required. Co-Authored-By: Claude Opus 5 (1M context) --- RESOURCES.md | 6 +-- ...ganization_app_accessible_repositories.md} | 6 +-- ...erprise_organization_app_installations.md} | 6 +-- ...terprise_organization_app_installation.md} | 14 +++--- .../data-source_1.tf | 4 -- .../data-source_1.tf | 2 +- .../data-source_1.tf | 4 ++ .../import.sh | 1 - .../import.sh | 1 + .../resource.tf | 4 +- ...ganization_app_accessible_repositories.go} | 6 +-- ...ation_app_accessible_repositories_test.go} | 6 +-- ...erprise_organization_app_installations.go} | 6 +-- ...se_organization_app_installations_test.go} | 6 +-- github/provider.go | 6 +-- ...terprise_organization_app_installation.go} | 43 ++++++++++--------- ...ise_organization_app_installation_test.go} | 30 ++++++------- 17 files changed, 76 insertions(+), 75 deletions(-) rename docs/data-sources/{enterprise_app_accessible_organization_repositories.md => enterprise_organization_app_accessible_repositories.md} (87%) rename docs/data-sources/{enterprise_app_installations.md => enterprise_organization_app_installations.md} (87%) rename docs/resources/{enterprise_app_installation.md => enterprise_organization_app_installation.md} (59%) delete mode 100644 examples/data-sources/github_enterprise_app_installations/data-source_1.tf rename examples/data-sources/{github_enterprise_app_accessible_organization_repositories => github_enterprise_organization_app_accessible_repositories}/data-source_1.tf (55%) create mode 100644 examples/data-sources/github_enterprise_organization_app_installations/data-source_1.tf delete mode 100644 examples/resources/github_enterprise_app_installation/import.sh create mode 100644 examples/resources/github_enterprise_organization_app_installation/import.sh rename examples/resources/{github_enterprise_app_installation => github_enterprise_organization_app_installation}/resource.tf (70%) rename github/{data_source_github_enterprise_app_accessible_organization_repositories.go => data_source_github_enterprise_organization_app_accessible_repositories.go} (92%) rename github/{data_source_github_enterprise_app_accessible_organization_repositories_test.go => data_source_github_enterprise_organization_app_accessible_repositories_test.go} (83%) rename github/{data_source_github_enterprise_app_installations.go => data_source_github_enterprise_organization_app_installations.go} (93%) rename github/{data_source_github_enterprise_app_installations_test.go => data_source_github_enterprise_organization_app_installations_test.go} (71%) rename github/{resource_github_enterprise_app_installation.go => resource_github_enterprise_organization_app_installation.go} (83%) rename github/{resource_github_enterprise_app_installation_test.go => resource_github_enterprise_organization_app_installation_test.go} (57%) diff --git a/RESOURCES.md b/RESOURCES.md index 96d6872b59..f0b3fa8283 100644 --- a/RESOURCES.md +++ b/RESOURCES.md @@ -69,9 +69,9 @@ The overall status of each resource or data source is captured in this document | `github_dependabot_public_key` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | | `github_dependabot_secrets` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | | `github_enterprise` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | -| `github_enterprise_app_accessible_organization_repositories` | ✅ | ✅ | ✅ | ✅ | ❓ | ✅ | | `github_enterprise_app_installable_organizations` | ✅ | ✅ | ✅ | ✅ | ❓ | ✅ | -| `github_enterprise_app_installations` | ✅ | ✅ | ✅ | ✅ | ❓ | ✅ | +| `github_enterprise_organization_app_accessible_repositories` | ✅ | ✅ | ✅ | ✅ | ❓ | ✅ | +| `github_enterprise_organization_app_installations` | ✅ | ✅ | ✅ | ✅ | ❓ | ✅ | | `github_external_groups` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | `github_ip_ranges` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | | `github_issue_labels` | ⚠️ | ✅ | ❓ | ❓ | ❓ | ❓ | @@ -163,9 +163,9 @@ The overall status of each resource or data source is captured in this document | `github_enterprise_actions_permissions` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_enterprise_actions_runner_group` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_enterprise_actions_workflow_permissions` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | -| `github_enterprise_app_installation` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_enterprise_ip_allow_list_entry` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_enterprise_organization` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | +| `github_enterprise_organization_app_installation` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_enterprise_security_analysis_settings` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_issue` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | | `github_issue_label` | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | ❓ | diff --git a/docs/data-sources/enterprise_app_accessible_organization_repositories.md b/docs/data-sources/enterprise_organization_app_accessible_repositories.md similarity index 87% rename from docs/data-sources/enterprise_app_accessible_organization_repositories.md rename to docs/data-sources/enterprise_organization_app_accessible_repositories.md index 90fc357885..11d33a6250 100644 --- a/docs/data-sources/enterprise_app_accessible_organization_repositories.md +++ b/docs/data-sources/enterprise_organization_app_accessible_repositories.md @@ -1,18 +1,18 @@ --- -page_title: "github_enterprise_app_accessible_organization_repositories (Data Source) - GitHub" +page_title: "github_enterprise_organization_app_accessible_repositories (Data Source) - GitHub" subcategory: "" description: |- Use this data source to retrieve the repositories of an enterprise-owned organization that GitHub Apps can be granted access to. This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. --- -# github_enterprise_app_accessible_organization_repositories (Data Source) +# github_enterprise_organization_app_accessible_repositories (Data Source) Use this data source to retrieve the repositories of an enterprise-owned organization that GitHub Apps can be granted access to. This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. ## Example Usage ```terraform -data "github_enterprise_app_accessible_organization_repositories" "example" { +data "github_enterprise_organization_app_accessible_repositories" "example" { enterprise_slug = "my-enterprise" organization = "my-org" } diff --git a/docs/data-sources/enterprise_app_installations.md b/docs/data-sources/enterprise_organization_app_installations.md similarity index 87% rename from docs/data-sources/enterprise_app_installations.md rename to docs/data-sources/enterprise_organization_app_installations.md index 244d2c2587..0bdc35ab8e 100644 --- a/docs/data-sources/enterprise_app_installations.md +++ b/docs/data-sources/enterprise_organization_app_installations.md @@ -1,18 +1,18 @@ --- -page_title: "github_enterprise_app_installations (Data Source) - GitHub" +page_title: "github_enterprise_organization_app_installations (Data Source) - GitHub" subcategory: "" description: |- Use this data source to retrieve the GitHub App installations on an enterprise-owned organization. This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. --- -# github_enterprise_app_installations (Data Source) +# github_enterprise_organization_app_installations (Data Source) Use this data source to retrieve the GitHub App installations on an enterprise-owned organization. This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. ## Example Usage ```terraform -data "github_enterprise_app_installations" "example" { +data "github_enterprise_organization_app_installations" "example" { enterprise_slug = "my-enterprise" organization = "my-org" } diff --git a/docs/resources/enterprise_app_installation.md b/docs/resources/enterprise_organization_app_installation.md similarity index 59% rename from docs/resources/enterprise_app_installation.md rename to docs/resources/enterprise_organization_app_installation.md index f7b7beebb4..6fc6d382fd 100644 --- a/docs/resources/enterprise_app_installation.md +++ b/docs/resources/enterprise_organization_app_installation.md @@ -1,25 +1,25 @@ --- -page_title: "github_enterprise_app_installation (Resource) - GitHub" +page_title: "github_enterprise_organization_app_installation (Resource) - GitHub" subcategory: "" description: |- - Manage a GitHub App installation on an enterprise-owned organization. This resource requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. + Manage the installation of a GitHub App on an organization owned by an enterprise. The app is installed on the organization, not on the enterprise account itself; GitHub offers no API for the latter. This resource requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. --- -# github_enterprise_app_installation (Resource) +# github_enterprise_organization_app_installation (Resource) -Manage a GitHub App installation on an enterprise-owned organization. This resource requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. +Manage the installation of a GitHub App on an organization owned by an enterprise. The app is installed on the organization, not on the enterprise account itself; GitHub offers no API for the latter. This resource requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner. ## Example Usage ```terraform -resource "github_enterprise_app_installation" "all_repos" { +resource "github_enterprise_organization_app_installation" "all_repos" { enterprise_slug = "my-enterprise" organization = "my-org" client_id = "Iv1.abc123def456" repository_selection = "all" } -resource "github_enterprise_app_installation" "selected_repos" { +resource "github_enterprise_organization_app_installation" "selected_repos" { enterprise_slug = "my-enterprise" organization = "my-org" client_id = "Iv1.789ghi012jkl" @@ -55,5 +55,5 @@ Import is supported using the following syntax: The [`terraform import` command](https://developer.hashicorp.com/terraform/cli/commands/import) can be used, for example: ```shell -terraform import github_enterprise_app_installation.all_repos my-enterprise:my-org:Iv1.abc123def456 +terraform import github_enterprise_organization_app_installation.all_repos my-enterprise:my-org:Iv1.abc123def456 ``` diff --git a/examples/data-sources/github_enterprise_app_installations/data-source_1.tf b/examples/data-sources/github_enterprise_app_installations/data-source_1.tf deleted file mode 100644 index 5879719cd6..0000000000 --- a/examples/data-sources/github_enterprise_app_installations/data-source_1.tf +++ /dev/null @@ -1,4 +0,0 @@ -data "github_enterprise_app_installations" "example" { - enterprise_slug = "my-enterprise" - organization = "my-org" -} diff --git a/examples/data-sources/github_enterprise_app_accessible_organization_repositories/data-source_1.tf b/examples/data-sources/github_enterprise_organization_app_accessible_repositories/data-source_1.tf similarity index 55% rename from examples/data-sources/github_enterprise_app_accessible_organization_repositories/data-source_1.tf rename to examples/data-sources/github_enterprise_organization_app_accessible_repositories/data-source_1.tf index 29e387abf7..3c0860968a 100644 --- a/examples/data-sources/github_enterprise_app_accessible_organization_repositories/data-source_1.tf +++ b/examples/data-sources/github_enterprise_organization_app_accessible_repositories/data-source_1.tf @@ -1,4 +1,4 @@ -data "github_enterprise_app_accessible_organization_repositories" "example" { +data "github_enterprise_organization_app_accessible_repositories" "example" { enterprise_slug = "my-enterprise" organization = "my-org" } diff --git a/examples/data-sources/github_enterprise_organization_app_installations/data-source_1.tf b/examples/data-sources/github_enterprise_organization_app_installations/data-source_1.tf new file mode 100644 index 0000000000..a8ea83f3a0 --- /dev/null +++ b/examples/data-sources/github_enterprise_organization_app_installations/data-source_1.tf @@ -0,0 +1,4 @@ +data "github_enterprise_organization_app_installations" "example" { + enterprise_slug = "my-enterprise" + organization = "my-org" +} diff --git a/examples/resources/github_enterprise_app_installation/import.sh b/examples/resources/github_enterprise_app_installation/import.sh deleted file mode 100644 index acad6d01f0..0000000000 --- a/examples/resources/github_enterprise_app_installation/import.sh +++ /dev/null @@ -1 +0,0 @@ -terraform import github_enterprise_app_installation.all_repos my-enterprise:my-org:Iv1.abc123def456 diff --git a/examples/resources/github_enterprise_organization_app_installation/import.sh b/examples/resources/github_enterprise_organization_app_installation/import.sh new file mode 100644 index 0000000000..1c9a0d0f2a --- /dev/null +++ b/examples/resources/github_enterprise_organization_app_installation/import.sh @@ -0,0 +1 @@ +terraform import github_enterprise_organization_app_installation.all_repos my-enterprise:my-org:Iv1.abc123def456 diff --git a/examples/resources/github_enterprise_app_installation/resource.tf b/examples/resources/github_enterprise_organization_app_installation/resource.tf similarity index 70% rename from examples/resources/github_enterprise_app_installation/resource.tf rename to examples/resources/github_enterprise_organization_app_installation/resource.tf index d3acb06efe..a29a80a233 100644 --- a/examples/resources/github_enterprise_app_installation/resource.tf +++ b/examples/resources/github_enterprise_organization_app_installation/resource.tf @@ -1,11 +1,11 @@ -resource "github_enterprise_app_installation" "all_repos" { +resource "github_enterprise_organization_app_installation" "all_repos" { enterprise_slug = "my-enterprise" organization = "my-org" client_id = "Iv1.abc123def456" repository_selection = "all" } -resource "github_enterprise_app_installation" "selected_repos" { +resource "github_enterprise_organization_app_installation" "selected_repos" { enterprise_slug = "my-enterprise" organization = "my-org" client_id = "Iv1.789ghi012jkl" diff --git a/github/data_source_github_enterprise_app_accessible_organization_repositories.go b/github/data_source_github_enterprise_organization_app_accessible_repositories.go similarity index 92% rename from github/data_source_github_enterprise_app_accessible_organization_repositories.go rename to github/data_source_github_enterprise_organization_app_accessible_repositories.go index df22007ef7..abc653bd9a 100644 --- a/github/data_source_github_enterprise_app_accessible_organization_repositories.go +++ b/github/data_source_github_enterprise_organization_app_accessible_repositories.go @@ -8,9 +8,9 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) -func dataSourceGithubEnterpriseAppAccessibleOrganizationRepositories() *schema.Resource { +func dataSourceGithubEnterpriseOrganizationAppAccessibleRepositories() *schema.Resource { return &schema.Resource{ - ReadContext: dataSourceGithubEnterpriseAppAccessibleOrganizationRepositoriesRead, + ReadContext: dataSourceGithubEnterpriseOrganizationAppAccessibleRepositoriesRead, Description: "Use this data source to retrieve the repositories of an enterprise-owned organization that GitHub Apps can be granted access to. " + "This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner.", @@ -53,7 +53,7 @@ func dataSourceGithubEnterpriseAppAccessibleOrganizationRepositories() *schema.R } } -func dataSourceGithubEnterpriseAppAccessibleOrganizationRepositoriesRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { +func dataSourceGithubEnterpriseOrganizationAppAccessibleRepositoriesRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { meta, _ := m.(*Owner) client := meta.v3client diff --git a/github/data_source_github_enterprise_app_accessible_organization_repositories_test.go b/github/data_source_github_enterprise_organization_app_accessible_repositories_test.go similarity index 83% rename from github/data_source_github_enterprise_app_accessible_organization_repositories_test.go rename to github/data_source_github_enterprise_organization_app_accessible_repositories_test.go index 24dcf84f4b..321e6cc819 100644 --- a/github/data_source_github_enterprise_app_accessible_organization_repositories_test.go +++ b/github/data_source_github_enterprise_organization_app_accessible_repositories_test.go @@ -11,7 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" ) -func TestAccDataSourceGithubEnterpriseAppAccessibleOrganizationRepositories(t *testing.T) { +func TestAccDataSourceGithubEnterpriseOrganizationAppAccessibleRepositories(t *testing.T) { t.Parallel() skipUnlessEnterprise(t) @@ -28,7 +28,7 @@ resource "github_repository" "test" { auto_init = true } -data "github_enterprise_app_accessible_organization_repositories" "test" { +data "github_enterprise_organization_app_accessible_repositories" "test" { enterprise_slug = "%s" organization = "%s" @@ -42,7 +42,7 @@ data "github_enterprise_app_accessible_organization_repositories" "test" { { Config: config, ConfigStateChecks: []statecheck.StateCheck{ - statecheck.ExpectKnownValue("data.github_enterprise_app_accessible_organization_repositories.test", tfjsonpath.New("repositories"), knownvalue.SetPartial([]knownvalue.Check{ + statecheck.ExpectKnownValue("data.github_enterprise_organization_app_accessible_repositories.test", tfjsonpath.New("repositories"), knownvalue.SetPartial([]knownvalue.Check{ knownvalue.MapPartial(map[string]knownvalue.Check{ "name": knownvalue.StringExact(repoName), }), diff --git a/github/data_source_github_enterprise_app_installations.go b/github/data_source_github_enterprise_organization_app_installations.go similarity index 93% rename from github/data_source_github_enterprise_app_installations.go rename to github/data_source_github_enterprise_organization_app_installations.go index eecd5b4d66..bfc5ff95fd 100644 --- a/github/data_source_github_enterprise_app_installations.go +++ b/github/data_source_github_enterprise_organization_app_installations.go @@ -8,9 +8,9 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) -func dataSourceGithubEnterpriseAppInstallations() *schema.Resource { +func dataSourceGithubEnterpriseOrganizationAppInstallations() *schema.Resource { return &schema.Resource{ - ReadContext: dataSourceGithubEnterpriseAppInstallationsRead, + ReadContext: dataSourceGithubEnterpriseOrganizationAppInstallationsRead, Description: "Use this data source to retrieve the GitHub App installations on an enterprise-owned organization. " + "This data source requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner.", @@ -106,7 +106,7 @@ func dataSourceGithubEnterpriseAppInstallations() *schema.Resource { } } -func dataSourceGithubEnterpriseAppInstallationsRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { +func dataSourceGithubEnterpriseOrganizationAppInstallationsRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { meta, _ := m.(*Owner) client := meta.v3client diff --git a/github/data_source_github_enterprise_app_installations_test.go b/github/data_source_github_enterprise_organization_app_installations_test.go similarity index 71% rename from github/data_source_github_enterprise_app_installations_test.go rename to github/data_source_github_enterprise_organization_app_installations_test.go index cb1042b67d..e7dfecfe5c 100644 --- a/github/data_source_github_enterprise_app_installations_test.go +++ b/github/data_source_github_enterprise_organization_app_installations_test.go @@ -10,7 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" ) -func TestAccDataSourceGithubEnterpriseAppInstallations(t *testing.T) { +func TestAccDataSourceGithubEnterpriseOrganizationAppInstallations(t *testing.T) { t.Parallel() skipUnlessEnterprise(t) @@ -19,7 +19,7 @@ func TestAccDataSourceGithubEnterpriseAppInstallations(t *testing.T) { t.Parallel() config := fmt.Sprintf(` -data "github_enterprise_app_installations" "test" { +data "github_enterprise_organization_app_installations" "test" { enterprise_slug = "%s" organization = "%s" } @@ -31,7 +31,7 @@ data "github_enterprise_app_installations" "test" { { Config: config, ConfigStateChecks: []statecheck.StateCheck{ - statecheck.ExpectKnownValue("data.github_enterprise_app_installations.test", tfjsonpath.New("installations"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("data.github_enterprise_organization_app_installations.test", tfjsonpath.New("installations"), knownvalue.NotNull()), }, }, }, diff --git a/github/provider.go b/github/provider.go index 1fe21e87cb..fb0f146855 100644 --- a/github/provider.go +++ b/github/provider.go @@ -240,7 +240,7 @@ func NewProvider(version, commit string) func() *schema.Provider { "github_user_gpg_key": resourceGithubUserGpgKey(), "github_user_invitation_accepter": resourceGithubUserInvitationAccepter(), "github_user_ssh_key": resourceGithubUserSshKey(), - "github_enterprise_app_installation": resourceGithubEnterpriseAppInstallation(), + "github_enterprise_organization_app_installation": resourceGithubEnterpriseOrganizationAppInstallation(), "github_enterprise_organization": resourceGithubEnterpriseOrganization(), "github_enterprise_actions_runner_group": resourceGithubActionsEnterpriseRunnerGroup(), "github_enterprise_ip_allow_list_entry": resourceGithubEnterpriseIpAllowListEntry(), @@ -329,9 +329,9 @@ func NewProvider(version, commit string) func() *schema.Provider { "github_user_external_identity": dataSourceGithubUserExternalIdentity(), "github_users": dataSourceGithubUsers(), "github_enterprise": dataSourceGithubEnterprise(), - "github_enterprise_app_accessible_organization_repositories": dataSourceGithubEnterpriseAppAccessibleOrganizationRepositories(), + "github_enterprise_organization_app_accessible_repositories": dataSourceGithubEnterpriseOrganizationAppAccessibleRepositories(), "github_enterprise_app_installable_organizations": dataSourceGithubEnterpriseAppInstallableOrganizations(), - "github_enterprise_app_installations": dataSourceGithubEnterpriseAppInstallations(), + "github_enterprise_organization_app_installations": dataSourceGithubEnterpriseOrganizationAppInstallations(), "github_repository_environment_deployment_policies": dataSourceGithubRepositoryEnvironmentDeploymentPolicies(), }, diff --git a/github/resource_github_enterprise_app_installation.go b/github/resource_github_enterprise_organization_app_installation.go similarity index 83% rename from github/resource_github_enterprise_app_installation.go rename to github/resource_github_enterprise_organization_app_installation.go index 7ebe0bd04d..eab722e6fd 100644 --- a/github/resource_github_enterprise_app_installation.go +++ b/github/resource_github_enterprise_organization_app_installation.go @@ -19,14 +19,15 @@ import ( // the enterprise organization-installations endpoints accept per request. const maxInstallationRepositoriesPerRequest = 50 -func resourceGithubEnterpriseAppInstallation() *schema.Resource { +func resourceGithubEnterpriseOrganizationAppInstallation() *schema.Resource { return &schema.Resource{ - Description: "Manage a GitHub App installation on an enterprise-owned organization. " + + Description: "Manage the installation of a GitHub App on an organization owned by an enterprise. " + + "The app is installed on the organization, not on the enterprise account itself; GitHub offers no API for the latter. " + "This resource requires GitHub Enterprise Cloud or GitHub Enterprise Server 3.19+ and an authenticated user that is an enterprise owner.", - CreateContext: resourceGithubEnterpriseAppInstallationCreate, - ReadContext: resourceGithubEnterpriseAppInstallationRead, - UpdateContext: resourceGithubEnterpriseAppInstallationUpdate, - DeleteContext: resourceGithubEnterpriseAppInstallationDelete, + CreateContext: resourceGithubEnterpriseOrganizationAppInstallationCreate, + ReadContext: resourceGithubEnterpriseOrganizationAppInstallationRead, + UpdateContext: resourceGithubEnterpriseOrganizationAppInstallationUpdate, + DeleteContext: resourceGithubEnterpriseOrganizationAppInstallationDelete, Importer: &schema.ResourceImporter{ StateContext: schema.ImportStatePassthroughContext, }, @@ -103,7 +104,7 @@ func resourceGithubEnterpriseAppInstallation() *schema.Resource { } } -func resourceGithubEnterpriseAppInstallationCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { +func resourceGithubEnterpriseOrganizationAppInstallationCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client enterpriseSlug := d.Get("enterprise_slug").(string) @@ -153,14 +154,14 @@ func resourceGithubEnterpriseAppInstallationCreate(ctx context.Context, d *schem return diag.FromErr(err) } - if err := addEnterpriseAppInstallationRepositories(ctx, client, enterpriseSlug, org, installation.GetID(), remainder); err != nil { + if err := addEnterpriseOrganizationAppInstallationRepositories(ctx, client, enterpriseSlug, org, installation.GetID(), remainder); err != nil { return diag.FromErr(err) } return nil } -func resourceGithubEnterpriseAppInstallationRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { +func resourceGithubEnterpriseOrganizationAppInstallationRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { owner := meta.(*Owner) client := owner.v3client @@ -169,7 +170,7 @@ func resourceGithubEnterpriseAppInstallationRead(ctx context.Context, d *schema. return diag.FromErr(err) } - installation, err := findEnterpriseAppInstallation(ctx, owner, enterpriseSlug, org, clientID) + installation, err := findEnterpriseOrganizationAppInstallation(ctx, owner, enterpriseSlug, org, clientID) if err != nil { var ghErr *github.ErrorResponse if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { @@ -232,7 +233,7 @@ func resourceGithubEnterpriseAppInstallationRead(ctx context.Context, d *schema. return nil } -func resourceGithubEnterpriseAppInstallationUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { +func resourceGithubEnterpriseOrganizationAppInstallationUpdate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client enterpriseSlug, org, _, err := parseID3(d.Id()) @@ -274,7 +275,7 @@ func resourceGithubEnterpriseAppInstallationUpdate(ctx context.Context, d *schem return diag.FromErr(err) } - if err := addEnterpriseAppInstallationRepositories(ctx, client, enterpriseSlug, org, installationID, remainder); err != nil { + if err := addEnterpriseOrganizationAppInstallationRepositories(ctx, client, enterpriseSlug, org, installationID, remainder); err != nil { return diag.FromErr(err) } } else if d.HasChange("selected_repositories") { @@ -284,7 +285,7 @@ func resourceGithubEnterpriseAppInstallationUpdate(ctx context.Context, d *schem // Add before removing so the installation never has an empty selection. toAdd := expandStringList(newSet.Difference(oldSet).List()) - if err := addEnterpriseAppInstallationRepositories(ctx, client, enterpriseSlug, org, installationID, toAdd); err != nil { + if err := addEnterpriseOrganizationAppInstallationRepositories(ctx, client, enterpriseSlug, org, installationID, toAdd); err != nil { return diag.FromErr(err) } @@ -306,7 +307,7 @@ func resourceGithubEnterpriseAppInstallationUpdate(ctx context.Context, d *schem return nil } -func resourceGithubEnterpriseAppInstallationDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { +func resourceGithubEnterpriseOrganizationAppInstallationDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { client := meta.(*Owner).v3client enterpriseSlug, org, clientID, err := parseID3(d.Id()) @@ -337,9 +338,9 @@ func resourceGithubEnterpriseAppInstallationDelete(ctx context.Context, d *schem return nil } -// addEnterpriseAppInstallationRepositories grants an installation access to -// the given repositories, in chunks the API accepts. -func addEnterpriseAppInstallationRepositories(ctx context.Context, client *github.Client, enterpriseSlug, org string, installationID int64, repositories []string) error { +// addEnterpriseOrganizationAppInstallationRepositories grants an installation +// access to the given repositories, in chunks the API accepts. +func addEnterpriseOrganizationAppInstallationRepositories(ctx context.Context, client *github.Client, enterpriseSlug, org string, installationID int64, repositories []string) error { for chunk := range slices.Chunk(repositories, maxInstallationRepositoriesPerRequest) { tflog.Debug(ctx, "Granting enterprise app installation access to repositories", map[string]any{ "installation_id": installationID, @@ -356,10 +357,10 @@ func addEnterpriseAppInstallationRepositories(ctx context.Context, client *githu return nil } -// findEnterpriseAppInstallation returns the installation of the app with the -// given client ID on an enterprise-owned organization, or nil if the app is -// not installed. -func findEnterpriseAppInstallation(ctx context.Context, owner *Owner, enterpriseSlug, org, clientID string) (*github.Installation, error) { +// findEnterpriseOrganizationAppInstallation returns the installation of the app +// with the given client ID on an enterprise-owned organization, or nil if the +// app is not installed. +func findEnterpriseOrganizationAppInstallation(ctx context.Context, owner *Owner, enterpriseSlug, org, clientID string) (*github.Installation, error) { opts := &github.ListOptions{PerPage: owner.maxPerPage} for { installations, resp, err := owner.v3client.Enterprise.ListAppInstallations(ctx, enterpriseSlug, org, opts) diff --git a/github/resource_github_enterprise_app_installation_test.go b/github/resource_github_enterprise_organization_app_installation_test.go similarity index 57% rename from github/resource_github_enterprise_app_installation_test.go rename to github/resource_github_enterprise_organization_app_installation_test.go index 47c96f8310..d2270db3a3 100644 --- a/github/resource_github_enterprise_app_installation_test.go +++ b/github/resource_github_enterprise_organization_app_installation_test.go @@ -12,7 +12,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" ) -func TestAccGithubEnterpriseAppInstallation(t *testing.T) { +func TestAccGithubEnterpriseOrganizationAppInstallation(t *testing.T) { appClientID := os.Getenv("GH_TEST_ENTERPRISE_APP_CLIENT_ID") skipUnlessEnterpriseAppClientID := func(t *testing.T) { t.Helper() @@ -23,7 +23,7 @@ func TestAccGithubEnterpriseAppInstallation(t *testing.T) { t.Run("installs an app on all repositories", func(t *testing.T) { config := fmt.Sprintf(` - resource "github_enterprise_app_installation" "test" { + resource "github_enterprise_organization_app_installation" "test" { enterprise_slug = "%s" organization = "%s" client_id = "%s" @@ -41,16 +41,16 @@ func TestAccGithubEnterpriseAppInstallation(t *testing.T) { { Config: config, ConfigStateChecks: []statecheck.StateCheck{ - statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("enterprise_slug"), knownvalue.StringExact(testAccConf.enterpriseSlug)), - statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("organization"), knownvalue.StringExact(testAccConf.owner)), - statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("client_id"), knownvalue.StringExact(appClientID)), - statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("repository_selection"), knownvalue.StringExact("all")), - statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("installation_id"), knownvalue.NotNull()), - statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("app_slug"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("enterprise_slug"), knownvalue.StringExact(testAccConf.enterpriseSlug)), + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("organization"), knownvalue.StringExact(testAccConf.owner)), + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("client_id"), knownvalue.StringExact(appClientID)), + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("repository_selection"), knownvalue.StringExact("all")), + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("installation_id"), knownvalue.NotNull()), + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("app_slug"), knownvalue.NotNull()), }, }, { - ResourceName: "github_enterprise_app_installation.test", + ResourceName: "github_enterprise_organization_app_installation.test", ImportState: true, ImportStateVerify: true, }, @@ -67,7 +67,7 @@ func TestAccGithubEnterpriseAppInstallation(t *testing.T) { auto_init = true } - resource "github_enterprise_app_installation" "test" { + resource "github_enterprise_organization_app_installation" "test" { enterprise_slug = "%[1]s" organization = "%[2]s" client_id = "%[3]s" @@ -82,7 +82,7 @@ func TestAccGithubEnterpriseAppInstallation(t *testing.T) { auto_init = true } - resource "github_enterprise_app_installation" "test" { + resource "github_enterprise_organization_app_installation" "test" { enterprise_slug = "%[1]s" organization = "%[2]s" client_id = "%[3]s" @@ -100,15 +100,15 @@ func TestAccGithubEnterpriseAppInstallation(t *testing.T) { { Config: configSelected, ConfigStateChecks: []statecheck.StateCheck{ - statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("repository_selection"), knownvalue.StringExact("selected")), - statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("selected_repositories"), knownvalue.SetSizeExact(1)), + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("repository_selection"), knownvalue.StringExact("selected")), + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("selected_repositories"), knownvalue.SetSizeExact(1)), }, }, { Config: configAll, ConfigStateChecks: []statecheck.StateCheck{ - statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("repository_selection"), knownvalue.StringExact("all")), - statecheck.ExpectKnownValue("github_enterprise_app_installation.test", tfjsonpath.New("selected_repositories"), knownvalue.SetSizeExact(0)), + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("repository_selection"), knownvalue.StringExact("all")), + statecheck.ExpectKnownValue("github_enterprise_organization_app_installation.test", tfjsonpath.New("selected_repositories"), knownvalue.SetSizeExact(0)), }, }, }, From 743f769a472c787227305cd854f87c8b790f204b Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Wed, 19 Aug 2026 08:23:18 +0100 Subject: [PATCH 09/12] fix: Force replacement when the new repository selection is unknown An unknown 'repository_selection' could resolve to 'none' during apply, turning the planned in-place update into a replacement and aborting the run with an inconsistent-final-plan error. Co-Authored-By: Claude Fable 5 --- ...github_enterprise_organization_app_installation.go | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/github/resource_github_enterprise_organization_app_installation.go b/github/resource_github_enterprise_organization_app_installation.go index eab722e6fd..fe10c301e9 100644 --- a/github/resource_github_enterprise_organization_app_installation.go +++ b/github/resource_github_enterprise_organization_app_installation.go @@ -79,8 +79,15 @@ func resourceGithubEnterpriseOrganizationAppInstallation() *schema.Resource { CustomizeDiff: customdiff.All( // The API can only toggle an existing installation between 'all' and - // 'selected'; transitions involving 'none' require a reinstall. - customdiff.ForceNewIfChange("repository_selection", func(ctx context.Context, oldValue, newValue, meta any) bool { + // 'selected'; transitions involving 'none' require a reinstall. An + // unknown new value could resolve to 'none' during apply, so it must + // also force replacement, or the expanded plan would disagree with + // the initial one and Terraform would abort the apply. + customdiff.ForceNewIf("repository_selection", func(ctx context.Context, d *schema.ResourceDiff, meta any) bool { + if !d.NewValueKnown("repository_selection") { + return true + } + oldValue, newValue := d.GetChange("repository_selection") return oldValue.(string) == "none" || newValue.(string) == "none" }), func(ctx context.Context, d *schema.ResourceDiff, meta any) error { From 3d78f5d268e0bd94b016d11768e14819d0c4252c Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Wed, 19 Aug 2026 08:23:35 +0100 Subject: [PATCH 10/12] refactor: Paginate with the go-github iterator methods Replaces the five hand-rolled NextPage loops with the generated *Iter methods so pagination and error handling stay in the client, matching the provider's convention. Co-Authored-By: Claude Fable 5 --- ...nterprise_app_installable_organizations.go | 20 +++++----------- ...rganization_app_accessible_repositories.go | 20 +++++----------- ...terprise_organization_app_installations.go | 13 ++++------ ...nterprise_organization_app_installation.go | 24 ++++--------------- 4 files changed, 21 insertions(+), 56 deletions(-) diff --git a/github/data_source_github_enterprise_app_installable_organizations.go b/github/data_source_github_enterprise_app_installable_organizations.go index b260286080..7e9a6a46fb 100644 --- a/github/data_source_github_enterprise_app_installable_organizations.go +++ b/github/data_source_github_enterprise_app_installable_organizations.go @@ -59,24 +59,16 @@ func dataSourceGithubEnterpriseAppInstallableOrganizationsRead(ctx context.Conte } results := make([]map[string]any, 0) - for { - organizations, resp, err := client.Enterprise.ListAppInstallableOrganizations(ctx, enterpriseSlug, opts) + for organization, err := range client.Enterprise.ListAppInstallableOrganizationsIter(ctx, enterpriseSlug, opts) { if err != nil { return diag.FromErr(err) } - for _, organization := range organizations { - results = append(results, map[string]any{ - "id": organization.ID, - "login": organization.Login, - "accessible_repositories_url": organization.GetAccessibleRepositoriesURL(), - }) - } - if resp.NextPage == 0 { - break - } - - opts.Page = resp.NextPage + results = append(results, map[string]any{ + "id": organization.ID, + "login": organization.Login, + "accessible_repositories_url": organization.GetAccessibleRepositoriesURL(), + }) } d.SetId(enterpriseSlug) diff --git a/github/data_source_github_enterprise_organization_app_accessible_repositories.go b/github/data_source_github_enterprise_organization_app_accessible_repositories.go index abc653bd9a..7faf10e452 100644 --- a/github/data_source_github_enterprise_organization_app_accessible_repositories.go +++ b/github/data_source_github_enterprise_organization_app_accessible_repositories.go @@ -65,24 +65,16 @@ func dataSourceGithubEnterpriseOrganizationAppAccessibleRepositoriesRead(ctx con } results := make([]map[string]any, 0) - for { - repositories, resp, err := client.Enterprise.ListAppAccessibleOrganizationRepositories(ctx, enterpriseSlug, org, opts) + for repository, err := range client.Enterprise.ListAppAccessibleOrganizationRepositoriesIter(ctx, enterpriseSlug, org, opts) { if err != nil { return diag.FromErr(err) } - for _, repository := range repositories { - results = append(results, map[string]any{ - "id": repository.ID, - "name": repository.Name, - "full_name": repository.FullName, - }) - } - if resp.NextPage == 0 { - break - } - - opts.Page = resp.NextPage + results = append(results, map[string]any{ + "id": repository.ID, + "name": repository.Name, + "full_name": repository.FullName, + }) } d.SetId(buildTwoPartID(enterpriseSlug, org)) diff --git a/github/data_source_github_enterprise_organization_app_installations.go b/github/data_source_github_enterprise_organization_app_installations.go index bfc5ff95fd..8cb8baeec6 100644 --- a/github/data_source_github_enterprise_organization_app_installations.go +++ b/github/data_source_github_enterprise_organization_app_installations.go @@ -117,20 +117,15 @@ func dataSourceGithubEnterpriseOrganizationAppInstallationsRead(ctx context.Cont PerPage: meta.maxPerPage, } - results := make([]map[string]any, 0) - for { - installations, resp, err := client.Enterprise.ListAppInstallations(ctx, enterpriseSlug, org, opts) + installations := make([]*github.Installation, 0) + for installation, err := range client.Enterprise.ListAppInstallationsIter(ctx, enterpriseSlug, org, opts) { if err != nil { return diag.FromErr(err) } - results = append(results, flattenGitHubAppInstallations(installations)...) - if resp.NextPage == 0 { - break - } - - opts.Page = resp.NextPage + installations = append(installations, installation) } + results := flattenGitHubAppInstallations(installations) d.SetId(buildTwoPartID(enterpriseSlug, org)) if err := d.Set("installations", results); err != nil { diff --git a/github/resource_github_enterprise_organization_app_installation.go b/github/resource_github_enterprise_organization_app_installation.go index fe10c301e9..98230d1f61 100644 --- a/github/resource_github_enterprise_organization_app_installation.go +++ b/github/resource_github_enterprise_organization_app_installation.go @@ -219,18 +219,11 @@ func resourceGithubEnterpriseOrganizationAppInstallationRead(ctx context.Context selectedRepositories := []string{} if installation.GetRepositorySelection() == "selected" { opts := &github.ListOptions{PerPage: owner.maxPerPage} - for { - repos, resp, err := client.Enterprise.ListRepositoriesForOrgAppInstallation(ctx, enterpriseSlug, org, installation.GetID(), opts) + for repo, err := range client.Enterprise.ListRepositoriesForOrgAppInstallationIter(ctx, enterpriseSlug, org, installation.GetID(), opts) { if err != nil { return diag.FromErr(err) } - for _, repo := range repos { - selectedRepositories = append(selectedRepositories, repo.GetName()) - } - if resp.NextPage == 0 { - break - } - opts.Page = resp.NextPage + selectedRepositories = append(selectedRepositories, repo.GetName()) } } if err := d.Set("selected_repositories", flattenStringList(selectedRepositories)); err != nil { @@ -369,20 +362,13 @@ func addEnterpriseOrganizationAppInstallationRepositories(ctx context.Context, c // app is not installed. func findEnterpriseOrganizationAppInstallation(ctx context.Context, owner *Owner, enterpriseSlug, org, clientID string) (*github.Installation, error) { opts := &github.ListOptions{PerPage: owner.maxPerPage} - for { - installations, resp, err := owner.v3client.Enterprise.ListAppInstallations(ctx, enterpriseSlug, org, opts) + for installation, err := range owner.v3client.Enterprise.ListAppInstallationsIter(ctx, enterpriseSlug, org, opts) { if err != nil { return nil, err } - for _, installation := range installations { - if installation.GetClientID() == clientID { - return installation, nil - } - } - if resp.NextPage == 0 { - break + if installation.GetClientID() == clientID { + return installation, nil } - opts.Page = resp.NextPage } return nil, nil From 904c1e02fe50855bb6c912d073fcfb76a8607b95 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Wed, 19 Aug 2026 08:23:36 +0100 Subject: [PATCH 11/12] ci: Provide the enterprise app client ID to acceptance tests GH_TEST_ENTERPRISE_APP_CLIENT_ID was only read by the tests, never exported by the workflow, so the enterprise app installation lifecycle tests skipped on every CI run. Co-Authored-By: Claude Fable 5 --- .github/workflows/acceptance-tests.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/acceptance-tests.yaml b/.github/workflows/acceptance-tests.yaml index 118887cc19..8e252ec0ae 100644 --- a/.github/workflows/acceptance-tests.yaml +++ b/.github/workflows/acceptance-tests.yaml @@ -216,6 +216,7 @@ jobs: GITHUB_BASE_URL: https://api.github.com/ GITHUB_OWNER: ${{ case(matrix.mode == 'anonymous', '', matrix.mode == 'individual', vars.GH_TEST_LOGIN, vars.GH_TEST_ORG_NAME) }} GITHUB_ENTERPRISE_SLUG: ${{ case(matrix.mode == 'enterprise', vars.GH_TEST_ENTERPRISE_SLUG, '') }} + GH_TEST_ENTERPRISE_APP_CLIENT_ID: ${{ vars.GH_TEST_ENTERPRISE_APP_CLIENT_ID }} GITHUB_LEGACY_CLIENT: "false" GH_TEST_AUTH_MODE: ${{ matrix.mode }} GH_TEST_ORG_USER1: ${{ vars.GH_TEST_ORG_USER1 }} From 6d7f0c9288ea52673895a9dc2636c0198eacd7e2 Mon Sep 17 00:00:00 2001 From: Stuart Lang Date: Wed, 19 Aug 2026 08:27:54 +0100 Subject: [PATCH 12/12] Fix indentation in acceptance-tests.yaml --- .github/workflows/acceptance-tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/acceptance-tests.yaml b/.github/workflows/acceptance-tests.yaml index 8e252ec0ae..c1217ce056 100644 --- a/.github/workflows/acceptance-tests.yaml +++ b/.github/workflows/acceptance-tests.yaml @@ -216,7 +216,7 @@ jobs: GITHUB_BASE_URL: https://api.github.com/ GITHUB_OWNER: ${{ case(matrix.mode == 'anonymous', '', matrix.mode == 'individual', vars.GH_TEST_LOGIN, vars.GH_TEST_ORG_NAME) }} GITHUB_ENTERPRISE_SLUG: ${{ case(matrix.mode == 'enterprise', vars.GH_TEST_ENTERPRISE_SLUG, '') }} - GH_TEST_ENTERPRISE_APP_CLIENT_ID: ${{ vars.GH_TEST_ENTERPRISE_APP_CLIENT_ID }} + GH_TEST_ENTERPRISE_APP_CLIENT_ID: ${{ vars.GH_TEST_ENTERPRISE_APP_CLIENT_ID }} GITHUB_LEGACY_CLIENT: "false" GH_TEST_AUTH_MODE: ${{ matrix.mode }} GH_TEST_ORG_USER1: ${{ vars.GH_TEST_ORG_USER1 }}