From 053ddd93c503b87a37559866f6032941d2ef1dd4 Mon Sep 17 00:00:00 2001 From: Nitin Jain Date: Mon, 1 Jun 2026 07:52:03 +0200 Subject: [PATCH 1/2] feat: add github_organization_invitation resource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2197 Adds a new resource to invite users to a GitHub organization by email address or GitHub user ID, using the CreateOrgInvitation API. This addresses the limitation of github_membership which requires the user to already be a GitHub user and cannot invite by email. Addresses all review feedback from #3229: - Removed redundant d.Set("invitation_id", ...) — the resource ID is the invitation ID; a separate computed field is unnecessary - Removed unnecessary ctx = context.WithValue(ctx, ctxId, ...) calls - Fixed idempotency: when an invitation is no longer pending, Read now checks if the invitee became an org member (invitation accepted) and keeps the resource in state rather than blindly removing it, which would have caused a perpetual create loop --- .../organization_invitation/example_1.tf | 15 ++ github/provider.go | 1 + ...resource_github_organization_invitation.go | 174 ++++++++++++++++++ ...rce_github_organization_invitation_test.go | 98 ++++++++++ .../resources/organization_invitation.md.tmpl | 40 ++++ 5 files changed, 328 insertions(+) create mode 100644 examples/resources/organization_invitation/example_1.tf create mode 100644 github/resource_github_organization_invitation.go create mode 100644 github/resource_github_organization_invitation_test.go create mode 100644 templates/resources/organization_invitation.md.tmpl 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 b70c58c9f0..8e42c6b9ac 100644 --- a/github/provider.go +++ b/github/provider.go @@ -169,6 +169,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..a373f14fa8 --- /dev/null +++ b/github/resource_github_organization_invitation.go @@ -0,0 +1,174 @@ +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) + } + if found.GetEmail() != "" { + 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 +``` From b021c62669355850aca87f7424ae17f93c6d2853 Mon Sep 17 00:00:00 2001 From: Nitin Jain Date: Mon, 1 Jun 2026 08:03:50 +0200 Subject: [PATCH 2/2] fix: don't overwrite email from API when resource uses invitee_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GitHub API always returns the invitee's email in the invitation response, even for invitee_id-based invitations. Setting email in state from the Read response caused a perpetual diff when the config used invitee_id — each plan would propose to destroy and recreate. Only set email in Read when the resource was originally created with email. --- github/resource_github_organization_invitation.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/github/resource_github_organization_invitation.go b/github/resource_github_organization_invitation.go index a373f14fa8..f50e5a132e 100644 --- a/github/resource_github_organization_invitation.go +++ b/github/resource_github_organization_invitation.go @@ -142,7 +142,10 @@ func resourceGithubOrganizationInvitationRead(ctx context.Context, d *schema.Res if err := d.Set("login", found.GetLogin()); err != nil { return diag.FromErr(err) } - if found.GetEmail() != "" { + // 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) }