diff --git a/github/resource_github_membership.go b/github/resource_github_membership.go index 5bbe4e22f9..71b396e140 100644 --- a/github/resource_github_membership.go +++ b/github/resource_github_membership.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "github.com/google/go-github/v85/github" "github.com/hashicorp/terraform-plugin-log/tflog" @@ -47,6 +48,11 @@ func resourceGithubMembership() *schema.Resource { Default: false, Description: "Instead of removing the member from the org, you can choose to downgrade their membership to 'member' when this resource is destroyed. This is useful when wanting to downgrade admins while keeping them in the organization", }, + "user_id": { + Type: schema.TypeString, + Computed: true, + Description: "The ID of the user, used to resolve the current username before membership updates in case the username changed (e.g. suspended EMU users).", + }, }, } } @@ -133,6 +139,13 @@ func resourceGithubMembershipRead(ctx context.Context, d *schema.ResourceData, m return diag.FromErr(err) } + user, _, err := client.Users.Get(ctx, username) + if err == nil { + if err = d.Set("user_id", strconv.FormatInt(user.GetID(), 10)); err != nil { + return diag.FromErr(err) + } + } + return nil } @@ -146,7 +159,7 @@ func resourceGithubMembershipDelete(ctx context.Context, d *schema.ResourceData, orgName := meta.(*Owner).name ctx = context.WithValue(ctx, ctxId, d.Id()) - username := d.Get("username").(string) + username := resolveUsernameByID(ctx, client, d.Get("user_id").(string), d.Get("username").(string)) downgradeOnDestroy := d.Get("downgrade_on_destroy").(bool) downgradeTo := "member" diff --git a/github/resource_github_repository_collaborator.go b/github/resource_github_repository_collaborator.go index fd0ff5f3d1..ab71a37b1a 100644 --- a/github/resource_github_repository_collaborator.go +++ b/github/resource_github_repository_collaborator.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "net/http" + "strconv" "strings" "github.com/google/go-github/v85/github" @@ -63,6 +64,11 @@ func resourceGithubRepositoryCollaborator() *schema.Resource { Computed: true, Description: "ID of the invitation to be used in 'github_user_invitation_accepter'", }, + "user_id": { + Type: schema.TypeString, + Computed: true, + Description: "The ID of the user, used to resolve the current username before collaborator updates in case the username changed (e.g. suspended EMU users).", + }, }, } } @@ -162,6 +168,9 @@ func resourceGithubRepositoryCollaboratorRead(d *schema.ResourceData, meta any) if err = d.Set("permission", getPermission(c.GetRoleName())); err != nil { return err } + if err = d.Set("user_id", strconv.FormatInt(c.GetID(), 10)); err != nil { + return err + } return nil } } @@ -187,7 +196,12 @@ func resourceGithubRepositoryCollaboratorUpdate(d *schema.ResourceData, meta any func resourceGithubRepositoryCollaboratorDelete(d *schema.ResourceData, meta any) error { client := meta.(*Owner).v3client - username := d.Get("username").(string) + username := resolveUsernameByID( + context.WithValue(context.Background(), ctxId, d.Id()), + client, + d.Get("user_id").(string), + d.Get("username").(string), + ) repoName := d.Get("repository").(string) owner, repoNameWithoutOwner := parseRepoName(repoName, meta.(*Owner).name) diff --git a/github/resource_github_team_members.go b/github/resource_github_team_members.go index e1c616e7bf..02777f863b 100644 --- a/github/resource_github_team_members.go +++ b/github/resource_github_team_members.go @@ -56,6 +56,12 @@ func resourceGithubTeamMembers() *schema.Resource { }, }, }, + "user_ids": { + Type: schema.TypeMap, + Computed: true, + Description: "Map of usernames to their numeric user IDs, used to resolve current usernames before membership updates in case the username changed (e.g. suspended EMU users).", + Elem: &schema.Schema{Type: schema.TypeString}, + }, }, } } @@ -107,6 +113,11 @@ func resourceGithubTeamMembersUpdate(ctx context.Context, d *schema.ResourceData return diag.FromErr(err) } + storedUserIds := make(map[string]string) + for k, v := range d.Get("user_ids").(map[string]any) { + storedUserIds[k] = v.(string) + } + o, n := d.GetChange("members") vals := make(map[string]*MemberChange) for _, raw := range o.(*schema.Set).List() { @@ -143,9 +154,10 @@ func resourceGithubTeamMembersUpdate(ctx context.Context, d *schema.ResourceData } if del { - log.Printf("[DEBUG] Deleting team membership: %s/%s", teamIdString, username) + currentUsername := resolveUsernameByID(ctx, client, storedUserIds[username], username) + log.Printf("[DEBUG] Deleting team membership: %s/%s", teamIdString, currentUsername) - _, err = client.Teams.RemoveTeamMembershipByID(ctx, orgId, teamId, username) + _, err = client.Teams.RemoveTeamMembershipByID(ctx, orgId, teamId, currentUsername) if err != nil { return diag.FromErr(err) } @@ -204,7 +216,8 @@ func resourceGithubTeamMembersRead(ctx context.Context, d *schema.ResourceData, Members struct { Edges []struct { Node struct { - Login string + Login string + DatabaseId int64 } Role string } @@ -224,6 +237,7 @@ func resourceGithubTeamMembersRead(ctx context.Context, d *schema.ResourceData, } var teamMembersAndMaintainers []any + userIds := make(map[string]string) for { if err := client.Query(ctx, &q, variables); err != nil { return diag.FromErr(err) @@ -235,6 +249,7 @@ func resourceGithubTeamMembersRead(ctx context.Context, d *schema.ResourceData, "username": member.Node.Login, "role": strings.ToLower(member.Role), }) + userIds[member.Node.Login] = strconv.FormatInt(member.Node.DatabaseId, 10) } if !q.Organization.Team.Members.PageInfo.HasNextPage { break @@ -246,6 +261,10 @@ func resourceGithubTeamMembersRead(ctx context.Context, d *schema.ResourceData, return diag.FromErr(err) } + if err := d.Set("user_ids", userIds); err != nil { + return diag.FromErr(err) + } + return nil } @@ -260,15 +279,21 @@ func resourceGithubTeamMembersDelete(ctx context.Context, d *schema.ResourceData return diag.FromErr(err) } + storedUserIds := make(map[string]string) + for k, v := range d.Get("user_ids").(map[string]any) { + storedUserIds[k] = v.(string) + } + members := d.Get("members").(*schema.Set) for _, member := range members.List() { mem := member.(map[string]any) username := mem["username"].(string) + currentUsername := resolveUsernameByID(ctx, client, storedUserIds[username], username) - log.Printf("[DEBUG] Deleting team membership: %s/%s", teamIdString, username) + log.Printf("[DEBUG] Deleting team membership: %s/%s", teamIdString, currentUsername) - _, err = client.Teams.RemoveTeamMembershipByID(ctx, orgId, teamId, username) + _, err = client.Teams.RemoveTeamMembershipByID(ctx, orgId, teamId, currentUsername) if err != nil { return diag.FromErr(err) } diff --git a/github/resource_github_team_membership.go b/github/resource_github_team_membership.go index 30c00d8c62..584427c818 100644 --- a/github/resource_github_team_membership.go +++ b/github/resource_github_team_membership.go @@ -61,6 +61,11 @@ func resourceGithubTeamMembership() *schema.Resource { Type: schema.TypeString, Computed: true, }, + "user_id": { + Type: schema.TypeString, + Computed: true, + Description: "The ID of the user, used to resolve the current username before membership updates in case the username changed (e.g. suspended EMU users).", + }, }, } } @@ -150,6 +155,13 @@ func resourceGithubTeamMembershipRead(ctx context.Context, d *schema.ResourceDat return diag.FromErr(err) } + user, _, err := client.Users.Get(ctx, username) + if err == nil { + if err = d.Set("user_id", strconv.FormatInt(user.GetID(), 10)); err != nil { + return diag.FromErr(err) + } + } + return nil } @@ -163,7 +175,8 @@ func resourceGithubTeamMembershipDelete(ctx context.Context, d *schema.ResourceD if err != nil { return diag.FromErr(err) } - username := d.Get("username").(string) + + username := resolveUsernameByID(ctx, client, d.Get("user_id").(string), d.Get("username").(string)) _, err = client.Teams.RemoveTeamMembershipByID(ctx, orgId, teamId, username) if err != nil { diff --git a/github/util.go b/github/util.go index 4c3eef7c3f..7f4b917638 100644 --- a/github/util.go +++ b/github/util.go @@ -1,6 +1,7 @@ package github import ( + "context" "crypto/md5" "errors" "fmt" @@ -9,6 +10,7 @@ import ( "regexp" "slices" "sort" + "strconv" "strings" "github.com/google/go-github/v85/github" @@ -234,6 +236,29 @@ func validateSecretNameFunc(v any, path cty.Path) diag.Diagnostics { return wrapErrors(errs) } +// resolveUsernameByID resolves the current GitHub login for a user by their numeric ID. +// This handles EMU username changes where a suspended user's username becomes an obfuscated hash. +// If userID is empty or the lookup fails, the fallback username is returned unchanged. +func resolveUsernameByID(ctx context.Context, client *github.Client, userID string, fallback string) string { + if userID == "" { + return fallback + } + id, err := strconv.ParseInt(userID, 10, 64) + if err != nil { + log.Printf("[WARN] Could not parse user_id %q: %s", userID, err) + return fallback + } + user, _, err := client.Users.GetByID(ctx, id) + if err != nil { + log.Printf("[WARN] Could not resolve username for user_id %d: %s. Using fallback %q", id, err, fallback) + return fallback + } + if user.GetLogin() != fallback { + log.Printf("[DEBUG] Resolved user_id %d to current username %q (stored username was %q)", id, user.GetLogin(), fallback) + } + return user.GetLogin() +} + // deleteResourceOn404AndSwallow304OtherwiseReturnError will log and delete resource if error is 404 which indicates resource (or any of its ancestors) // doesn't exist. // resourceDescription represents a formatting string that represents the resource diff --git a/github/util_user.go b/github/util_user.go index 132bd887fe..6deec1d3a8 100644 --- a/github/util_user.go +++ b/github/util_user.go @@ -5,6 +5,9 @@ import "strconv" // userIdentity represents a GitHub user by their login. type userIdentity struct { login string + // id is the numeric GitHub user ID, used to resolve the current login + // when the username may have changed (e.g. suspended EMU users). + id int64 } // userCollaborator represents a GitHub user collaborator with its identity and permission level.