From 77f949e518efa193f168e32890ffa8c5f3381558 Mon Sep 17 00:00:00 2001 From: Nitin Jain Date: Sat, 30 May 2026 22:40:49 +0200 Subject: [PATCH 1/4] feat: add github_copilot_organization_seat_assignment and github_copilot_team_seat_assignment resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1629 Adds two new resources for managing GitHub Copilot seat assignments when the organization has seat management set to "assign_selected": - github_copilot_organization_seat_assignment — assigns a Copilot seat to a specific org member via AddCopilotUsers / RemoveCopilotUsers - github_copilot_team_seat_assignment — assigns Copilot seats to all members of a team via AddCopilotTeams / RemoveCopilotTeams Read gracefully handles seats removed outside Terraform by removing the resource from state. Acceptance tests skip automatically when the org does not have seat management set to assign_selected. --- .../example_1.tf | 3 + .../copilot_team_seat_assignment/example_1.tf | 7 ++ github/acc_test.go | 16 +++ github/provider.go | 2 + ...ub_copilot_organization_seat_assignment.go | 93 +++++++++++++++ ...pilot_organization_seat_assignment_test.go | 42 +++++++ ...rce_github_copilot_team_seat_assignment.go | 110 ++++++++++++++++++ ...ithub_copilot_team_seat_assignment_test.go | 44 +++++++ ...pilot_organization_seat_assignment.md.tmpl | 29 +++++ .../copilot_team_seat_assignment.md.tmpl | 29 +++++ 10 files changed, 375 insertions(+) create mode 100644 examples/resources/copilot_organization_seat_assignment/example_1.tf create mode 100644 examples/resources/copilot_team_seat_assignment/example_1.tf create mode 100644 github/resource_github_copilot_organization_seat_assignment.go create mode 100644 github/resource_github_copilot_organization_seat_assignment_test.go create mode 100644 github/resource_github_copilot_team_seat_assignment.go create mode 100644 github/resource_github_copilot_team_seat_assignment_test.go create mode 100644 templates/resources/copilot_organization_seat_assignment.md.tmpl create mode 100644 templates/resources/copilot_team_seat_assignment.md.tmpl diff --git a/examples/resources/copilot_organization_seat_assignment/example_1.tf b/examples/resources/copilot_organization_seat_assignment/example_1.tf new file mode 100644 index 0000000000..96699b172c --- /dev/null +++ b/examples/resources/copilot_organization_seat_assignment/example_1.tf @@ -0,0 +1,3 @@ +resource "github_copilot_organization_seat_assignment" "example" { + username = "someuser" +} diff --git a/examples/resources/copilot_team_seat_assignment/example_1.tf b/examples/resources/copilot_team_seat_assignment/example_1.tf new file mode 100644 index 0000000000..5ba9b68bf4 --- /dev/null +++ b/examples/resources/copilot_team_seat_assignment/example_1.tf @@ -0,0 +1,7 @@ +resource "github_team" "example" { + name = "my-copilot-team" +} + +resource "github_copilot_team_seat_assignment" "example" { + team = github_team.example.slug +} diff --git a/github/acc_test.go b/github/acc_test.go index 97a45d3980..28c59adddc 100644 --- a/github/acc_test.go +++ b/github/acc_test.go @@ -345,3 +345,19 @@ func skipUnlessMode(t *testing.T, testModes ...testMode) { t.Skip("Skipping as not supported test mode") } } + +func skipUnlessCopilotSeatManagementEnabled(t *testing.T) { + t.Helper() + skipUnlessHasOrgs(t) + meta, err := getTestMeta() + if err != nil { + t.Fatalf("failed to get test meta: %s", err) + } + billing, _, err := meta.v3client.Copilot.GetCopilotBilling(context.Background(), meta.name) + if err != nil { + t.Skipf("Skipping: Copilot billing not available for org %s: %s", meta.name, err) + } + if billing.GetSeatManagementSetting() != "assign_selected" { + t.Skipf("Skipping: Copilot seat management is %q, must be %q", billing.GetSeatManagementSetting(), "assign_selected") + } +} diff --git a/github/provider.go b/github/provider.go index b70c58c9f0..def7b0c378 100644 --- a/github/provider.go +++ b/github/provider.go @@ -157,6 +157,8 @@ func NewProvider() func() *schema.Provider { "github_codespaces_organization_secret_repositories": resourceGithubCodespacesOrganizationSecretRepositories(), "github_codespaces_secret": resourceGithubCodespacesSecret(), "github_codespaces_user_secret": resourceGithubCodespacesUserSecret(), + "github_copilot_organization_seat_assignment": resourceGithubCopilotOrganizationSeatAssignment(), + "github_copilot_team_seat_assignment": resourceGithubCopilotTeamSeatAssignment(), "github_dependabot_organization_secret": resourceGithubDependabotOrganizationSecret(), "github_dependabot_organization_secret_repositories": resourceGithubDependabotOrganizationSecretRepositories(), "github_dependabot_organization_secret_repository": resourceGithubDependabotOrganizationSecretRepository(), diff --git a/github/resource_github_copilot_organization_seat_assignment.go b/github/resource_github_copilot_organization_seat_assignment.go new file mode 100644 index 0000000000..17e36c39ee --- /dev/null +++ b/github/resource_github_copilot_organization_seat_assignment.go @@ -0,0 +1,93 @@ +package github + +import ( + "context" + "errors" + "net/http" + + "github.com/google/go-github/v88/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubCopilotOrganizationSeatAssignment() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceGithubCopilotOrganizationSeatAssignmentCreate, + ReadContext: resourceGithubCopilotOrganizationSeatAssignmentRead, + DeleteContext: resourceGithubCopilotOrganizationSeatAssignmentDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "username": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + DiffSuppressFunc: caseInsensitive(), + Description: "The login of the user to assign a Copilot seat.", + }, + }, + } +} + +func resourceGithubCopilotOrganizationSeatAssignmentCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + org := meta.name + + username := d.Get("username").(string) + + _, _, err := client.Copilot.AddCopilotUsers(ctx, org, []string{username}) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(username) + + return resourceGithubCopilotOrganizationSeatAssignmentRead(ctx, d, m) +} + +func resourceGithubCopilotOrganizationSeatAssignmentRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + org := meta.name + + username := d.Id() + + _, _, err := client.Copilot.GetSeatDetails(ctx, org, username) + if err != nil { + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Copilot seat assignment no longer exists, removing from state", map[string]any{"username": username}) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + + if err := d.Set("username", username); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubCopilotOrganizationSeatAssignmentDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + org := meta.name + + username := d.Id() + + _, _, err := client.Copilot.RemoveCopilotUsers(ctx, org, []string{username}) + if err != nil { + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Copilot seat assignment no longer exists, skipping delete", map[string]any{"username": username}) + return nil + } + return diag.FromErr(err) + } + + return nil +} diff --git a/github/resource_github_copilot_organization_seat_assignment_test.go b/github/resource_github_copilot_organization_seat_assignment_test.go new file mode 100644 index 0000000000..b9f062a7f3 --- /dev/null +++ b/github/resource_github_copilot_organization_seat_assignment_test.go @@ -0,0 +1,42 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccGithubCopilotOrganizationSeatAssignment(t *testing.T) { + if testAccConf.testOrgUser == "" { + t.Skip("GH_TEST_ORG_USER not set") + } + + username := testAccConf.testOrgUser + + t.Run("assigns and removes a Copilot seat for a user", func(t *testing.T) { + config := fmt.Sprintf(` +resource "github_copilot_organization_seat_assignment" "test" { + username = "%s" +} +`, username) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessCopilotSeatManagementEnabled(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_copilot_organization_seat_assignment.test", "username", username), + ), + }, + { + ResourceName: "github_copilot_organization_seat_assignment.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) +} diff --git a/github/resource_github_copilot_team_seat_assignment.go b/github/resource_github_copilot_team_seat_assignment.go new file mode 100644 index 0000000000..44856b0a2d --- /dev/null +++ b/github/resource_github_copilot_team_seat_assignment.go @@ -0,0 +1,110 @@ +package github + +import ( + "context" + "errors" + "net/http" + + "github.com/google/go-github/v88/github" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubCopilotTeamSeatAssignment() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceGithubCopilotTeamSeatAssignmentCreate, + ReadContext: resourceGithubCopilotTeamSeatAssignmentRead, + DeleteContext: resourceGithubCopilotTeamSeatAssignmentDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "team": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + DiffSuppressFunc: caseInsensitive(), + Description: "The slug of the team to assign Copilot seats.", + }, + }, + } +} + +func resourceGithubCopilotTeamSeatAssignmentCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + org := meta.name + + team := d.Get("team").(string) + + _, _, err := client.Copilot.AddCopilotTeams(ctx, org, []string{team}) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(team) + + return resourceGithubCopilotTeamSeatAssignmentRead(ctx, d, m) +} + +func resourceGithubCopilotTeamSeatAssignmentRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + org := meta.name + + teamSlug := d.Id() + + // The Copilot API has no single-team seat lookup; scan all seats for a team assignee matching our slug. + opts := &github.ListOptions{PerPage: 100} + for { + resp_data, resp, err := client.Copilot.ListCopilotSeats(ctx, org, opts) + if err != nil { + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Copilot team seat assignment no longer exists, removing from state", map[string]any{"team": teamSlug}) + d.SetId("") + return nil + } + return diag.FromErr(err) + } + + for _, seat := range resp_data.Seats { + if t, ok := seat.GetTeam(); ok && t.GetSlug() == teamSlug { + if err := d.Set("team", teamSlug); err != nil { + return diag.FromErr(err) + } + return nil + } + } + + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + // Team not found in any seat — it was removed outside Terraform. + tflog.Info(ctx, "Copilot team seat assignment no longer exists, removing from state", map[string]any{"team": teamSlug}) + d.SetId("") + return nil +} + +func resourceGithubCopilotTeamSeatAssignmentDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + org := meta.name + + teamSlug := d.Id() + + _, _, err := client.Copilot.RemoveCopilotTeams(ctx, org, []string{teamSlug}) + if err != nil { + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Copilot team seat assignment no longer exists, skipping delete", map[string]any{"team": teamSlug}) + return nil + } + return diag.FromErr(err) + } + + return nil +} diff --git a/github/resource_github_copilot_team_seat_assignment_test.go b/github/resource_github_copilot_team_seat_assignment_test.go new file mode 100644 index 0000000000..88c6cdd36d --- /dev/null +++ b/github/resource_github_copilot_team_seat_assignment_test.go @@ -0,0 +1,44 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/acctest" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccGithubCopilotTeamSeatAssignment(t *testing.T) { + t.Run("assigns and removes Copilot seats for a team", func(t *testing.T) { + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + teamName := fmt.Sprintf("%scopilot-team-%s", testResourcePrefix, randomID) + + config := fmt.Sprintf(` +resource "github_team" "test" { + name = "%s" +} + +resource "github_copilot_team_seat_assignment" "test" { + team = github_team.test.slug +} +`, teamName) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessCopilotSeatManagementEnabled(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_copilot_team_seat_assignment.test", "team"), + ), + }, + { + ResourceName: "github_copilot_team_seat_assignment.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + }) +} diff --git a/templates/resources/copilot_organization_seat_assignment.md.tmpl b/templates/resources/copilot_organization_seat_assignment.md.tmpl new file mode 100644 index 0000000000..c04a30499e --- /dev/null +++ b/templates/resources/copilot_organization_seat_assignment.md.tmpl @@ -0,0 +1,29 @@ +--- +page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +description: |- + Assigns a Copilot seat to an organization member. +--- + +# {{.Name}} ({{.Type}}) + +Assigns a GitHub Copilot seat to an organization member. + +This resource requires the organization to have a Copilot Business or Enterprise subscription with seat management set to **Selected users and teams** (not **All members**). + +## Example Usage + +{{ tffile "examples/resources/copilot_organization_seat_assignment/example_1.tf" }} + +## Argument Reference + +The following arguments are supported: + +- `username` - (Required, Forces new resource) The login of the organization member to assign a Copilot seat. + +## Import + +Copilot organization seat assignments can be imported using the username, e.g. + +```shell +terraform import github_copilot_organization_seat_assignment.example someuser +``` diff --git a/templates/resources/copilot_team_seat_assignment.md.tmpl b/templates/resources/copilot_team_seat_assignment.md.tmpl new file mode 100644 index 0000000000..d787c0c177 --- /dev/null +++ b/templates/resources/copilot_team_seat_assignment.md.tmpl @@ -0,0 +1,29 @@ +--- +page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +description: |- + Assigns Copilot seats to all members of an organization team. +--- + +# {{.Name}} ({{.Type}}) + +Assigns GitHub Copilot seats to all members of an organization team. + +This resource requires the organization to have a Copilot Business or Enterprise subscription with seat management set to **Selected users and teams** (not **All members**). + +## Example Usage + +{{ tffile "examples/resources/copilot_team_seat_assignment/example_1.tf" }} + +## Argument Reference + +The following arguments are supported: + +- `team` - (Required, Forces new resource) The slug of the team to assign Copilot seats. + +## Import + +Copilot team seat assignments can be imported using the team slug, e.g. + +```shell +terraform import github_copilot_team_seat_assignment.example my-team +``` From d08e08f012d620789f1020cfce4ae79959174db7 Mon Sep 17 00:00:00 2001 From: Nitin Jain Date: Sat, 30 May 2026 22:43:41 +0200 Subject: [PATCH 2/4] feat: expose computed seat fields on github_copilot_organization_seat_assignment Add created_at, pending_cancellation_date, last_activity_at, last_activity_editor, and plan_type as computed attributes sourced from GetSeatDetails. Useful for auditing seat usage. --- ...ub_copilot_organization_seat_assignment.go | 47 ++++++++++++++++++- ...pilot_organization_seat_assignment.md.tmpl | 10 ++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/github/resource_github_copilot_organization_seat_assignment.go b/github/resource_github_copilot_organization_seat_assignment.go index 17e36c39ee..f2bb3c7e41 100644 --- a/github/resource_github_copilot_organization_seat_assignment.go +++ b/github/resource_github_copilot_organization_seat_assignment.go @@ -28,6 +28,31 @@ func resourceGithubCopilotOrganizationSeatAssignment() *schema.Resource { DiffSuppressFunc: caseInsensitive(), Description: "The login of the user to assign a Copilot seat.", }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp of when the seat was first assigned.", + }, + "pending_cancellation_date": { + Type: schema.TypeString, + Computed: true, + Description: "Date on which the seat will be cancelled, if pending cancellation.", + }, + "last_activity_at": { + Type: schema.TypeString, + Computed: true, + Description: "Timestamp of the user's last Copilot activity.", + }, + "last_activity_editor": { + Type: schema.TypeString, + Computed: true, + Description: "Editor used in the user's last Copilot activity.", + }, + "plan_type": { + Type: schema.TypeString, + Computed: true, + Description: "The Copilot plan type for this seat (e.g. business, enterprise).", + }, }, } } @@ -56,7 +81,7 @@ func resourceGithubCopilotOrganizationSeatAssignmentRead(ctx context.Context, d username := d.Id() - _, _, err := client.Copilot.GetSeatDetails(ctx, org, username) + seat, _, err := client.Copilot.GetSeatDetails(ctx, org, username) if err != nil { if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { tflog.Info(ctx, "Copilot seat assignment no longer exists, removing from state", map[string]any{"username": username}) @@ -70,6 +95,26 @@ func resourceGithubCopilotOrganizationSeatAssignmentRead(ctx context.Context, d return diag.FromErr(err) } + createdAt := seat.GetCreatedAt() + if err := d.Set("created_at", createdAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("pending_cancellation_date", seat.GetPendingCancellationDate()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("last_activity_editor", seat.GetLastActivityEditor()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("plan_type", seat.GetPlanType()); err != nil { + return diag.FromErr(err) + } + + if lastActivity := seat.GetLastActivityAt(); !lastActivity.IsZero() { + if err := d.Set("last_activity_at", lastActivity.String()); err != nil { + return diag.FromErr(err) + } + } + return nil } diff --git a/templates/resources/copilot_organization_seat_assignment.md.tmpl b/templates/resources/copilot_organization_seat_assignment.md.tmpl index c04a30499e..8472744f47 100644 --- a/templates/resources/copilot_organization_seat_assignment.md.tmpl +++ b/templates/resources/copilot_organization_seat_assignment.md.tmpl @@ -20,6 +20,16 @@ The following arguments are supported: - `username` - (Required, Forces new resource) The login of the organization member to assign a Copilot seat. +## Attributes Reference + +In addition to the arguments above, the following attributes are exported: + +- `created_at` - Timestamp of when the seat was first assigned. +- `pending_cancellation_date` - Date on which the seat will be cancelled, if a cancellation is pending. +- `last_activity_at` - Timestamp of the user's last Copilot activity. +- `last_activity_editor` - Editor used in the user's last Copilot activity. +- `plan_type` - The Copilot plan type for this seat (e.g. `business`, `enterprise`). + ## Import Copilot organization seat assignments can be imported using the username, e.g. From 5c63b6247ff57694e941c5cc419da9a8b3a98c5b Mon Sep 17 00:00:00 2001 From: Nitin Jain Date: Sat, 30 May 2026 22:45:40 +0200 Subject: [PATCH 3/4] fix: rename resp_data to respData (Go naming convention) --- github/resource_github_copilot_team_seat_assignment.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/github/resource_github_copilot_team_seat_assignment.go b/github/resource_github_copilot_team_seat_assignment.go index 44856b0a2d..4ee3b9862e 100644 --- a/github/resource_github_copilot_team_seat_assignment.go +++ b/github/resource_github_copilot_team_seat_assignment.go @@ -59,7 +59,7 @@ func resourceGithubCopilotTeamSeatAssignmentRead(ctx context.Context, d *schema. // The Copilot API has no single-team seat lookup; scan all seats for a team assignee matching our slug. opts := &github.ListOptions{PerPage: 100} for { - resp_data, resp, err := client.Copilot.ListCopilotSeats(ctx, org, opts) + respData, resp, err := client.Copilot.ListCopilotSeats(ctx, org, opts) if err != nil { if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { tflog.Info(ctx, "Copilot team seat assignment no longer exists, removing from state", map[string]any{"team": teamSlug}) @@ -69,7 +69,7 @@ func resourceGithubCopilotTeamSeatAssignmentRead(ctx context.Context, d *schema. return diag.FromErr(err) } - for _, seat := range resp_data.Seats { + for _, seat := range respData.Seats { if t, ok := seat.GetTeam(); ok && t.GetSlug() == teamSlug { if err := d.Set("team", teamSlug); err != nil { return diag.FromErr(err) From e74f2c6719ec46c4a8565ff60396fd7d5da202af Mon Sep 17 00:00:00 2001 From: Nitin Jain Date: Sat, 30 May 2026 22:52:02 +0200 Subject: [PATCH 4/4] feat: add github_copilot_organization_settings data source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes read-only Copilot billing settings for the provider org via GET /orgs/{org}/copilot/billing: seat_management_setting, public_code_suggestions, and the full seat_breakdown block. The GitHub API has no write endpoint for these settings so a resource is not possible — a data source is the correct primitive here. Acceptance test verified against a real Copilot-enabled org. --- .../data-source.tf | 9 ++ ...ce_github_copilot_organization_settings.go | 106 ++++++++++++++++++ ...thub_copilot_organization_settings_test.go | 25 +++++ github/provider.go | 1 + .../copilot_organization_settings.md.tmpl | 33 ++++++ 5 files changed, 174 insertions(+) create mode 100644 examples/data-sources/github_copilot_organization_settings/data-source.tf create mode 100644 github/data_source_github_copilot_organization_settings.go create mode 100644 github/data_source_github_copilot_organization_settings_test.go create mode 100644 templates/data-sources/copilot_organization_settings.md.tmpl diff --git a/examples/data-sources/github_copilot_organization_settings/data-source.tf b/examples/data-sources/github_copilot_organization_settings/data-source.tf new file mode 100644 index 0000000000..e65b2a8259 --- /dev/null +++ b/examples/data-sources/github_copilot_organization_settings/data-source.tf @@ -0,0 +1,9 @@ +data "github_copilot_organization_settings" "example" {} + +output "seat_management" { + value = data.github_copilot_organization_settings.example.seat_management_setting +} + +output "total_seats" { + value = data.github_copilot_organization_settings.example.seat_breakdown[0].total +} diff --git a/github/data_source_github_copilot_organization_settings.go b/github/data_source_github_copilot_organization_settings.go new file mode 100644 index 0000000000..708f21d4c8 --- /dev/null +++ b/github/data_source_github_copilot_organization_settings.go @@ -0,0 +1,106 @@ +package github + +import ( + "context" + "errors" + "net/http" + + "github.com/google/go-github/v88/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubCopilotOrganizationSettings() *schema.Resource { + return &schema.Resource{ + ReadContext: dataSourceGithubCopilotOrganizationSettingsRead, + + Schema: map[string]*schema.Schema{ + "seat_management_setting": { + Type: schema.TypeString, + Computed: true, + Description: "How Copilot seats are assigned: assign_selected, all_members, or unconfigured.", + }, + "public_code_suggestions": { + Type: schema.TypeString, + Computed: true, + Description: "Whether Copilot can suggest code matching public repositories: allow or block.", + }, + "seat_breakdown": { + Type: schema.TypeList, + Computed: true, + Description: "Breakdown of Copilot seat usage.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "total": { + Type: schema.TypeInt, + Computed: true, + Description: "Total number of Copilot seats.", + }, + "added_this_cycle": { + Type: schema.TypeInt, + Computed: true, + Description: "Seats added in the current billing cycle.", + }, + "pending_invitation": { + Type: schema.TypeInt, + Computed: true, + Description: "Seats pending invitation acceptance.", + }, + "pending_cancellation": { + Type: schema.TypeInt, + Computed: true, + Description: "Seats pending cancellation.", + }, + "active_this_cycle": { + Type: schema.TypeInt, + Computed: true, + Description: "Seats active in the current billing cycle.", + }, + "inactive_this_cycle": { + Type: schema.TypeInt, + Computed: true, + Description: "Seats inactive in the current billing cycle.", + }, + }, + }, + }, + }, + } +} + +func dataSourceGithubCopilotOrganizationSettingsRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + org := meta.name + + billing, _, err := client.Copilot.GetCopilotBilling(ctx, org) + if err != nil { + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { + return diag.Errorf("Copilot is not enabled for organization %q", org) + } + return diag.FromErr(err) + } + + d.SetId(org) + + if err := d.Set("seat_management_setting", billing.GetSeatManagementSetting()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("public_code_suggestions", billing.GetPublicCodeSuggestions()); err != nil { + return diag.FromErr(err) + } + if breakdown := billing.GetSeatBreakdown(); breakdown != nil { + if err := d.Set("seat_breakdown", []any{map[string]any{ + "total": breakdown.GetTotal(), + "added_this_cycle": breakdown.GetAddedThisCycle(), + "pending_invitation": breakdown.GetPendingInvitation(), + "pending_cancellation": breakdown.GetPendingCancellation(), + "active_this_cycle": breakdown.GetActiveThisCycle(), + "inactive_this_cycle": breakdown.GetInactiveThisCycle(), + }}); err != nil { + return diag.FromErr(err) + } + } + + return nil +} diff --git a/github/data_source_github_copilot_organization_settings_test.go b/github/data_source_github_copilot_organization_settings_test.go new file mode 100644 index 0000000000..4ed4ca750f --- /dev/null +++ b/github/data_source_github_copilot_organization_settings_test.go @@ -0,0 +1,25 @@ +package github + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccDataSourceGithubCopilotOrganizationSettings(t *testing.T) { + t.Run("reads Copilot organization settings", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: `data "github_copilot_organization_settings" "test" {}`, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("data.github_copilot_organization_settings.test", "seat_management_setting"), + resource.TestCheckResourceAttrSet("data.github_copilot_organization_settings.test", "public_code_suggestions"), + ), + }, + }, + }) + }) +} diff --git a/github/provider.go b/github/provider.go index def7b0c378..3e45f1d11a 100644 --- a/github/provider.go +++ b/github/provider.go @@ -246,6 +246,7 @@ func NewProvider() func() *schema.Provider { "github_codespaces_secrets": dataSourceGithubCodespacesSecrets(), "github_codespaces_user_public_key": dataSourceGithubCodespacesUserPublicKey(), "github_codespaces_user_secrets": dataSourceGithubCodespacesUserSecrets(), + "github_copilot_organization_settings": dataSourceGithubCopilotOrganizationSettings(), "github_dependabot_organization_public_key": dataSourceGithubDependabotOrganizationPublicKey(), "github_dependabot_organization_secrets": dataSourceGithubDependabotOrganizationSecrets(), "github_dependabot_public_key": dataSourceGithubDependabotPublicKey(), diff --git a/templates/data-sources/copilot_organization_settings.md.tmpl b/templates/data-sources/copilot_organization_settings.md.tmpl new file mode 100644 index 0000000000..39caea906b --- /dev/null +++ b/templates/data-sources/copilot_organization_settings.md.tmpl @@ -0,0 +1,33 @@ +--- +page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +description: |- + Reads the GitHub Copilot settings for an organization. +--- + +# {{.Name}} ({{.Type}}) + +Use this data source to read the GitHub Copilot settings and seat breakdown for an organization. + +~> **Note** This data source requires the organization to have a Copilot Business or Enterprise subscription. + +## Example Usage + +{{ tffile "examples/data-sources/github_copilot_organization_settings/data-source.tf" }} + +## Argument Reference + +No arguments are required. The data source reads settings for the provider's configured organization. + +## Attributes Reference + +The following attributes are exported: + +- `seat_management_setting` - How Copilot seats are assigned: `assign_selected`, `all_members`, or `unconfigured`. +- `public_code_suggestions` - Whether Copilot can suggest code matching public repositories: `allow` or `block`. +- `seat_breakdown` - Breakdown of Copilot seat usage. Contains: + - `total` - Total number of Copilot seats. + - `added_this_cycle` - Seats added in the current billing cycle. + - `pending_invitation` - Seats pending invitation acceptance. + - `pending_cancellation` - Seats pending cancellation. + - `active_this_cycle` - Seats active in the current billing cycle. + - `inactive_this_cycle` - Seats inactive in the current billing cycle.