From 7dd1f83dbe6de05e49ef8cadf45a2c82332e677f Mon Sep 17 00:00:00 2001 From: Matt Gowie Date: Thu, 26 Feb 2026 12:39:41 -0700 Subject: [PATCH] feat: add org invite resource --- github/provider.go | 1 + ...resource_github_organization_invitation.go | 199 ++++++++++++++++++ ...rce_github_organization_invitation_test.go | 146 +++++++++++++ .../r/organization_invitation.html.markdown | 72 +++++++ 4 files changed, 418 insertions(+) create mode 100644 github/resource_github_organization_invitation.go create mode 100644 github/resource_github_organization_invitation_test.go create mode 100644 website/docs/r/organization_invitation.html.markdown diff --git a/github/provider.go b/github/provider.go index 2d5d6dc33a..6222ab8115 100644 --- a/github/provider.go +++ b/github/provider.go @@ -173,6 +173,7 @@ func Provider() *schema.Provider { "github_organization_block": resourceOrganizationBlock(), "github_organization_custom_role": resourceGithubOrganizationCustomRole(), "github_organization_custom_properties": resourceGithubOrganizationCustomProperties(), + "github_organization_invitation": resourceGithubOrganizationInvitation(), "github_organization_project": resourceGithubOrganizationProject(), "github_organization_repository_role": resourceGithubOrganizationRepositoryRole(), "github_organization_role": resourceGithubOrganizationRole(), diff --git a/github/resource_github_organization_invitation.go b/github/resource_github_organization_invitation.go new file mode 100644 index 0000000000..10a6e157c1 --- /dev/null +++ b/github/resource_github_organization_invitation.go @@ -0,0 +1,199 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/google/go-github/v83/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 resourceGithubOrganizationInvitation() *schema.Resource { + return &schema.Resource{ + Description: "Invite a user to a GitHub organization by email address or GitHub user ID.", + + CreateContext: resourceGithubOrganizationInvitationCreate, + ReadContext: resourceGithubOrganizationInvitationRead, + DeleteContext: resourceGithubOrganizationInvitationDelete, + + Schema: map[string]*schema.Schema{ + "email": { + Description: "The email address of the person to invite to the organization. Exactly one of `email` or `invitee_id` must be set.", + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ExactlyOneOf: []string{"email", "invitee_id"}, + }, + "invitee_id": { + Description: "The GitHub user ID of the person to invite. Exactly one of `email` or `invitee_id` must be set.", + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + ExactlyOneOf: []string{"email", "invitee_id"}, + }, + "role": { + Description: "The role for the new member. Must be one of `admin`, `direct_member`, or `billing_manager`. Defaults to `direct_member`.", + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Default: "direct_member", + ValidateDiagFunc: validateValueFunc([]string{"admin", "direct_member", "billing_manager"}), + }, + "invitation_id": { + Description: "The ID of the invitation that was created.", + Type: schema.TypeInt, + Computed: true, + }, + "login": { + Description: "The GitHub username of the invited user (if available).", + Type: schema.TypeString, + Computed: true, + }, + }, + } +} + +func resourceGithubOrganizationInvitationCreate(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + err := checkOrganization(meta) + if err != nil { + return diag.FromErr(err) + } + + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + + opts := &github.CreateOrgInvitationOptions{ + Role: github.Ptr(d.Get("role").(string)), + } + + if v, ok := d.GetOk("email"); ok { + opts.Email = github.Ptr(v.(string)) + } + + if v, ok := d.GetOk("invitee_id"); ok { + opts.InviteeID = github.Ptr(int64(v.(int))) + } + + invitation, _, err := client.Organizations.CreateOrgInvitation(ctx, orgName, opts) + if err != nil { + return diag.FromErr(err) + } + + invitationID := invitation.GetID() + d.SetId(strconv.FormatInt(invitationID, 10)) + + if err = d.Set("invitation_id", int(invitationID)); err != nil { + return diag.FromErr(err) + } + if err = d.Set("login", invitation.GetLogin()); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubOrganizationInvitationRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + err := checkOrganization(meta) + if err != nil { + return diag.FromErr(err) + } + + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + + invitationID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return diag.FromErr(err) + } + + ctx = context.WithValue(ctx, ctxId, d.Id()) + + // There is no single-item GET endpoint for org invitations, + // so we paginate through the pending invitations list to find ours. + opts := &github.ListOptions{ + PerPage: maxPerPage, + } + + var invitation *github.Invitation + for { + invitations, resp, err := client.Organizations.ListPendingOrgInvitations(ctx, orgName, opts) + if err != nil { + return diag.FromErr(err) + } + + for _, inv := range invitations { + if inv.GetID() == invitationID { + invitation = inv + break + } + } + + if invitation != nil || resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + if invitation == nil { + // Invitation was accepted, cancelled, or expired — remove from state + tflog.Info(ctx, fmt.Sprintf("Removing organization invitation %s from state because it is no longer pending", d.Id()), map[string]any{ + "invitation_id": d.Id(), + }) + d.SetId("") + return nil + } + + if err = d.Set("invitation_id", int(invitation.GetID())); err != nil { + return diag.FromErr(err) + } + if err = d.Set("role", invitation.GetRole()); err != nil { + return diag.FromErr(err) + } + if err = d.Set("login", invitation.GetLogin()); err != nil { + return diag.FromErr(err) + } + if invitation.GetEmail() != "" { + if err = d.Set("email", invitation.GetEmail()); err != nil { + return diag.FromErr(err) + } + } + + return nil +} + +func resourceGithubOrganizationInvitationDelete(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + err := checkOrganization(meta) + if err != nil { + return diag.FromErr(err) + } + + client := meta.(*Owner).v3client + orgName := meta.(*Owner).name + + invitationID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return diag.FromErr(err) + } + + ctx = context.WithValue(ctx, ctxId, d.Id()) + + _, err = client.Organizations.CancelInvite(ctx, orgName, invitationID) + if err != nil { + var ghErr *github.ErrorResponse + if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusNotFound { + // Already cancelled or accepted — not an error + tflog.Info(ctx, fmt.Sprintf("Organization invitation %s was already cancelled or accepted", d.Id()), map[string]any{ + "invitation_id": d.Id(), + }) + return nil + } + return diag.FromErr(err) + } + + return nil +} diff --git a/github/resource_github_organization_invitation_test.go b/github/resource_github_organization_invitation_test.go new file mode 100644 index 0000000000..d6ba726029 --- /dev/null +++ b/github/resource_github_organization_invitation_test.go @@ -0,0 +1,146 @@ +package github + +import ( + "context" + "fmt" + "os" + "strconv" + "testing" + + "github.com/google/go-github/v83/github" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccGithubOrganizationInvitation_byEmail(t *testing.T) { + email := os.Getenv("GH_TEST_INVITATION_EMAIL") + if email == "" { + t.Skip("GH_TEST_INVITATION_EMAIL not set, skipping email invitation test") + } + + config := fmt.Sprintf(` + resource "github_organization_invitation" "test" { + email = "%s" + } + `, email) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGithubOrganizationInvitationDestroy, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_organization_invitation.test", "email", email), + resource.TestCheckResourceAttr("github_organization_invitation.test", "role", "direct_member"), + resource.TestCheckResourceAttrSet("github_organization_invitation.test", "invitation_id"), + ), + }, + }, + }) +} + +func TestAccGithubOrganizationInvitation_byInviteeId(t *testing.T) { + config := fmt.Sprintf(` + data "github_user" "test" { + username = "%s" + } + + resource "github_organization_invitation" "test" { + invitee_id = data.github_user.test.id + } + `, testAccConf.testExternalUser) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + skipUnlessHasOrgs(t) + if testAccConf.testExternalUser == "" { + t.Skip("GH_TEST_EXTERNAL_USER not set, skipping invitee_id test") + } + }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGithubOrganizationInvitationDestroy, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("github_organization_invitation.test", "invitee_id"), + resource.TestCheckResourceAttr("github_organization_invitation.test", "role", "direct_member"), + resource.TestCheckResourceAttrSet("github_organization_invitation.test", "invitation_id"), + ), + }, + }, + }) +} + +func TestAccGithubOrganizationInvitation_adminRole(t *testing.T) { + email := os.Getenv("GH_TEST_INVITATION_EMAIL") + if email == "" { + t.Skip("GH_TEST_INVITATION_EMAIL not set, skipping admin role invitation test") + } + + config := fmt.Sprintf(` + resource "github_organization_invitation" "test" { + email = "%s" + role = "admin" + } + `, email) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGithubOrganizationInvitationDestroy, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_organization_invitation.test", "email", email), + resource.TestCheckResourceAttr("github_organization_invitation.test", "role", "admin"), + resource.TestCheckResourceAttrSet("github_organization_invitation.test", "invitation_id"), + ), + }, + }, + }) +} + +func testAccCheckGithubOrganizationInvitationDestroy(s *terraform.State) error { + meta, err := getTestMeta() + if err != nil { + return err + } + client := meta.v3client + orgName := meta.name + + for _, rs := range s.RootModule().Resources { + if rs.Type != "github_organization_invitation" { + continue + } + + invitationID, err := strconv.ParseInt(rs.Primary.ID, 10, 64) + if err != nil { + return err + } + + opts := &github.ListOptions{PerPage: 100} + for { + invitations, resp, err := client.Organizations.ListPendingOrgInvitations(context.TODO(), orgName, opts) + if err != nil { + return err + } + + for _, inv := range invitations { + if inv.GetID() == invitationID { + return fmt.Errorf("organization invitation %d still exists", invitationID) + } + } + + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + } + + return nil +} diff --git a/website/docs/r/organization_invitation.html.markdown b/website/docs/r/organization_invitation.html.markdown new file mode 100644 index 0000000000..53409cea41 --- /dev/null +++ b/website/docs/r/organization_invitation.html.markdown @@ -0,0 +1,72 @@ +--- +layout: "github" +page_title: "GitHub: github_organization_invitation Resource" +description: |- + Invite a user to a GitHub organization by email address or GitHub user ID. +--- + +# github_organization_invitation (Resource) + +Invite a user to a GitHub organization by email address or GitHub user ID. This +resource creates a pending organization invitation, which the invitee must accept +to become a member. + +~> **Note:** Once the invitation is accepted, this resource will be removed from +state on the next `terraform plan`/`apply`. To manage ongoing organization +membership after acceptance, use [`github_membership`](membership.html). + +~> **Note:** The `role` attribute uses `direct_member` (not `member` as used by +`github_membership`). Make sure to use the correct value when specifying a role. + +## Example Usage + +### Invite by email address + +```terraform +resource "github_organization_invitation" "example" { + email = "user@example.com" +} +``` + +### Invite by GitHub user ID + +```terraform +data "github_user" "example" { + username = "example-user" +} + +resource "github_organization_invitation" "example" { + invitee_id = data.github_user.example.id +} +``` + +### Invite as admin + +```terraform +resource "github_organization_invitation" "admin" { + email = "admin@example.com" + role = "admin" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `email` - (Optional) The email address of the person to invite to the + organization. Exactly one of `email` or `invitee_id` must be set. + +* `invitee_id` - (Optional) The GitHub user ID of the person to invite. + Exactly one of `email` or `invitee_id` must be set. + +* `role` - (Optional) The role for the new member. Must be one of `admin`, + `direct_member`, or `billing_manager`. Defaults to `direct_member`. + +## Attribute Reference + +The following additional attributes are exported: + +* `invitation_id` - The ID of the invitation that was created. + +* `login` - The GitHub username of the invited user, if the invitee has a + GitHub account.