Skip to content

Commit 77f949e

Browse files
Nitin Jainnitinjain999
authored andcommitted
feat: add github_copilot_organization_seat_assignment and github_copilot_team_seat_assignment resources
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.
1 parent 7024b09 commit 77f949e

10 files changed

Lines changed: 375 additions & 0 deletions
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
resource "github_copilot_organization_seat_assignment" "example" {
2+
username = "someuser"
3+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
resource "github_team" "example" {
2+
name = "my-copilot-team"
3+
}
4+
5+
resource "github_copilot_team_seat_assignment" "example" {
6+
team = github_team.example.slug
7+
}

github/acc_test.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,19 @@ func skipUnlessMode(t *testing.T, testModes ...testMode) {
345345
t.Skip("Skipping as not supported test mode")
346346
}
347347
}
348+
349+
func skipUnlessCopilotSeatManagementEnabled(t *testing.T) {
350+
t.Helper()
351+
skipUnlessHasOrgs(t)
352+
meta, err := getTestMeta()
353+
if err != nil {
354+
t.Fatalf("failed to get test meta: %s", err)
355+
}
356+
billing, _, err := meta.v3client.Copilot.GetCopilotBilling(context.Background(), meta.name)
357+
if err != nil {
358+
t.Skipf("Skipping: Copilot billing not available for org %s: %s", meta.name, err)
359+
}
360+
if billing.GetSeatManagementSetting() != "assign_selected" {
361+
t.Skipf("Skipping: Copilot seat management is %q, must be %q", billing.GetSeatManagementSetting(), "assign_selected")
362+
}
363+
}

github/provider.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ func NewProvider() func() *schema.Provider {
157157
"github_codespaces_organization_secret_repositories": resourceGithubCodespacesOrganizationSecretRepositories(),
158158
"github_codespaces_secret": resourceGithubCodespacesSecret(),
159159
"github_codespaces_user_secret": resourceGithubCodespacesUserSecret(),
160+
"github_copilot_organization_seat_assignment": resourceGithubCopilotOrganizationSeatAssignment(),
161+
"github_copilot_team_seat_assignment": resourceGithubCopilotTeamSeatAssignment(),
160162
"github_dependabot_organization_secret": resourceGithubDependabotOrganizationSecret(),
161163
"github_dependabot_organization_secret_repositories": resourceGithubDependabotOrganizationSecretRepositories(),
162164
"github_dependabot_organization_secret_repository": resourceGithubDependabotOrganizationSecretRepository(),
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
8+
"github.com/google/go-github/v88/github"
9+
"github.com/hashicorp/terraform-plugin-log/tflog"
10+
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
11+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
12+
)
13+
14+
func resourceGithubCopilotOrganizationSeatAssignment() *schema.Resource {
15+
return &schema.Resource{
16+
CreateContext: resourceGithubCopilotOrganizationSeatAssignmentCreate,
17+
ReadContext: resourceGithubCopilotOrganizationSeatAssignmentRead,
18+
DeleteContext: resourceGithubCopilotOrganizationSeatAssignmentDelete,
19+
Importer: &schema.ResourceImporter{
20+
StateContext: schema.ImportStatePassthroughContext,
21+
},
22+
23+
Schema: map[string]*schema.Schema{
24+
"username": {
25+
Type: schema.TypeString,
26+
Required: true,
27+
ForceNew: true,
28+
DiffSuppressFunc: caseInsensitive(),
29+
Description: "The login of the user to assign a Copilot seat.",
30+
},
31+
},
32+
}
33+
}
34+
35+
func resourceGithubCopilotOrganizationSeatAssignmentCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
36+
meta := m.(*Owner)
37+
client := meta.v3client
38+
org := meta.name
39+
40+
username := d.Get("username").(string)
41+
42+
_, _, err := client.Copilot.AddCopilotUsers(ctx, org, []string{username})
43+
if err != nil {
44+
return diag.FromErr(err)
45+
}
46+
47+
d.SetId(username)
48+
49+
return resourceGithubCopilotOrganizationSeatAssignmentRead(ctx, d, m)
50+
}
51+
52+
func resourceGithubCopilotOrganizationSeatAssignmentRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
53+
meta := m.(*Owner)
54+
client := meta.v3client
55+
org := meta.name
56+
57+
username := d.Id()
58+
59+
_, _, err := client.Copilot.GetSeatDetails(ctx, org, username)
60+
if err != nil {
61+
if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound {
62+
tflog.Info(ctx, "Copilot seat assignment no longer exists, removing from state", map[string]any{"username": username})
63+
d.SetId("")
64+
return nil
65+
}
66+
return diag.FromErr(err)
67+
}
68+
69+
if err := d.Set("username", username); err != nil {
70+
return diag.FromErr(err)
71+
}
72+
73+
return nil
74+
}
75+
76+
func resourceGithubCopilotOrganizationSeatAssignmentDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
77+
meta := m.(*Owner)
78+
client := meta.v3client
79+
org := meta.name
80+
81+
username := d.Id()
82+
83+
_, _, err := client.Copilot.RemoveCopilotUsers(ctx, org, []string{username})
84+
if err != nil {
85+
if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound {
86+
tflog.Info(ctx, "Copilot seat assignment no longer exists, skipping delete", map[string]any{"username": username})
87+
return nil
88+
}
89+
return diag.FromErr(err)
90+
}
91+
92+
return nil
93+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package github
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
8+
)
9+
10+
func TestAccGithubCopilotOrganizationSeatAssignment(t *testing.T) {
11+
if testAccConf.testOrgUser == "" {
12+
t.Skip("GH_TEST_ORG_USER not set")
13+
}
14+
15+
username := testAccConf.testOrgUser
16+
17+
t.Run("assigns and removes a Copilot seat for a user", func(t *testing.T) {
18+
config := fmt.Sprintf(`
19+
resource "github_copilot_organization_seat_assignment" "test" {
20+
username = "%s"
21+
}
22+
`, username)
23+
24+
resource.Test(t, resource.TestCase{
25+
PreCheck: func() { skipUnlessCopilotSeatManagementEnabled(t) },
26+
ProviderFactories: providerFactories,
27+
Steps: []resource.TestStep{
28+
{
29+
Config: config,
30+
Check: resource.ComposeTestCheckFunc(
31+
resource.TestCheckResourceAttr("github_copilot_organization_seat_assignment.test", "username", username),
32+
),
33+
},
34+
{
35+
ResourceName: "github_copilot_organization_seat_assignment.test",
36+
ImportState: true,
37+
ImportStateVerify: true,
38+
},
39+
},
40+
})
41+
})
42+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
8+
"github.com/google/go-github/v88/github"
9+
"github.com/hashicorp/terraform-plugin-log/tflog"
10+
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
11+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
12+
)
13+
14+
func resourceGithubCopilotTeamSeatAssignment() *schema.Resource {
15+
return &schema.Resource{
16+
CreateContext: resourceGithubCopilotTeamSeatAssignmentCreate,
17+
ReadContext: resourceGithubCopilotTeamSeatAssignmentRead,
18+
DeleteContext: resourceGithubCopilotTeamSeatAssignmentDelete,
19+
Importer: &schema.ResourceImporter{
20+
StateContext: schema.ImportStatePassthroughContext,
21+
},
22+
23+
Schema: map[string]*schema.Schema{
24+
"team": {
25+
Type: schema.TypeString,
26+
Required: true,
27+
ForceNew: true,
28+
DiffSuppressFunc: caseInsensitive(),
29+
Description: "The slug of the team to assign Copilot seats.",
30+
},
31+
},
32+
}
33+
}
34+
35+
func resourceGithubCopilotTeamSeatAssignmentCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
36+
meta := m.(*Owner)
37+
client := meta.v3client
38+
org := meta.name
39+
40+
team := d.Get("team").(string)
41+
42+
_, _, err := client.Copilot.AddCopilotTeams(ctx, org, []string{team})
43+
if err != nil {
44+
return diag.FromErr(err)
45+
}
46+
47+
d.SetId(team)
48+
49+
return resourceGithubCopilotTeamSeatAssignmentRead(ctx, d, m)
50+
}
51+
52+
func resourceGithubCopilotTeamSeatAssignmentRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
53+
meta := m.(*Owner)
54+
client := meta.v3client
55+
org := meta.name
56+
57+
teamSlug := d.Id()
58+
59+
// The Copilot API has no single-team seat lookup; scan all seats for a team assignee matching our slug.
60+
opts := &github.ListOptions{PerPage: 100}
61+
for {
62+
resp_data, resp, err := client.Copilot.ListCopilotSeats(ctx, org, opts)
63+
if err != nil {
64+
if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound {
65+
tflog.Info(ctx, "Copilot team seat assignment no longer exists, removing from state", map[string]any{"team": teamSlug})
66+
d.SetId("")
67+
return nil
68+
}
69+
return diag.FromErr(err)
70+
}
71+
72+
for _, seat := range resp_data.Seats {
73+
if t, ok := seat.GetTeam(); ok && t.GetSlug() == teamSlug {
74+
if err := d.Set("team", teamSlug); err != nil {
75+
return diag.FromErr(err)
76+
}
77+
return nil
78+
}
79+
}
80+
81+
if resp.NextPage == 0 {
82+
break
83+
}
84+
opts.Page = resp.NextPage
85+
}
86+
87+
// Team not found in any seat — it was removed outside Terraform.
88+
tflog.Info(ctx, "Copilot team seat assignment no longer exists, removing from state", map[string]any{"team": teamSlug})
89+
d.SetId("")
90+
return nil
91+
}
92+
93+
func resourceGithubCopilotTeamSeatAssignmentDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics {
94+
meta := m.(*Owner)
95+
client := meta.v3client
96+
org := meta.name
97+
98+
teamSlug := d.Id()
99+
100+
_, _, err := client.Copilot.RemoveCopilotTeams(ctx, org, []string{teamSlug})
101+
if err != nil {
102+
if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound {
103+
tflog.Info(ctx, "Copilot team seat assignment no longer exists, skipping delete", map[string]any{"team": teamSlug})
104+
return nil
105+
}
106+
return diag.FromErr(err)
107+
}
108+
109+
return nil
110+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package github
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
8+
"github.com/hashicorp/terraform-plugin-testing/helper/resource"
9+
)
10+
11+
func TestAccGithubCopilotTeamSeatAssignment(t *testing.T) {
12+
t.Run("assigns and removes Copilot seats for a team", func(t *testing.T) {
13+
randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum)
14+
teamName := fmt.Sprintf("%scopilot-team-%s", testResourcePrefix, randomID)
15+
16+
config := fmt.Sprintf(`
17+
resource "github_team" "test" {
18+
name = "%s"
19+
}
20+
21+
resource "github_copilot_team_seat_assignment" "test" {
22+
team = github_team.test.slug
23+
}
24+
`, teamName)
25+
26+
resource.Test(t, resource.TestCase{
27+
PreCheck: func() { skipUnlessCopilotSeatManagementEnabled(t) },
28+
ProviderFactories: providerFactories,
29+
Steps: []resource.TestStep{
30+
{
31+
Config: config,
32+
Check: resource.ComposeTestCheckFunc(
33+
resource.TestCheckResourceAttrSet("github_copilot_team_seat_assignment.test", "team"),
34+
),
35+
},
36+
{
37+
ResourceName: "github_copilot_team_seat_assignment.test",
38+
ImportState: true,
39+
ImportStateVerify: true,
40+
},
41+
},
42+
})
43+
})
44+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}"
3+
description: |-
4+
Assigns a Copilot seat to an organization member.
5+
---
6+
7+
# {{.Name}} ({{.Type}})
8+
9+
Assigns a GitHub Copilot seat to an organization member.
10+
11+
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**).
12+
13+
## Example Usage
14+
15+
{{ tffile "examples/resources/copilot_organization_seat_assignment/example_1.tf" }}
16+
17+
## Argument Reference
18+
19+
The following arguments are supported:
20+
21+
- `username` - (Required, Forces new resource) The login of the organization member to assign a Copilot seat.
22+
23+
## Import
24+
25+
Copilot organization seat assignments can be imported using the username, e.g.
26+
27+
```shell
28+
terraform import github_copilot_organization_seat_assignment.example someuser
29+
```
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}"
3+
description: |-
4+
Assigns Copilot seats to all members of an organization team.
5+
---
6+
7+
# {{.Name}} ({{.Type}})
8+
9+
Assigns GitHub Copilot seats to all members of an organization team.
10+
11+
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**).
12+
13+
## Example Usage
14+
15+
{{ tffile "examples/resources/copilot_team_seat_assignment/example_1.tf" }}
16+
17+
## Argument Reference
18+
19+
The following arguments are supported:
20+
21+
- `team` - (Required, Forces new resource) The slug of the team to assign Copilot seats.
22+
23+
## Import
24+
25+
Copilot team seat assignments can be imported using the team slug, e.g.
26+
27+
```shell
28+
terraform import github_copilot_team_seat_assignment.example my-team
29+
```

0 commit comments

Comments
 (0)