diff --git a/examples/resources/organization_invitation/example_1.tf b/examples/resources/organization_invitation/example_1.tf new file mode 100644 index 0000000000..9f30045909 --- /dev/null +++ b/examples/resources/organization_invitation/example_1.tf @@ -0,0 +1,15 @@ +# Invite by email address +resource "github_organization_invitation" "by_email" { + email = "newmember@example.com" + role = "direct_member" +} + +# Invite by GitHub user ID +data "github_user" "invitee" { + username = "someuser" +} + +resource "github_organization_invitation" "by_id" { + invitee_id = data.github_user.invitee.id + role = "direct_member" +} diff --git a/github/provider.go b/github/provider.go index e7df236f19..530e9a2d25 100644 --- a/github/provider.go +++ b/github/provider.go @@ -192,6 +192,7 @@ func NewProvider() func() *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..f50e5a132e --- /dev/null +++ b/github/resource_github_organization_invitation.go @@ -0,0 +1,177 @@ +package github + +import ( + "context" + "errors" + "net/http" + "strconv" + + "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 resourceGithubOrganizationInvitation() *schema.Resource { + return &schema.Resource{ + CreateContext: resourceGithubOrganizationInvitationCreate, + ReadContext: resourceGithubOrganizationInvitationRead, + DeleteContext: resourceGithubOrganizationInvitationDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "email": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ExactlyOneOf: []string{"email", "invitee_id"}, + Description: "The email address of the person to invite. Exactly one of email or invitee_id must be set.", + }, + "invitee_id": { + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + ExactlyOneOf: []string{"email", "invitee_id"}, + Description: "The GitHub user ID of the person to invite. Exactly one of email or invitee_id must be set.", + }, + "role": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Default: "direct_member", + ValidateDiagFunc: validateValueFunc([]string{"admin", "direct_member", "billing_manager"}), + Description: "The role for the new member. Must be one of admin, direct_member, or billing_manager. Defaults to direct_member.", + }, + "login": { + Type: schema.TypeString, + Computed: true, + Description: "The GitHub username of the invited user, if available at invitation time.", + }, + }, + } +} + +func resourceGithubOrganizationInvitationCreate(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + org := meta.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, org, opts) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(strconv.FormatInt(invitation.GetID(), 10)) + + if err := d.Set("login", invitation.GetLogin()); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceGithubOrganizationInvitationRead(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + org := meta.name + + invitationID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return diag.FromErr(err) + } + + // There is no single-item GET endpoint for org invitations; + // scan the pending list to find ours. + opts := &github.ListOptions{PerPage: maxPerPage} + var found *github.Invitation + for { + invitations, resp, err := client.Organizations.ListPendingOrgInvitations(ctx, org, opts) + if err != nil { + return diag.FromErr(err) + } + for _, inv := range invitations { + if inv.GetID() == invitationID { + found = inv + break + } + } + if found != nil || resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + if found == nil { + // Invitation is no longer pending. Check if the invitee accepted and + // became an org member — if so the resource goal is achieved; keep in + // state and let Terraform consider it up-to-date. If the invitee is + // not a member either, the invitation expired or was cancelled and + // must be removed from state so Terraform can re-invite. + login := d.Get("login").(string) + if login != "" { + isMember, _, err := client.Organizations.IsMember(ctx, org, login) + if err != nil { + return diag.FromErr(err) + } + if isMember { + tflog.Info(ctx, "Invitation was accepted; user is an org member — keeping in state", map[string]any{"login": login}) + return nil + } + } + tflog.Info(ctx, "Organization invitation no longer pending and invitee is not a member, removing from state", map[string]any{"invitation_id": d.Id()}) + d.SetId("") + return nil + } + + if err := d.Set("role", found.GetRole()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("login", found.GetLogin()); err != nil { + return diag.FromErr(err) + } + // Only set email if the resource was created with email — the API always + // returns the invitee's email even for invitee_id-based invitations, which + // would cause a perpetual diff when invitee_id is the configured field. + if _, usingEmail := d.GetOk("email"); usingEmail { + if err := d.Set("email", found.GetEmail()); err != nil { + return diag.FromErr(err) + } + } + + return nil +} + +func resourceGithubOrganizationInvitationDelete(ctx context.Context, d *schema.ResourceData, m any) diag.Diagnostics { + meta := m.(*Owner) + client := meta.v3client + org := meta.name + + invitationID, err := strconv.ParseInt(d.Id(), 10, 64) + if err != nil { + return diag.FromErr(err) + } + + _, err = client.Organizations.CancelInvite(ctx, org, invitationID) + if err != nil { + if ghErr, ok := errors.AsType[*github.ErrorResponse](err); ok && ghErr.Response.StatusCode == http.StatusNotFound { + tflog.Info(ctx, "Organization invitation no longer exists, skipping cancel", 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..531c43ec62 --- /dev/null +++ b/github/resource_github_organization_invitation_test.go @@ -0,0 +1,98 @@ +package github + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccGithubOrganizationInvitation(t *testing.T) { + t.Run("invite by email", func(t *testing.T) { + email := os.Getenv("GH_TEST_INVITATION_EMAIL") + if email == "" { + t.Skip("GH_TEST_INVITATION_EMAIL not set") + } + + config := fmt.Sprintf(` +resource "github_organization_invitation" "test" { + email = "%s" +} +`, email) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + 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", "id"), + ), + }, + }, + }) + }) + + t.Run("invite by invitee_id", func(t *testing.T) { + if testAccConf.testExternalUser == "" { + t.Skip("GH_TEST_EXTERNAL_USER not set") + } + + 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) }, + ProviderFactories: providerFactories, + 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", "login"), + ), + }, + }, + }) + }) + + t.Run("invite with admin role", func(t *testing.T) { + email := os.Getenv("GH_TEST_INVITATION_EMAIL") + if email == "" { + t.Skip("GH_TEST_INVITATION_EMAIL not set") + } + + config := fmt.Sprintf(` +resource "github_organization_invitation" "test" { + email = "%s" + role = "admin" +} +`, email) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessHasOrgs(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr("github_organization_invitation.test", "email", email), + resource.TestCheckResourceAttr("github_organization_invitation.test", "role", "admin"), + ), + }, + }, + }) + }) +} diff --git a/templates/resources/organization_invitation.md.tmpl b/templates/resources/organization_invitation.md.tmpl new file mode 100644 index 0000000000..f90339bc7a --- /dev/null +++ b/templates/resources/organization_invitation.md.tmpl @@ -0,0 +1,40 @@ +--- +page_title: "{{.Name}} ({{.Type}}) - {{.RenderedProviderName}}" +description: |- + Invites a user to a GitHub organization by email address or GitHub user ID. +--- + +# {{.Name}} ({{.Type}}) + +Invites a user to a GitHub organization by email address or GitHub user ID. + +When the invitation is accepted and the user becomes an active member, this resource will reflect that on the next plan/refresh. When an invitation expires or is cancelled outside of Terraform, the resource will be removed from state and Terraform will re-invite on the next apply. + +~> **Note** This resource is not compatible with `github_membership`. Use either `github_membership` to manage existing members or `github_organization_invitation` to send invitations. + +## Example Usage + +{{ tffile "examples/resources/organization_invitation/example_1.tf" }} + +## Argument Reference + +The following arguments are supported: + +- `email` - (Optional, Forces new resource) The email address of the person to invite. Exactly one of `email` or `invitee_id` must be set. +- `invitee_id` - (Optional, Forces new resource) The GitHub user ID of the person to invite. Exactly one of `email` or `invitee_id` must be set. +- `role` - (Optional, Forces new resource) The role for the new member. Must be one of `admin`, `direct_member`, or `billing_manager`. Defaults to `direct_member`. + +## Attributes Reference + +In addition to the arguments above, the following attributes are exported: + +- `id` - The ID of the invitation. +- `login` - The GitHub username of the invited user, if available at invitation time. + +## Import + +Organization invitations can be imported using the invitation ID, e.g. + +```shell +terraform import github_organization_invitation.example 1234567 +```