Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions examples/resources/organization_invitation/example_1.tf

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what you mean by h ?

Original file line number Diff line number Diff line change
@@ -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"
}
1 change: 1 addition & 0 deletions github/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
174 changes: 174 additions & 0 deletions github/resource_github_organization_invitation.go
Original file line number Diff line number Diff line change
@@ -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
}
98 changes: 98 additions & 0 deletions github/resource_github_organization_invitation_test.go
Original file line number Diff line number Diff line change
@@ -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"),
),
},
},
})
})
}
40 changes: 40 additions & 0 deletions templates/resources/organization_invitation.md.tmpl
Original file line number Diff line number Diff line change
@@ -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
```