Skip to content

Commit 4cbaf51

Browse files
committed
fixup! fix: Stop repo collaborators drifting on owner
1 parent e14f116 commit 4cbaf51

7 files changed

Lines changed: 310 additions & 46 deletions

.golangci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ linters:
1212
- errcheck
1313
- errname
1414
- errorlint
15-
# - forcetypeassert TODO: Re-enable when we can fix the issues
15+
# - forcetypeassert # TODO: Re-enable when we can fix the issues
1616
- godot
1717
- govet
1818
- ineffassign

docs/resources/repository_collaborators.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,7 @@ Teams will be added to the repository on apply, and removed if removed from the
2525

2626
## Personal Repositories
2727

28-
For personal repositories, collaborators can only be granted [write](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/repository-access-and-collaboration/permission-levels-for-a-personal-account-repository#collaborator-access-for-a-repository-owned-by-a-personal-account) permission.
29-
30-
!> If the repository owner is not added as a collaborator with admin access, the provider will churn this resource on every plan/apply. To prevent this, ensure that the repository owner is included in the set of user collaborators.
28+
For personal repositories, non-owner collaborators can only be granted [write](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/repository-access-and-collaboration/permission-levels-for-a-personal-account-repository#collaborator-access-for-a-repository-owned-by-a-personal-account) permission. Owners will be ignored unless they are explicitly added, in which case they must be granted `admin` permission.
3129

3230
## Users
3331

@@ -90,14 +88,15 @@ resource "github_repository_collaborators" "some_repo_collaborators" {
9088
- `repository_id` (Number) ID of the repository.
9189

9290
<a id="nestedblock--ignore_team"></a>
91+
9392
### Nested Schema for `ignore_team`
9493

9594
Required:
9695

9796
- `team_id` (String) ID or slug of the team to ignore.
9897

99-
10098
<a id="nestedblock--team"></a>
99+
101100
### Nested Schema for `team`
102101

103102
Required:
@@ -108,8 +107,8 @@ Optional:
108107

109108
- `permission` (String) Permission to grant to the team. Must be one of `pull`, `triage`, `push`, `maintain`, `admin` or the name of an existing [custom repository role](https://docs.github.com/en/enterprise-cloud@latest/organizations/managing-peoples-access-to-your-organization-with-roles/managing-custom-repository-roles-for-an-organization) within the organization. Defaults to `push`.
110109

111-
112110
<a id="nestedblock--user"></a>
111+
113112
### Nested Schema for `user`
114113

115114
Required:

github/resource_github_repository_collaborators.go

Lines changed: 63 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,18 @@ import (
1818

1919
func resourceGithubRepositoryCollaborators() *schema.Resource {
2020
return &schema.Resource{
21-
SchemaVersion: 1,
21+
SchemaVersion: 2,
2222
StateUpgraders: []schema.StateUpgrader{
2323
{
2424
Type: resourceGithubRepositoryCollaboratorsV0().CoreConfigSchema().ImpliedType(),
2525
Upgrade: resourceGithubRepositoryCollaboratorsStateUpgradeV0,
2626
Version: 0,
2727
},
28+
{
29+
Type: resourceGithubRepositoryCollaboratorsV1().CoreConfigSchema().ImpliedType(),
30+
Upgrade: resourceGithubRepositoryCollaboratorsStateUpgradeV1,
31+
Version: 1,
32+
},
2833
},
2934

3035
Description: "Manage the complete set of collaborators (users and teams) for a GitHub repository.",
@@ -145,32 +150,6 @@ func resourceGithubRepositoryCollaboratorsDiff(ctx context.Context, d *schema.Re
145150
}
146151
}
147152

148-
if meta.IsOrganization {
149-
if err := d.SetNew("owner_configured", false); err != nil {
150-
return fmt.Errorf("error setting owner_configured: %w", err)
151-
}
152-
} else if d.NewValueKnown("user") {
153-
users := d.Get("user").(*schema.Set).List()
154-
ownerConfigured := false
155-
owner := strings.ToLower(meta.name)
156-
157-
for _, u := range users {
158-
user := u.(map[string]any)
159-
if strings.ToLower(user["username"].(string)) == owner {
160-
ownerConfigured = true
161-
break
162-
}
163-
}
164-
165-
if err := d.SetNew("owner_configured", ownerConfigured); err != nil {
166-
return fmt.Errorf("error setting owner_configured: %w", err)
167-
}
168-
} else {
169-
if err := d.SetNewComputed("owner_configured"); err != nil {
170-
return fmt.Errorf("error setting owner_configured to computed: %w", err)
171-
}
172-
}
173-
174153
if d.HasChange("team") && d.NewValueKnown("team") {
175154
v, diags := d.GetRawConfigAt(cty.GetAttrPath("team"))
176155
if diags.HasError() {
@@ -196,7 +175,61 @@ func resourceGithubRepositoryCollaboratorsDiff(ctx context.Context, d *schema.Re
196175
}
197176
}
198177

199-
if len(d.Id()) == 0 {
178+
if meta.IsOrganization {
179+
// If the repository belongs to an organization the owner cannot be a,
180+
// collaborator, so owner_configured is always false.
181+
182+
if err := d.SetNew("owner_configured", false); err != nil {
183+
return fmt.Errorf("error setting owner_configured: %w", err)
184+
}
185+
} else if d.NewValueKnown("user") {
186+
// If the repository belongs to a user and we know the new value of user,
187+
// then we can determine the value of owner_configured by checking if
188+
// the owner is included in the list of users.
189+
190+
ownerConfigured := false
191+
owner := strings.ToLower(meta.name)
192+
193+
usersVal := d.Get("user")
194+
if users, ok := usersVal.(*schema.Set); ok {
195+
for _, u := range users.List() {
196+
user, ok := u.(map[string]any)
197+
if !ok {
198+
continue
199+
}
200+
201+
usernameVal, ok := user["username"]
202+
if !ok {
203+
continue
204+
}
205+
206+
username, ok := usernameVal.(string)
207+
if !ok {
208+
continue
209+
}
210+
211+
if strings.ToLower(username) == owner {
212+
ownerConfigured = true
213+
break
214+
}
215+
}
216+
}
217+
218+
if err := d.SetNew("owner_configured", ownerConfigured); err != nil {
219+
return fmt.Errorf("error setting owner_configured: %w", err)
220+
}
221+
} else {
222+
// If the repository belongs to a user but we don't know the new value of user,
223+
// then we don't know if the owner is configured as a collaborator or not,
224+
// so we set owner_configured to computed to indicate that Terraform should
225+
// determine the value during apply.
226+
227+
if err := d.SetNewComputed("owner_configured"); err != nil {
228+
return fmt.Errorf("error setting owner_configured to computed: %w", err)
229+
}
230+
}
231+
232+
if d.Id() == "" {
200233
return nil
201234
}
202235

@@ -281,9 +314,10 @@ func resourceGithubRepositoryCollaboratorsRead(ctx context.Context, d *schema.Re
281314
repoName := d.Get("repository").(string)
282315
teams := d.Get("team").(*schema.Set).List()
283316
ignoreTeams := d.Get("ignore_team").(*schema.Set).List()
317+
ownerConfigured, _ := d.Get("owner_configured").(bool)
284318

285319
inIgnoreUsers := make([]string, 0)
286-
if !isOrg && !d.Get("owner_configured").(bool) {
320+
if !isOrg && !ownerConfigured {
287321
inIgnoreUsers = append(inIgnoreUsers, strings.ToLower(owner))
288322
}
289323

github/resource_github_repository_collaborators_migration.go

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"log"
77
"strconv"
8+
"strings"
89

910
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
1011
)
@@ -88,7 +89,7 @@ func resourceGithubRepositoryCollaboratorsStateUpgradeV0(ctx context.Context, ra
8889
client := meta.v3client
8990
owner := meta.name
9091

91-
log.Printf("[DEBUG] GitHub Repository Collaborators Attributes before migration: %#v", rawState)
92+
log.Printf("[DEBUG] GitHub Repository Collaborators Attributes before migration to v1: %#v", rawState)
9293

9394
repoName, ok := rawState["repository"].(string)
9495
if !ok {
@@ -103,7 +104,141 @@ func resourceGithubRepositoryCollaboratorsStateUpgradeV0(ctx context.Context, ra
103104
rawState["id"] = strconv.FormatInt(repo.GetID(), 10)
104105
rawState["repository_id"] = int(repo.GetID())
105106

106-
log.Printf("[DEBUG] GitHub Repository Collaborators Attributes after migration: %#v", rawState)
107+
log.Printf("[DEBUG] GitHub Repository Collaborators Attributes after migration to v1: %#v", rawState)
108+
109+
return rawState, nil
110+
}
111+
112+
func resourceGithubRepositoryCollaboratorsV1() *schema.Resource {
113+
return &schema.Resource{
114+
SchemaVersion: 1,
115+
116+
Schema: map[string]*schema.Schema{
117+
"repository": {
118+
Type: schema.TypeString,
119+
Required: true,
120+
Description: "Name of the repository.",
121+
},
122+
"repository_id": {
123+
Type: schema.TypeInt,
124+
Computed: true,
125+
Description: "ID of the repository.",
126+
},
127+
"user": {
128+
Type: schema.TypeSet,
129+
Optional: true,
130+
Description: "Users to grant access to the repository.",
131+
Elem: &schema.Resource{
132+
Schema: map[string]*schema.Schema{
133+
"username": {
134+
Type: schema.TypeString,
135+
Description: "Login for the user to add to the repository as a collaborator.",
136+
Required: true,
137+
DiffSuppressFunc: caseInsensitive(),
138+
},
139+
"permission": {
140+
Type: schema.TypeString,
141+
Description: "Permission to grant to the user. Must be one of `pull`, `triage`, `push`, `maintain`, `admin` or the name of an existing [custom repository role](https://docs.github.com/en/enterprise-cloud@latest/organizations/managing-peoples-access-to-your-organization-with-roles/managing-custom-repository-roles-for-an-organization) within the organization. Must be `push` for personal repositories. Defaults to `push`.",
142+
Optional: true,
143+
Default: "push",
144+
},
145+
},
146+
},
147+
},
148+
"team": {
149+
Type: schema.TypeSet,
150+
Optional: true,
151+
Description: "Teams to grant access to the repository.",
152+
Elem: &schema.Resource{
153+
Schema: map[string]*schema.Schema{
154+
"team_id": {
155+
Type: schema.TypeString,
156+
Description: "ID or slug of the team to add to the repository as a collaborator.",
157+
Required: true,
158+
},
159+
"permission": {
160+
Type: schema.TypeString,
161+
Description: "Permission to grant to the team. Must be one of `pull`, `triage`, `push`, `maintain`, `admin` or the name of an existing [custom repository role](https://docs.github.com/en/enterprise-cloud@latest/organizations/managing-peoples-access-to-your-organization-with-roles/managing-custom-repository-roles-for-an-organization) within the organization. Defaults to `push`.",
162+
Optional: true,
163+
Default: "push",
164+
},
165+
},
166+
},
167+
},
168+
"invitation_ids": {
169+
Type: schema.TypeMap,
170+
Description: "Map of usernames to invitation ID for users that haven't yet accepted their invitation to become a collaborator. This is only set on read, and is used internally to track pending invitations for users that aren't yet collaborators.",
171+
Elem: &schema.Schema{
172+
Type: schema.TypeString,
173+
},
174+
Computed: true,
175+
},
176+
"ignore_team": {
177+
Type: schema.TypeSet,
178+
Optional: true,
179+
Description: "Teams to ignore when managing repository collaborators.",
180+
Elem: &schema.Resource{
181+
Schema: map[string]*schema.Schema{
182+
"team_id": {
183+
Type: schema.TypeString,
184+
Description: "ID or slug of the team to ignore.",
185+
Required: true,
186+
},
187+
},
188+
},
189+
},
190+
},
191+
}
192+
}
193+
194+
func resourceGithubRepositoryCollaboratorsStateUpgradeV1(_ context.Context, rawState map[string]any, m any) (map[string]any, error) {
195+
meta, _ := m.(*Owner)
196+
197+
log.Printf("[DEBUG] GitHub Repository Collaborators Attributes before migration to v2: %#v", rawState)
198+
199+
if meta.IsOrganization {
200+
// If the repository belongs to an organization the owner cannot be a,
201+
// collaborator, so owner_configured is always false.
202+
203+
rawState["owner_configured"] = false
204+
} else {
205+
// If the repository belongs to a user and we know the new value of user
206+
// we can determine the value of owner_configured by checking if the owner
207+
// is included in the list of users.
208+
209+
ownerConfigured := false
210+
owner := strings.ToLower(meta.name)
211+
212+
if usersVal, ok := rawState["user"]; ok {
213+
if users, ok := usersVal.([]any); ok {
214+
for _, userVal := range users {
215+
user, ok := userVal.(map[string]any)
216+
if !ok {
217+
continue
218+
}
219+
220+
usernameVal, ok := user["username"]
221+
if !ok {
222+
continue
223+
}
224+
225+
username, ok := usernameVal.(string)
226+
if !ok {
227+
continue
228+
}
229+
230+
if strings.ToLower(username) == owner {
231+
ownerConfigured = true
232+
break
233+
}
234+
}
235+
}
236+
}
237+
238+
rawState["owner_configured"] = ownerConfigured
239+
}
240+
241+
log.Printf("[DEBUG] GitHub Repository Collaborators Attributes after migration to v2: %#v", rawState)
107242

108243
return rawState, nil
109244
}

0 commit comments

Comments
 (0)