Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions github/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
199 changes: 199 additions & 0 deletions github/resource_github_organization_invitation.go
Original file line number Diff line number Diff line change
@@ -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)
}
Comment on lines +90 to +92

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This seems superfluous, since we are setting the same as the resource ID alrady

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())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This isn't needed

Suggested change
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
}
Comment on lines +142 to +149

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This seems problematic as this breaks idempotency. The resource would automatically be removed and next plan would try to re-add it again


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
}
146 changes: 146 additions & 0 deletions github/resource_github_organization_invitation_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading