Skip to content

Commit 81514ae

Browse files
[FEAT] Support looking up github_membership data source by user_id
Add an optional 'user_id' input (mutually exclusive with 'username') to the github_membership data source. When set, the user is resolved via GET /user/{id} and the resulting login is used to query the org membership endpoint, which only accepts logins. The data source now always exposes 'user_id' as a computed attribute too, so downstream resources can refer to the stable numeric ID even when the query used a login. Refs #524
1 parent 5419243 commit 81514ae

5 files changed

Lines changed: 173 additions & 20 deletions

File tree

docs/data-sources/membership.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,31 @@ data "github_membership" "membership_for_some_user" {
1616
}
1717
```
1818

19+
### Lookup by stable user ID
20+
21+
```terraform
22+
# Look up a membership by the stable GitHub user ID.
23+
# The numeric ID does not change when the user renames their account.
24+
data "github_membership" "by_user_id" {
25+
user_id = 1
26+
}
27+
```
28+
1929
## Argument Reference
2030

21-
- `username` - (Required) The username to lookup in the organization.
31+
Exactly one of the following must be set:
32+
33+
- `username` - (Optional) The username (login) to lookup in the organization.
34+
- `user_id` - (Optional) The GitHub numeric user ID. Stable across username changes; prefer this for lookups that should survive renames.
35+
36+
Other arguments:
2237

23-
- `organization` - (Optional) The organization to check for the above username.
38+
- `organization` - (Optional) The organization to check for the above user.
2439

2540
## Attributes Reference
2641

27-
- `username` - The username.
42+
- `username` - The username (login). Always reflects the user's current login at refresh time.
43+
- `user_id` - The GitHub numeric user ID.
2844
- `role` - `admin` or `member` -- the role the user has within the organization.
2945
- `etag` - An etag representing the membership object.
3046
- `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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Look up a membership by the stable GitHub user ID.
2+
# The numeric ID does not change when the user renames their account.
3+
data "github_membership" "by_user_id" {
4+
user_id = 1
5+
}

github/data_source_github_membership.go

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,18 @@ func dataSourceGithubMembership() *schema.Resource {
1313

1414
Schema: map[string]*schema.Schema{
1515
"username": {
16-
Type: schema.TypeString,
17-
Required: true,
16+
Type: schema.TypeString,
17+
Optional: true,
18+
Computed: true,
19+
ExactlyOneOf: []string{"username", "user_id"},
20+
Description: "The username (login) to lookup in the organization. Exactly one of `username` or `user_id` must be set.",
21+
},
22+
"user_id": {
23+
Type: schema.TypeInt,
24+
Optional: true,
25+
Computed: true,
26+
ExactlyOneOf: []string{"username", "user_id"},
27+
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.",
1828
},
1929
"organization": {
2030
Type: schema.TypeString,
@@ -37,37 +47,48 @@ func dataSourceGithubMembership() *schema.Resource {
3747
}
3848

3949
func dataSourceGithubMembershipRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
40-
username := d.Get("username").(string)
41-
4250
client := meta.(*Owner).v3client
4351
orgName := meta.(*Owner).name
4452

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

49-
membership, resp, err := client.Organizations.GetOrgMembership(ctx,
50-
username, orgName)
57+
// Resolve to username (login). If user_id is provided, resolve it via
58+
// GET /user/{id} since GitHub's membership endpoints only accept the
59+
// username. This makes the data source robust against username changes.
60+
var username string
61+
if v, ok := d.GetOk("user_id"); ok {
62+
userID := int64(v.(int))
63+
user, _, err := client.Users.GetByID(ctx, userID)
64+
if err != nil {
65+
return diag.FromErr(err)
66+
}
67+
username = user.GetLogin()
68+
} else {
69+
username = d.Get("username").(string)
70+
}
71+
72+
membership, resp, err := client.Organizations.GetOrgMembership(ctx, username, orgName)
5173
if err != nil {
5274
return diag.FromErr(err)
5375
}
5476

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

57-
err = d.Set("username", membership.GetUser().GetLogin())
58-
if err != nil {
79+
if err = d.Set("username", membership.GetUser().GetLogin()); err != nil {
5980
return diag.FromErr(err)
6081
}
61-
err = d.Set("role", membership.GetRole())
62-
if err != nil {
82+
if err = d.Set("user_id", membership.GetUser().GetID()); err != nil {
6383
return diag.FromErr(err)
6484
}
65-
err = d.Set("etag", resp.Header.Get("ETag"))
66-
if err != nil {
85+
if err = d.Set("role", membership.GetRole()); err != nil {
6786
return diag.FromErr(err)
6887
}
69-
err = d.Set("state", membership.GetState())
70-
if err != nil {
88+
if err = d.Set("etag", resp.Header.Get("ETag")); err != nil {
89+
return diag.FromErr(err)
90+
}
91+
if err = d.Set("state", membership.GetState()); err != nil {
7192
return diag.FromErr(err)
7293
}
7394
return nil

github/data_source_github_membership_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ func TestAccGithubMembershipDataSource(t *testing.T) {
2626
resource.TestCheckResourceAttrSet("data.github_membership.test", "role"),
2727
resource.TestCheckResourceAttrSet("data.github_membership.test", "etag"),
2828
resource.TestCheckResourceAttrSet("data.github_membership.test", "state"),
29+
resource.TestCheckResourceAttrSet("data.github_membership.test", "user_id"),
2930
)
3031

3132
resource.Test(t, resource.TestCase{
@@ -59,4 +60,104 @@ func TestAccGithubMembershipDataSource(t *testing.T) {
5960
},
6061
})
6162
})
63+
64+
t.Run("queries the membership for a user by user_id", func(t *testing.T) {
65+
ctx := t.Context()
66+
67+
meta, err := getTestMeta()
68+
if err != nil {
69+
t.Fatalf("failed to get test meta: %s", err)
70+
}
71+
72+
ghUser, _, err := meta.v3client.Users.Get(ctx, testAccConf.testOrgUser)
73+
if err != nil {
74+
t.Fatalf("failed to resolve org user id: %s", err)
75+
}
76+
77+
config := fmt.Sprintf(`
78+
data "github_membership" "test" {
79+
user_id = %d
80+
organization = "%s"
81+
}
82+
`, ghUser.GetID(), testAccConf.owner)
83+
84+
check := resource.ComposeTestCheckFunc(
85+
resource.TestCheckResourceAttr("data.github_membership.test", "username", testAccConf.testOrgUser),
86+
resource.TestCheckResourceAttr("data.github_membership.test", "user_id", fmt.Sprintf("%d", ghUser.GetID())),
87+
resource.TestCheckResourceAttrSet("data.github_membership.test", "role"),
88+
resource.TestCheckResourceAttrSet("data.github_membership.test", "etag"),
89+
resource.TestCheckResourceAttrSet("data.github_membership.test", "state"),
90+
)
91+
92+
resource.Test(t, resource.TestCase{
93+
PreCheck: func() { skipUnlessHasOrgs(t) },
94+
ProviderFactories: providerFactories,
95+
Steps: []resource.TestStep{
96+
{
97+
Config: config,
98+
Check: check,
99+
},
100+
},
101+
})
102+
})
103+
104+
t.Run("errors when querying with non-existent user_id", func(t *testing.T) {
105+
config := fmt.Sprintf(`
106+
data "github_membership" "test" {
107+
user_id = 999999999999
108+
organization = "%s"
109+
}
110+
`, testAccConf.owner)
111+
112+
resource.Test(t, resource.TestCase{
113+
PreCheck: func() { skipUnlessHasOrgs(t) },
114+
ProviderFactories: providerFactories,
115+
Steps: []resource.TestStep{
116+
{
117+
Config: config,
118+
ExpectError: regexp.MustCompile(`Not Found`),
119+
},
120+
},
121+
})
122+
})
123+
124+
t.Run("errors when neither username nor user_id is provided", func(t *testing.T) {
125+
config := fmt.Sprintf(`
126+
data "github_membership" "test" {
127+
organization = "%s"
128+
}
129+
`, testAccConf.owner)
130+
131+
resource.Test(t, resource.TestCase{
132+
PreCheck: func() { skipUnlessHasOrgs(t) },
133+
ProviderFactories: providerFactories,
134+
Steps: []resource.TestStep{
135+
{
136+
Config: config,
137+
ExpectError: regexp.MustCompile(`one of (\x60username\x60,\x60user_id\x60|\x60user_id\x60,\x60username\x60) must be specified`),
138+
},
139+
},
140+
})
141+
})
142+
143+
t.Run("errors when both username and user_id are provided", func(t *testing.T) {
144+
config := fmt.Sprintf(`
145+
data "github_membership" "test" {
146+
username = "%s"
147+
user_id = 1
148+
organization = "%s"
149+
}
150+
`, testAccConf.testOrgUser, testAccConf.owner)
151+
152+
resource.Test(t, resource.TestCase{
153+
PreCheck: func() { skipUnlessHasOrgs(t) },
154+
ProviderFactories: providerFactories,
155+
Steps: []resource.TestStep{
156+
{
157+
Config: config,
158+
ExpectError: regexp.MustCompile(`only one of (\x60user_id\x60,\x60username\x60|\x60username\x60,\x60user_id\x60) can be specified`),
159+
},
160+
},
161+
})
162+
})
62163
}

templates/data-sources/membership.md.tmpl

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,25 @@ Use this data source to find out if a user is a member of your organization, as
1212

1313
{{ tffile "examples/data-sources/membership/example_1.tf" }}
1414

15+
### Lookup by stable user ID
16+
17+
{{ tffile "examples/data-sources/membership/example_2.tf" }}
18+
1519
## Argument Reference
1620

17-
- `username` - (Required) The username to lookup in the organization.
21+
Exactly one of the following must be set:
22+
23+
- `username` - (Optional) The username (login) to lookup in the organization.
24+
- `user_id` - (Optional) The GitHub numeric user ID. Stable across username changes; prefer this for lookups that should survive renames.
25+
26+
Other arguments:
1827

19-
- `organization` - (Optional) The organization to check for the above username.
28+
- `organization` - (Optional) The organization to check for the above user.
2029

2130
## Attributes Reference
2231

23-
- `username` - The username.
32+
- `username` - The username (login). Always reflects the user's current login at refresh time.
33+
- `user_id` - The GitHub numeric user ID.
2434
- `role` - `admin` or `member` -- the role the user has within the organization.
2535
- `etag` - An etag representing the membership object.
2636
- `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.

0 commit comments

Comments
 (0)