Skip to content
Open
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
22 changes: 19 additions & 3 deletions docs/data-sources/membership.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,31 @@ data "github_membership" "membership_for_some_user" {
}
```

### Lookup by stable user ID

```terraform
# Look up a membership by the stable GitHub user ID.
# The numeric ID does not change when the user renames their account.
data "github_membership" "by_user_id" {
user_id = 1
}
```

## Argument Reference

- `username` - (Required) The username to lookup in the organization.
Exactly one of the following must be set:

- `username` - (Optional) The username (login) to lookup in the organization.
- `user_id` - (Optional) The GitHub numeric user ID. Stable across username changes; prefer this for lookups that should survive renames.

Other arguments:

- `organization` - (Optional) The organization to check for the above username.
- `organization` - (Optional) The organization to check for the above user.

## Attributes Reference

- `username` - The username.
- `username` - The username (login). Always reflects the user's current login at refresh time.
- `user_id` - The GitHub numeric user ID.
- `role` - `admin` or `member` -- the role the user has within the organization.
- `etag` - An etag representing the membership object.
- `state` - `active` or `pending` -- the state of membership within the organization. `active` if the member has accepted the invite, or `pending` if the invite is still pending.
16 changes: 15 additions & 1 deletion docs/data-sources/user.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,27 @@ output "current_github_login" {
}
```

### Lookup by stable user ID

```terraform
# Retrieve information about a GitHub user by their stable numeric ID.
# Useful when the user may rename themselves: the lookup keeps working.
data "github_user" "by_id" {
user_id = 1
}
```

## Argument Reference

- `username` - (Required) The username. Use an empty string `""` to retrieve information about the currently authenticated user.
Exactly one of the following must be set:

- `username` - (Optional) The username (login). Use an empty string `""` to retrieve information about the currently authenticated user.
- `user_id` - (Optional) The GitHub numeric user ID. Stable across username changes, so prefer this if you need lookups that survive a rename.

## Attributes Reference

- `id` - the ID of the user.
- `user_id` - the GitHub numeric user ID (same value as `id`, also settable as an input).
- `node_id` - the Node ID of the user.
- `login` - the user's login.
- `avatar_url` - the user's avatar URL.
Expand Down
34 changes: 31 additions & 3 deletions docs/resources/membership.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,46 @@ resource "github_membership" "membership_for_some_user" {
}
```

### Identifying the user by stable numeric ID

Using `user_id` instead of `username` makes the membership resilient to the user renaming their GitHub account. After a rename, the next `terraform refresh` updates the `username` attribute in state with no diff, and the resource continues to manage the same membership.

```terraform
# Manage organization membership by stable GitHub user ID.
# Recommended over `username` for production: if the user renames their
# account, the membership stays in sync without drift.
resource "github_membership" "membership_by_user_id" {
user_id = 1
role = "member"
}
```

## Argument Reference

The following arguments are supported:

- `username` - (Required) The user to add to the organization.
Exactly one of:

- `username` - (Optional) The user (login) to add to the organization. Note: usernames can change; if the user renames themselves, the resource will recreate unless `user_id` is used instead.
- `user_id` - (Optional) The GitHub numeric user ID to add to the organization. Stable across username changes. Recommended for production use.

Other arguments:

- `role` - (Optional) The role of the user within the organization. Must be one of `member` or `admin`. Defaults to `member`. `admin` role represents the `owner` role available via GitHub UI.
- `downgrade_on_destroy` - (Optional) Defaults to `false`. If set to true, when this resource is destroyed, the member will not be removed from the organization. Instead, the member's role will be downgraded to 'member'.

## Attributes Reference

- `username` - The user's current login. When the resource is identified by `user_id`, this attribute tracks the user's live login at refresh time.
- `user_id` - The GitHub numeric user ID.
- `etag` - The etag of the membership object.

## Import

GitHub Membership can be imported using an ID made up of `organization:username`, e.g.
GitHub Membership can be imported using an ID made up of `organization:user_id`, e.g.

```shell
terraform import github_membership.member hashicorp:someuser
terraform import github_membership.member hashicorp:12345
```

Legacy IDs of the form `organization:username` are still accepted on import and will be migrated to the numeric form on the next refresh.
5 changes: 5 additions & 0 deletions examples/data-sources/membership/example_2.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Look up a membership by the stable GitHub user ID.
# The numeric ID does not change when the user renames their account.
data "github_membership" "by_user_id" {
user_id = 1
}
5 changes: 5 additions & 0 deletions examples/data-sources/user/example_2.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Retrieve information about a GitHub user by their stable numeric ID.
# Useful when the user may rename themselves: the lookup keeps working.
data "github_user" "by_id" {
user_id = 1
}
7 changes: 7 additions & 0 deletions examples/resources/membership/example_2.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Manage organization membership by stable GitHub user ID.
# Recommended over `username` for production: if the user renames their
# account, the membership stays in sync without drift.
resource "github_membership" "membership_by_user_id" {
user_id = 1
role = "member"
}
49 changes: 35 additions & 14 deletions github/data_source_github_membership.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,18 @@ func dataSourceGithubMembership() *schema.Resource {

Schema: map[string]*schema.Schema{
"username": {
Type: schema.TypeString,
Required: true,
Type: schema.TypeString,
Optional: true,
Computed: true,
ExactlyOneOf: []string{"username", "user_id"},
Description: "The username (login) to lookup in the organization. Exactly one of `username` or `user_id` must be set.",
},
"user_id": {
Type: schema.TypeInt,
Optional: true,
Computed: true,
ExactlyOneOf: []string{"username", "user_id"},
Description: "The GitHub numeric user ID to lookup in the organization. Stable across username changes. Exactly one of `username` or `user_id` must be set.",
},
"organization": {
Type: schema.TypeString,
Expand All @@ -37,37 +47,48 @@ func dataSourceGithubMembership() *schema.Resource {
}

func dataSourceGithubMembershipRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
username := d.Get("username").(string)

client := meta.(*Owner).v3client
orgName := meta.(*Owner).name

if configuredOrg := d.Get("organization").(string); configuredOrg != "" {
orgName = configuredOrg
}

membership, resp, err := client.Organizations.GetOrgMembership(ctx,
username, orgName)
// Resolve to username (login). If user_id is provided, resolve it via
// GET /user/{id} since GitHub's membership endpoints only accept the
// username. This makes the data source robust against username changes.
var username string
if v, ok := d.GetOk("user_id"); ok {
userID := int64(v.(int))
user, _, err := client.Users.GetByID(ctx, userID)
if err != nil {
return diag.FromErr(err)
}
username = user.GetLogin()
} else {
username = d.Get("username").(string)
}

membership, resp, err := client.Organizations.GetOrgMembership(ctx, username, orgName)
if err != nil {
return diag.FromErr(err)
}

d.SetId(buildTwoPartID(membership.GetOrganization().GetLogin(), membership.GetUser().GetLogin()))

err = d.Set("username", membership.GetUser().GetLogin())
if err != nil {
if err = d.Set("username", membership.GetUser().GetLogin()); err != nil {
return diag.FromErr(err)
}
err = d.Set("role", membership.GetRole())
if err != nil {
if err = d.Set("user_id", membership.GetUser().GetID()); err != nil {
return diag.FromErr(err)
}
err = d.Set("etag", resp.Header.Get("ETag"))
if err != nil {
if err = d.Set("role", membership.GetRole()); err != nil {
return diag.FromErr(err)
}
err = d.Set("state", membership.GetState())
if err != nil {
if err = d.Set("etag", resp.Header.Get("ETag")); err != nil {
return diag.FromErr(err)
}
if err = d.Set("state", membership.GetState()); err != nil {
return diag.FromErr(err)
}
return nil
Expand Down
101 changes: 101 additions & 0 deletions github/data_source_github_membership_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func TestAccGithubMembershipDataSource(t *testing.T) {
resource.TestCheckResourceAttrSet("data.github_membership.test", "role"),
resource.TestCheckResourceAttrSet("data.github_membership.test", "etag"),
resource.TestCheckResourceAttrSet("data.github_membership.test", "state"),
resource.TestCheckResourceAttrSet("data.github_membership.test", "user_id"),
)

resource.Test(t, resource.TestCase{
Expand Down Expand Up @@ -59,4 +60,104 @@ func TestAccGithubMembershipDataSource(t *testing.T) {
},
})
})

t.Run("queries the membership for a user by user_id", func(t *testing.T) {
ctx := t.Context()

meta, err := getTestMeta()
if err != nil {
t.Fatalf("failed to get test meta: %s", err)
}

ghUser, _, err := meta.v3client.Users.Get(ctx, testAccConf.testOrgUser)
if err != nil {
t.Fatalf("failed to resolve org user id: %s", err)
}

config := fmt.Sprintf(`
data "github_membership" "test" {
user_id = %d
organization = "%s"
}
`, ghUser.GetID(), testAccConf.owner)

check := resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("data.github_membership.test", "username", testAccConf.testOrgUser),
resource.TestCheckResourceAttr("data.github_membership.test", "user_id", fmt.Sprintf("%d", ghUser.GetID())),
resource.TestCheckResourceAttrSet("data.github_membership.test", "role"),
resource.TestCheckResourceAttrSet("data.github_membership.test", "etag"),
resource.TestCheckResourceAttrSet("data.github_membership.test", "state"),
)

resource.Test(t, resource.TestCase{
PreCheck: func() { skipUnlessHasOrgs(t) },
ProviderFactories: providerFactories,
Steps: []resource.TestStep{
{
Config: config,
Check: check,
},
},
})
})

t.Run("errors when querying with non-existent user_id", func(t *testing.T) {
config := fmt.Sprintf(`
data "github_membership" "test" {
user_id = 999999999999
organization = "%s"
}
`, testAccConf.owner)

resource.Test(t, resource.TestCase{
PreCheck: func() { skipUnlessHasOrgs(t) },
ProviderFactories: providerFactories,
Steps: []resource.TestStep{
{
Config: config,
ExpectError: regexp.MustCompile(`Not Found`),
},
},
})
})

t.Run("errors when neither username nor user_id is provided", func(t *testing.T) {
config := fmt.Sprintf(`
data "github_membership" "test" {
organization = "%s"
}
`, testAccConf.owner)

resource.Test(t, resource.TestCase{
PreCheck: func() { skipUnlessHasOrgs(t) },
ProviderFactories: providerFactories,
Steps: []resource.TestStep{
{
Config: config,
ExpectError: regexp.MustCompile(`one of (\x60username\x60,\x60user_id\x60|\x60user_id\x60,\x60username\x60) must be specified`),
},
},
})
})

t.Run("errors when both username and user_id are provided", func(t *testing.T) {
config := fmt.Sprintf(`
data "github_membership" "test" {
username = "%s"
user_id = 1
organization = "%s"
}
`, testAccConf.testOrgUser, testAccConf.owner)

resource.Test(t, resource.TestCase{
PreCheck: func() { skipUnlessHasOrgs(t) },
ProviderFactories: providerFactories,
Steps: []resource.TestStep{
{
Config: config,
ExpectError: regexp.MustCompile(`only one of (\x60user_id\x60,\x60username\x60|\x60username\x60,\x60user_id\x60) can be specified`),
},
},
})
})
}
36 changes: 31 additions & 5 deletions github/data_source_github_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"strconv"

"github.com/google/go-github/v86/github"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)
Expand All @@ -14,8 +15,18 @@ func dataSourceGithubUser() *schema.Resource {

Schema: map[string]*schema.Schema{
"username": {
Type: schema.TypeString,
Required: true,
Type: schema.TypeString,
Optional: true,
Computed: true,
ExactlyOneOf: []string{"username", "user_id"},
Description: "The username (login) to lookup. Exactly one of `username` or `user_id` must be set.",
},
"user_id": {
Type: schema.TypeInt,
Optional: true,
Computed: true,
ExactlyOneOf: []string{"username", "user_id"},
Description: "The GitHub numeric user ID to lookup. Stable across username changes. Exactly one of `username` or `user_id` must be set.",
},
"login": {
Type: schema.TypeString,
Expand Down Expand Up @@ -104,11 +115,20 @@ func dataSourceGithubUser() *schema.Resource {
}

func dataSourceGithubUserRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
username := d.Get("username").(string)

client := meta.(*Owner).v3client

user, _, err := client.Users.Get(ctx, username)
var (
user *github.User
err error
)

if v, ok := d.GetOk("user_id"); ok {
userID := int64(v.(int))
user, _, err = client.Users.GetByID(ctx, userID)
} else {
username := d.Get("username").(string)
user, _, err = client.Users.Get(ctx, username)
}
if err != nil {
return diag.FromErr(err)
}
Expand All @@ -133,6 +153,12 @@ func dataSourceGithubUserRead(ctx context.Context, d *schema.ResourceData, meta
}

d.SetId(strconv.FormatInt(user.GetID(), 10))
if err = d.Set("username", user.GetLogin()); err != nil {
return diag.FromErr(err)
}
if err = d.Set("user_id", user.GetID()); err != nil {
return diag.FromErr(err)
}
if err = d.Set("login", user.GetLogin()); err != nil {
return diag.FromErr(err)
}
Expand Down
Loading