Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5cc6da0
Move `toInt` and `toInt64` functions to `util.go`
deiga Jan 16, 2026
4654ca5
`resource_github_emu_group_mapping`) Change `Read` to use `ListExtern…
deiga Jan 17, 2026
1787fb3
`resource_github_emu_group_mapping`: Refactor `Create` to have less u…
deiga Jan 18, 2026
a4a8eaf
`emu_group_mapping`: Add `group_name` computed attribute
deiga Jan 18, 2026
8693502
Add matching of team ID into Read
deiga Jan 25, 2026
19dfa51
Split Create and Update to separate functions
deiga Jan 25, 2026
9973292
Update Importer to use new ID pattern
deiga Jan 25, 2026
7858f1a
Add Schema migration for new ID format
deiga Jan 25, 2026
cd9220b
Changes `group_id` to `ForceNew`
deiga Jan 25, 2026
1f80b9b
Add skipped test while waiting for `terraform-plugin-testing`
deiga Jan 25, 2026
54cf8dc
Add `CustomizeDiff` function to determine if `team_slug` change needs…
deiga Jan 25, 2026
98276f5
Update docs
deiga Jan 25, 2026
eb28a9f
Use `lookupTeamID` instead of `getTeamID`
deiga Jan 27, 2026
22d8455
Move `Create` before `Read`
deiga Jan 27, 2026
ff8de82
Replace `deep` package with `go-cmp`
deiga Jan 29, 2026
e632207
`ListExternalGroupsForTeamBySlug` does not return a nested `Teams` sl…
deiga Jan 29, 2026
7649872
Replace `go-github-mock` with `githubApiMock`
deiga Jan 29, 2026
e1fe75b
Remove unnecessry `matchTeamID` function
deiga Jan 30, 2026
6195269
Address review comments
deiga Feb 3, 2026
0e9d579
Rename state migartion functions
deiga Feb 3, 2026
998aa15
Inline unnecessary function
deiga Feb 3, 2026
f8e0147
Use new reusable diffing pattern
deiga Feb 3, 2026
1b4f74e
Address review comments
deiga Feb 3, 2026
343bc7d
Refactor mockResponse builder to accept inputs
deiga Feb 4, 2026
9c3367d
Delete test
deiga Feb 4, 2026
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
292 changes: 185 additions & 107 deletions github/resource_github_emu_group_mapping.go

Large diffs are not rendered by default.

68 changes: 68 additions & 0 deletions github/resource_github_emu_group_mapping_migration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package github

import (
"context"
"net/http"
"strconv"

"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

func resourceGithubEMUGroupMappingV0() *schema.Resource {
return &schema.Resource{
Schema: map[string]*schema.Schema{
"team_slug": {
Type: schema.TypeString,
Required: true,
Description: "Slug of the GitHub team.",
},
"group_id": {
Type: schema.TypeInt,
Required: true,
Description: "Integer corresponding to the external group ID to be linked.",
},
"etag": {
Type: schema.TypeString,
Computed: true,
},
},
}
}

func resourceGithubEMUGroupMappingStateUpgradeV0(ctx context.Context, rawState map[string]any, meta any) (map[string]any, error) {
orgName := meta.(*Owner).name
tflog.Trace(ctx, "GitHub EMU Group Mapping State before migration", map[string]any{"state": rawState, "owner": orgName})

client := meta.(*Owner).v3client

teamSlug := rawState["team_slug"].(string)
// We need to bypass the etag because we need to get the latest group
ctx = context.WithValue(ctx, ctxEtag, nil)
groupsList, resp, err := client.Teams.ListExternalGroupsForTeamBySlug(ctx, orgName, teamSlug)
if err != nil {
if resp != nil && (resp.StatusCode == http.StatusNotFound) {
// If the Group is not found, remove it from state
tflog.Info(ctx, "Removing EMU group mapping from state because team no longer exists in GitHub", map[string]any{
"resource_id": rawState["id"],
})
return nil, err
}
return nil, err
}

group := groupsList.Groups[0]
teamID, err := lookupTeamID(ctx, meta.(*Owner), teamSlug)
if err != nil {
return nil, err
}
rawState["team_id"] = teamID
resourceID, err := buildID(strconv.FormatInt(teamID, 10), teamSlug, strconv.FormatInt(group.GetGroupID(), 10))
if err != nil {
return nil, err
}
rawState["id"] = resourceID

tflog.Trace(ctx, "GitHub EMU Group Mapping State after migration", map[string]any{"state": rawState})
return rawState, nil
}
45 changes: 45 additions & 0 deletions github/resource_github_emu_group_mapping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,51 @@ func TestAccGithubEMUGroupMapping(t *testing.T) {
},
})
})

t.Run("forces new when switching to different team", func(t *testing.T) {
t.Skip("Skipping this test because we don't have terraform-plugin-testing available yet.")
randomID := acctest.RandString(5)
teamName1 := fmt.Sprintf("%semu1-%s", testResourcePrefix, randomID)
teamName2 := fmt.Sprintf("%semu2-%s", testResourcePrefix, randomID)

config := `
resource "github_team" "test1" {
name = "%s"
description = "EMU group mapping test team 1"
}
resource "github_team" "test2" {
name = "%s"
description = "EMU group mapping test team 2"
}
resource "github_emu_group_mapping" "test" {
team_slug = github_team.%s.slug
group_id = %d
}
`

resource.Test(t, resource.TestCase{
PreCheck: func() { skipUnlessEnterprise(t) },
ProviderFactories: providerFactories,
CheckDestroy: testAccCheckGithubEMUGroupMappingDestroy,
Steps: []resource.TestStep{
{
Config: fmt.Sprintf(config, teamName1, teamName2, "test1", groupID),
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttr("github_emu_group_mapping.test", "team_slug", teamName1),
),
},
{
Config: fmt.Sprintf(config, teamName1, teamName2, "test2", groupID),
// ConfigPlanChecks: resource.ConfigPlanChecks{
// PreApply: []plancheck.PlanCheck{
// plancheckExpectKnownValues("github_emu_group_mapping.test", "team_slug", teamName2),
// plancheck.ExpectResourceAction("github_emu_group_mapping.test", plancheck.ResourceActionDestroyBeforeCreate), // Verify that ForceNew is triggered
// },
// },
},
},
})
})
}

func testAccCheckGithubEMUGroupMappingDestroy(s *terraform.State) error {
Expand Down
40 changes: 40 additions & 0 deletions github/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -342,3 +342,43 @@ func deleteResourceOn404AndSwallow304OtherwiseReturnError(err error, d *schema.R
}
return err
}

// Helper function to safely convert interface{} to int, handling both int and float64.
func toInt(v any) int {
switch val := v.(type) {
case int:
return val
case float64:
return int(val)
case int64:
return int(val)
default:
return 0
}
}

// Helper function to safely convert interface{} to int64, handling both int and float64.
func toInt64(v any) int64 {
switch val := v.(type) {
case int:
return int64(val)
case int64:
return val
case float64:
return int64(val)
default:
return 0
}
}

// lookupTeamID looks up the ID of a team by its slug.
func lookupTeamID(ctx context.Context, meta *Owner, slug string) (int64, error) {
client := meta.v3client
owner := meta.name

team, _, err := client.Teams.GetTeamBySlug(ctx, owner, slug)
if err != nil {
return 0, err
}
return team.GetID(), nil
}
51 changes: 51 additions & 0 deletions github/util_diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"

"github.com/google/go-github/v82/github"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

Expand Down Expand Up @@ -109,3 +110,53 @@ func diffSecretVariableVisibility(ctx context.Context, d *schema.ResourceDiff, _

return nil
}

// diffTeam compares the team_id and team_slug fields to determine if the team has changed.
func diffTeam(ctx context.Context, diff *schema.ResourceDiff, m any) error {
// Skip for new resources - no existing team_id to compare against
if len(diff.Id()) == 0 {
return nil
}

if diff.HasChange("team_slug") {
if isNewTeamID(ctx, diff, m) {
return diff.ForceNew("team_slug")
}
}

return nil
}

// helper function to determine if the team has changed or was renamed.
func isNewTeamID(ctx context.Context, diff *schema.ResourceDiff, m any) bool {
// Get old team_id from state
oldTeamID := toInt64(diff.Get("team_id"))
if oldTeamID == 0 {
return false
}
meta := m.(*Owner)

// Resolve new team_slug to team ID via API
oldTeamSlug, newTeamSlug := diff.GetChange("team_slug")
newTeamID, err := lookupTeamID(ctx, meta, newTeamSlug.(string))
if err != nil {
// If team doesn't exist or API fails, skip ForceNew check and let Read handle it
tflog.Debug(ctx, "Unable to resolve new team_slug to team ID, skipping ForceNew check", map[string]any{
"new_team_slug": newTeamSlug,
"error": err.Error(),
})
return false
}

if newTeamID != oldTeamID {
tflog.Debug(ctx, "Team ID changed, forcing new resource", map[string]any{
"old_team_id": oldTeamID,
"new_team_id": newTeamID,
"new_team_slug": newTeamSlug,
"old_team_slug": oldTeamSlug,
})
return true
}

return false
}
28 changes: 0 additions & 28 deletions github/util_rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,34 +10,6 @@ import (
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

// Helper function to safely convert interface{} to int, handling both int and float64.
func toInt(v any) int {
switch val := v.(type) {
case int:
return val
case float64:
return int(val)
case int64:
return int(val)
default:
return 0
}
}

// Helper function to safely convert interface{} to int64, handling both int and float64.
func toInt64(v any) int64 {
switch val := v.(type) {
case int:
return int64(val)
case int64:
return val
case float64:
return int64(val)
default:
return 0
}
}

func toPullRequestMergeMethods(input any) []github.PullRequestMergeMethod {
value, ok := input.([]any)
if !ok || len(value) == 0 {
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
module github.com/integrations/terraform-provider-github/v6

go 1.24.0
go 1.24.4
Comment thread
stevehipwell marked this conversation as resolved.

require (
github.com/go-jose/go-jose/v3 v3.0.4
github.com/google/go-cmp v0.7.0
github.com/google/go-github/v82 v82.0.0
github.com/google/uuid v1.6.0
github.com/hashicorp/go-cty v1.5.0
Expand All @@ -21,7 +22,6 @@ require (
github.com/cloudflare/circl v1.6.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-checkpoint v0.5.0 // indirect
Expand Down
6 changes: 2 additions & 4 deletions website/docs/r/emu_group_mapping.html.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ description: |-

This resource manages mappings between external groups for enterprise managed users and GitHub teams. It wraps the [Teams#ExternalGroups API](https://docs.github.com/en/rest/reference/teams#external-groups). Note that this is a distinct resource from `github_team_sync_group_mapping`. `github_emu_group_mapping` is special to the Enterprise Managed User (EMU) external group feature, whereas `github_team_sync_group_mapping` is specific to Identity Provider Groups.

!> **Warning:**: This resources `Read` function has a fundamental bug. It doesn't verify that the group is actually linked to the team. Someone could modify the linked group outside of Terraform and the resource would not detect it.

## Example Usage

```hcl
Expand All @@ -29,8 +27,8 @@ The following arguments are supported:

## Import

GitHub EMU External Group Mappings can be imported using the external `group_id` and `team_slug` separated by a colon, e.g.
GitHub EMU External Group Mappings can be imported using the `team_slug` and external `group_id` separated by a colon, e.g.

```sh
$ terraform import github_emu_group_mapping.example_emu_group_mapping 28836:emu-test-team
$ terraform import github_emu_group_mapping.example_emu_group_mapping emu-test-team:28836
```