diff --git a/github/enterprise_app_installation.go b/github/enterprise_app_installation.go new file mode 100644 index 00000000000..aed3ac753fd --- /dev/null +++ b/github/enterprise_app_installation.go @@ -0,0 +1,159 @@ +// Copyright 2025 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package github + +import ( + "context" + "fmt" +) + +// InstallableOrganization represents an organization in an enterprise in which a GitHub app can be installed. +type InstallableOrganization struct { + ID int64 `json:"id"` + Login string `json:"login"` + AccessibleRepositoriesURL *string `json:"accessible_repositories_url,omitempty"` +} + +// AccessibleRepository represents a repository that can be made accessible to a GitHub app. +type AccessibleRepository struct { + ID int64 `json:"id"` + Name string `json:"name"` + FullName string `json:"full_name"` +} + +// InstallAppRequest represents the request to install a GitHub app on an enterprise-owned organization. +type InstallAppRequest struct { + // The Client ID of the GitHub App to install. + ClientID string `json:"client_id"` + // The selection of repositories that the GitHub app can access. + // Can be one of: all, selected, none + RepositorySelection string `json:"repository_selection"` + // A list of repository names that the GitHub App can access, if the repository_selection is set to selected. + Repositories []string `json:"repositories,omitempty"` +} + +// ListAppInstallableOrganizations lists the organizations in an enterprise that are installable for an app. +// +// GitHub API docs: https://docs.github.com/enterprise-cloud@latest/rest/enterprise-admin/organization-installations#get-enterprise-owned-organizations-that-can-have-github-apps-installed +// +//meta:operation GET /enterprises/{enterprise}/apps/installable_organizations +func (s *EnterpriseService) ListAppInstallableOrganizations(ctx context.Context, enterprise string, opts *ListOptions) ([]*InstallableOrganization, *Response, error) { + u := fmt.Sprintf("enterprises/%v/apps/installable_organizations", enterprise) + + u, err := addOptions(u, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + var orgs []*InstallableOrganization + resp, err := s.client.Do(ctx, req, &orgs) + if err != nil { + return nil, resp, err + } + + return orgs, resp, nil +} + +// ListAppAccessibleOrganizationRepositories lists the repositories accessible to an app in an enterprise-owned organization. +// +// GitHub API docs: https://docs.github.com/enterprise-cloud@latest/rest/enterprise-admin/organization-installations#get-repositories-belonging-to-an-enterprise-owned-organization +// +//meta:operation GET /enterprises/{enterprise}/apps/installable_organizations/{org}/accessible_repositories +func (s *EnterpriseService) ListAppAccessibleOrganizationRepositories(ctx context.Context, enterprise, org string, opts *ListOptions) ([]*AccessibleRepository, *Response, error) { + u := fmt.Sprintf("enterprises/%v/apps/installable_organizations/%v/accessible_repositories", enterprise, org) + + u, err := addOptions(u, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + var repos []*AccessibleRepository + resp, err := s.client.Do(ctx, req, &repos) + if err != nil { + return nil, resp, err + } + + return repos, resp, nil +} + +// ListAppInstallations lists the GitHub app installations associated with the given enterprise-owned organization. +// +// GitHub API docs: https://docs.github.com/enterprise-cloud@latest/rest/enterprise-admin/organization-installations#list-github-apps-installed-on-an-enterprise-owned-organization +// +//meta:operation GET /enterprises/{enterprise}/apps/organizations/{org}/installations +func (s *EnterpriseService) ListAppInstallations(ctx context.Context, enterprise, org string, opts *ListOptions) ([]*Installation, *Response, error) { + u := fmt.Sprintf("enterprises/%v/apps/organizations/%v/installations", enterprise, org) + + u, err := addOptions(u, opts) + if err != nil { + return nil, nil, err + } + + req, err := s.client.NewRequest("GET", u, nil) + if err != nil { + return nil, nil, err + } + + var installation []*Installation + resp, err := s.client.Do(ctx, req, &installation) + if err != nil { + return nil, resp, err + } + + return installation, resp, nil +} + +// InstallApp installs any valid GitHub app on the specified organization owned by the enterprise. +// If the app is already installed on the organization, and is suspended, it will be unsuspended. If the app has a pending installation request, they will all be approved. +// +// GitHub API docs: https://docs.github.com/enterprise-cloud@latest/rest/enterprise-admin/organization-installations#install-a-github-app-on-an-enterprise-owned-organization +// +//meta:operation POST /enterprises/{enterprise}/apps/organizations/{org}/installations +func (s *EnterpriseService) InstallApp(ctx context.Context, enterprise, org string, request InstallAppRequest) (*Installation, *Response, error) { + u := fmt.Sprintf("enterprises/%v/apps/organizations/%v/installations", enterprise, org) + req, err := s.client.NewRequest("POST", u, request) + if err != nil { + return nil, nil, err + } + + var installation *Installation + resp, err := s.client.Do(ctx, req, &installation) + if err != nil { + return nil, resp, err + } + + return installation, resp, nil +} + +// UninstallApp uninstalls a GitHub app from an organization. Any app installed on the organization can be removed. +// +// GitHub API docs: https://docs.github.com/enterprise-cloud@latest/rest/enterprise-admin/organization-installations#uninstall-a-github-app-from-an-enterprise-owned-organization +// +//meta:operation DELETE /enterprises/{enterprise}/apps/organizations/{org}/installations/{installation_id} +func (s *EnterpriseService) UninstallApp(ctx context.Context, enterprise, org string, installationID int64) (*Response, error) { + u := fmt.Sprintf("enterprises/%v/apps/organizations/%v/installations/%v", enterprise, org, installationID) + req, err := s.client.NewRequest("DELETE", u, nil) + if err != nil { + return nil, err + } + + resp, err := s.client.Do(ctx, req, nil) + if err != nil { + return resp, err + } + + return resp, nil +} diff --git a/github/enterprise_app_installation_test.go b/github/enterprise_app_installation_test.go new file mode 100644 index 00000000000..766982a28d4 --- /dev/null +++ b/github/enterprise_app_installation_test.go @@ -0,0 +1,197 @@ +// Copyright 2025 The go-github AUTHORS. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package github + +import ( + "fmt" + "net/http" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestEnterpriseService_ListAppInstallableOrganizations(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/enterprises/e/apps/installable_organizations", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, `[{"id":1, "login":"org1"}]`) + }) + + ctx := t.Context() + opts := &ListOptions{Page: 1, PerPage: 10} + got, _, err := client.Enterprise.ListAppInstallableOrganizations(ctx, "e", opts) + if err != nil { + t.Fatalf("Enterprise.ListAppInstallableOrganizations returned error: %v", err) + } + + want := []*InstallableOrganization{ + {ID: int64(1), Login: "org1"}, + } + + if !cmp.Equal(got, want) { + t.Errorf("Enterprise.ListAppInstallableOrganizations = %+v, want %+v", got, want) + } + + const methodName = "ListAppInstallableOrganizations" + testBadOptions(t, methodName, func() error { + _, _, err := client.Enterprise.ListAppInstallableOrganizations(ctx, "\n", opts) + return err + }) + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Enterprise.ListAppInstallableOrganizations(ctx, "e", nil) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + +func TestEnterpriseService_ListAppAccessibleOrganizationRepositories(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/enterprises/e/apps/installable_organizations/org1/accessible_repositories", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + fmt.Fprint(w, `[{"id":10, "name":"repo1", "full_name":"org1/repo1"}]`) + }) + + opts := &ListOptions{Page: 2, PerPage: 2} + ctx := t.Context() + repos, _, err := client.Enterprise.ListAppAccessibleOrganizationRepositories(ctx, "e", "org1", opts) + if err != nil { + t.Errorf("Enterprise.ListAppAccessibleOrganizationRepositories returned error: %v", err) + } + + want := []*AccessibleRepository{ + {ID: int64(10), Name: "repo1", FullName: "org1/repo1"}, + } + + if !cmp.Equal(repos, want) { + t.Errorf("Enterprise.ListAppAccessibleOrganizationRepositories returned %+v, want %+v", repos, want) + } + + const methodName = "ListAppAccessibleOrganizationRepositories" + + testBadOptions(t, methodName, func() (err error) { + _, _, err = client.Enterprise.ListAppAccessibleOrganizationRepositories(ctx, "\n", "org1", opts) + return err + }) + + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Enterprise.ListAppAccessibleOrganizationRepositories(ctx, "e", "org1", nil) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + +func TestEnterpriseService_ListAppInstallations(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/enterprises/e/apps/organizations/org1/installations", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testFormValues(t, r, values{"per_page": "2", "page": "2"}) + fmt.Fprint(w, `[{"id":99}]`) + }) + + opts := &ListOptions{Page: 2, PerPage: 2} + ctx := t.Context() + installations, _, err := client.Enterprise.ListAppInstallations(ctx, "e", "org1", opts) + if err != nil { + t.Errorf("ListAppInstallations returned error: %v", err) + } + want := []*Installation{ + {ID: Ptr(int64(99))}, + } + + if !cmp.Equal(installations, want) { + t.Errorf("ListAppInstallations returned %+v, want %+v", installations, want) + } + + const methodName = "ListAppInstallations" + + testBadOptions(t, methodName, func() (err error) { + _, _, err = client.Enterprise.ListAppInstallations(ctx, "\n", "org1", &ListOptions{}) + return err + }) + + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Enterprise.ListAppInstallations(ctx, "e", "org1", nil) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + +func TestEnterpriseService_InstallApp(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/enterprises/e/apps/organizations/org1/installations", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "POST") + testBody(t, r, `{"client_id":"cid","repository_selection":"selected","repositories":["r1","r2"]}`+"\n") + fmt.Fprint(w, `{"id":555}`) + }) + + req := InstallAppRequest{ + ClientID: "cid", + RepositorySelection: "selected", + Repositories: []string{"r1", "r2"}, + } + + ctx := t.Context() + installation, _, err := client.Enterprise.InstallApp(ctx, "e", "org1", req) + if err != nil { + t.Errorf("InstallApp returned error: %v", err) + } + + want := &Installation{ID: Ptr(int64(555))} + + if !cmp.Equal(installation, want) { + t.Errorf("InstallApp returned %+v, want %+v", installation, want) + } + + const methodName = "InstallApp" + + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + got, resp, err := client.Enterprise.InstallApp(ctx, "e", "org1", req) + if got != nil { + t.Errorf("testNewRequestAndDoFailure %v = %#v, want nil", methodName, got) + } + return resp, err + }) +} + +func TestEnterpriseService_UninstallApp(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/enterprises/e/apps/organizations/org1/installations/123", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "DELETE") + w.WriteHeader(http.StatusNoContent) + }) + + ctx := t.Context() + resp, err := client.Enterprise.UninstallApp(ctx, "e", "org1", 123) + if err != nil { + t.Errorf("UninstallApp returned error: %v", err) + } + + if resp.StatusCode != http.StatusNoContent { + t.Errorf("UninstallApp returned status %v, want %v", resp.StatusCode, http.StatusNoContent) + } + + const methodName = "UninstallApp" + + testNewRequestAndDoFailure(t, methodName, client, func() (*Response, error) { + return client.Enterprise.UninstallApp(ctx, "e", "org1", 123) + }) +} diff --git a/github/github-accessors.go b/github/github-accessors.go index 2a1a9394f67..d9ed060d5ce 100644 --- a/github/github-accessors.go +++ b/github/github-accessors.go @@ -11286,6 +11286,14 @@ func (i *Import) GetVCSUsername() string { return *i.VCSUsername } +// GetAccessibleRepositoriesURL returns the AccessibleRepositoriesURL field if it's non-nil, zero value otherwise. +func (i *InstallableOrganization) GetAccessibleRepositoriesURL() string { + if i == nil || i.AccessibleRepositoriesURL == nil { + return "" + } + return *i.AccessibleRepositoriesURL +} + // GetAccessTokensURL returns the AccessTokensURL field if it's non-nil, zero value otherwise. func (i *Installation) GetAccessTokensURL() string { if i == nil || i.AccessTokensURL == nil { diff --git a/github/github-accessors_test.go b/github/github-accessors_test.go index c926697944f..d2ae9e7b911 100644 --- a/github/github-accessors_test.go +++ b/github/github-accessors_test.go @@ -14662,6 +14662,17 @@ func TestImport_GetVCSUsername(tt *testing.T) { i.GetVCSUsername() } +func TestInstallableOrganization_GetAccessibleRepositoriesURL(tt *testing.T) { + tt.Parallel() + var zeroValue string + i := &InstallableOrganization{AccessibleRepositoriesURL: &zeroValue} + i.GetAccessibleRepositoriesURL() + i = &InstallableOrganization{} + i.GetAccessibleRepositoriesURL() + i = nil + i.GetAccessibleRepositoriesURL() +} + func TestInstallation_GetAccessTokensURL(tt *testing.T) { tt.Parallel() var zeroValue string