Skip to content

Commit cd39775

Browse files
authored
fix: Stop repo collaborators drifting on owner (#3471)
* fix: Stop repo collaborators drifting on owner Signed-off-by: Steve Hipwell <steve.hipwell@gmail.com> * fixup! fix: Stop repo collaborators drifting on owner * fixup! fix: Stop repo collaborators drifting on owner * fixup! fix: Stop repo collaborators drifting on owner --------- Signed-off-by: Steve Hipwell <steve.hipwell@gmail.com>
1 parent c41e47c commit cd39775

7 files changed

Lines changed: 479 additions & 35 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: 2 additions & 3 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

@@ -86,6 +84,7 @@ resource "github_repository_collaborators" "some_repo_collaborators" {
8684

8785
- `id` (String) The ID of this resource.
8886
- `invitation_ids` (Map of String) 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.
87+
- `owner_configured` (Boolean) Indicates whether the owner of a personal repository is configured as a collaborator.
8988
- `repository_id` (Number) ID of the repository.
9089

9190
<a id="nestedblock--ignore_team"></a>

github/resource_github_repository_collaborators.go

Lines changed: 161 additions & 24 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.",
@@ -89,6 +94,11 @@ func resourceGithubRepositoryCollaborators() *schema.Resource {
8994
},
9095
},
9196
},
97+
"owner_configured": {
98+
Type: schema.TypeBool,
99+
Computed: true,
100+
Description: "Indicates whether the owner of a personal repository is configured as a collaborator.",
101+
},
92102
"invitation_ids": {
93103
Type: schema.TypeMap,
94104
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.",
@@ -121,15 +131,34 @@ func resourceGithubRepositoryCollaborators() *schema.Resource {
121131
}
122132

123133
func resourceGithubRepositoryCollaboratorsDiff(ctx context.Context, d *schema.ResourceDiff, m any) error {
124-
tflog.Debug(ctx, "Diffing user collaborators")
134+
tflog.Debug(ctx, "Diffing collaborators")
135+
136+
meta, _ := m.(*Owner)
137+
125138
if d.HasChange("user") {
126-
users := d.Get("user").(*schema.Set).List()
139+
users, ok := d.Get("user").(*schema.Set)
140+
if !ok {
141+
return fmt.Errorf("error reading user config")
142+
}
143+
127144
seen := make(map[string]any)
145+
for _, u := range users.List() {
146+
user, ok := u.(map[string]any)
147+
if !ok {
148+
return fmt.Errorf("error reading user config")
149+
}
128150

129-
for _, u := range users {
130-
user := u.(map[string]any)
131-
username := user["username"].(string)
151+
usernameVal, ok := user["username"]
152+
if !ok {
153+
return fmt.Errorf("error reading user config")
154+
}
132155

156+
username, ok := usernameVal.(string)
157+
if !ok {
158+
return fmt.Errorf("error reading user config")
159+
}
160+
161+
username = strings.ToLower(username)
133162
if _, ok := seen[username]; ok {
134163
return fmt.Errorf("duplicate username %s found in user collaborators", username)
135164
}
@@ -162,7 +191,63 @@ func resourceGithubRepositoryCollaboratorsDiff(ctx context.Context, d *schema.Re
162191
}
163192
}
164193

165-
if len(d.Id()) == 0 {
194+
if meta.IsOrganization {
195+
// If the repository belongs to an organization the owner cannot be a,
196+
// collaborator, so owner_configured is always false.
197+
198+
if err := d.SetNew("owner_configured", false); err != nil {
199+
return fmt.Errorf("error setting owner_configured: %w", err)
200+
}
201+
} else if d.NewValueKnown("user") {
202+
// If the repository belongs to a user and we know the new value of user,
203+
// then we can determine the value of owner_configured by checking if
204+
// the owner is included in the list of users.
205+
206+
ownerConfigured := false
207+
owner := meta.name
208+
209+
users, ok := d.Get("user").(*schema.Set)
210+
if !ok {
211+
return fmt.Errorf("error reading user config")
212+
}
213+
214+
for _, u := range users.List() {
215+
user, ok := u.(map[string]any)
216+
if !ok {
217+
return fmt.Errorf("error reading user config")
218+
}
219+
220+
usernameVal, ok := user["username"]
221+
if !ok {
222+
return fmt.Errorf("error reading user config")
223+
}
224+
225+
username, ok := usernameVal.(string)
226+
if !ok {
227+
return fmt.Errorf("error reading user config")
228+
}
229+
230+
if strings.EqualFold(username, owner) {
231+
ownerConfigured = true
232+
break
233+
}
234+
}
235+
236+
if err := d.SetNew("owner_configured", ownerConfigured); err != nil {
237+
return fmt.Errorf("error setting owner_configured: %w", err)
238+
}
239+
} else {
240+
// If the repository belongs to a user but we don't know the new value of user,
241+
// then we don't know if the owner is configured as a collaborator or not,
242+
// so we set owner_configured to computed to indicate that Terraform should
243+
// determine the value during apply.
244+
245+
if err := d.SetNewComputed("owner_configured"); err != nil {
246+
return fmt.Errorf("error setting owner_configured to computed: %w", err)
247+
}
248+
}
249+
250+
if d.Id() == "" {
166251
return nil
167252
}
168253

@@ -186,16 +271,36 @@ func resourceGithubRepositoryCollaboratorsCreate(ctx context.Context, d *schema.
186271
teams := d.Get("team").(*schema.Set).List()
187272
ignoreTeams := d.Get("ignore_team").(*schema.Set).List()
188273

189-
tflog.Debug(ctx, "Creating repository collaborators", map[string]any{
190-
"users": users,
191-
"teams": teams,
192-
"ignoreTeams": ignoreTeams,
193-
})
274+
tflog.Debug(ctx, "Creating repository collaborators", map[string]any{"users": users, "teams": teams, "ignoreTeams": ignoreTeams})
275+
194276
inUsers, err := getUserCollaborators(users)
195277
if err != nil {
196278
return diag.FromErr(err)
197279
}
198280

281+
ownerConfigured := false
282+
inIgnoreUsers := make([]string, 0)
283+
if !isOrg {
284+
ownerConfigured, _ = d.Get("owner_configured").(bool)
285+
286+
if !ownerConfigured {
287+
for _, u := range inUsers {
288+
if strings.EqualFold(u.login, owner) {
289+
ownerConfigured = true
290+
break
291+
}
292+
}
293+
294+
if !ownerConfigured {
295+
inIgnoreUsers = append(inIgnoreUsers, strings.ToLower(owner))
296+
}
297+
298+
if err := d.Set("owner_configured", ownerConfigured); err != nil {
299+
return diag.FromErr(err)
300+
}
301+
}
302+
}
303+
199304
inTeams, err := getTeamCollaborators(teams)
200305
if err != nil {
201306
return diag.FromErr(err)
@@ -206,7 +311,7 @@ func resourceGithubRepositoryCollaboratorsCreate(ctx context.Context, d *schema.
206311
return diag.FromErr(err)
207312
}
208313

209-
invitations, err := updateUserCollaboratorsAndInvites(ctx, client, owner, repoName, inUsers)
314+
invitations, err := updateUserCollaboratorsAndInvites(ctx, client, owner, repoName, inUsers, inIgnoreUsers)
210315
if err != nil {
211316
return diag.FromErr(err)
212317
}
@@ -245,6 +350,12 @@ func resourceGithubRepositoryCollaboratorsRead(ctx context.Context, d *schema.Re
245350
repoName := d.Get("repository").(string)
246351
teams := d.Get("team").(*schema.Set).List()
247352
ignoreTeams := d.Get("ignore_team").(*schema.Set).List()
353+
ownerConfigured, _ := d.Get("owner_configured").(bool)
354+
355+
inIgnoreUsers := make([]string, 0)
356+
if !isOrg && !ownerConfigured {
357+
inIgnoreUsers = append(inIgnoreUsers, strings.ToLower(owner))
358+
}
248359

249360
inTeams, err := getTeamCollaborators(teams)
250361
if err != nil {
@@ -256,7 +367,7 @@ func resourceGithubRepositoryCollaboratorsRead(ctx context.Context, d *schema.Re
256367
return diag.FromErr(err)
257368
}
258369

259-
ghUsers, err := listUserCollaborators(ctx, client, owner, repoName)
370+
ghUsers, err := listUserCollaborators(ctx, client, owner, repoName, inIgnoreUsers)
260371
if err != nil {
261372
if err, ok := errors.AsType[*github.ErrorResponse](err); ok && err.Response.StatusCode == 404 {
262373
tflog.Debug(ctx, fmt.Sprintf("Repository %s not found when listing users, removing from state.", repoName))
@@ -316,6 +427,29 @@ func resourceGithubRepositoryCollaboratorsUpdate(ctx context.Context, d *schema.
316427
return diag.FromErr(err)
317428
}
318429

430+
ownerConfigured := false
431+
inIgnoreUsers := make([]string, 0)
432+
if !isOrg {
433+
ownerConfigured, _ = d.Get("owner_configured").(bool)
434+
435+
if !ownerConfigured {
436+
for _, u := range inUsers {
437+
if strings.EqualFold(u.login, owner) {
438+
ownerConfigured = true
439+
break
440+
}
441+
}
442+
443+
if !ownerConfigured {
444+
inIgnoreUsers = append(inIgnoreUsers, strings.ToLower(owner))
445+
}
446+
447+
if err := d.Set("owner_configured", ownerConfigured); err != nil {
448+
return diag.FromErr(err)
449+
}
450+
}
451+
}
452+
319453
inTeams, err := getTeamCollaborators(teams)
320454
if err != nil {
321455
return diag.FromErr(err)
@@ -326,7 +460,7 @@ func resourceGithubRepositoryCollaboratorsUpdate(ctx context.Context, d *schema.
326460
return diag.FromErr(err)
327461
}
328462

329-
invitations, err := updateUserCollaboratorsAndInvites(ctx, client, owner, repoName, inUsers)
463+
invitations, err := updateUserCollaboratorsAndInvites(ctx, client, owner, repoName, inUsers, inIgnoreUsers)
330464
if err != nil {
331465
return diag.FromErr(err)
332466
}
@@ -362,14 +496,12 @@ func resourceGithubRepositoryCollaboratorsDelete(ctx context.Context, d *schema.
362496

363497
tflog.Debug(ctx, fmt.Sprintf("Removing all collaborators from repository %s.", repoName))
364498

365-
_, err = updateUserCollaboratorsAndInvites(ctx, client, owner, repoName, nil)
366-
if err != nil {
499+
if _, err := updateUserCollaboratorsAndInvites(ctx, client, owner, repoName, nil, nil); err != nil {
367500
return diag.FromErr(err)
368501
}
369502

370503
if isOrg {
371-
err = updateTeamCollaborators(ctx, client, meta.id, owner, repoName, nil, inIgnoreTeams)
372-
if err != nil {
504+
if err := updateTeamCollaborators(ctx, client, meta.id, owner, repoName, nil, inIgnoreTeams); err != nil {
373505
return diag.FromErr(err)
374506
}
375507
}
@@ -462,7 +594,7 @@ func getTeamCollaborators(col []any) (teamCollaborators, error) {
462594

463595
permission, ok := m["permission"].(string)
464596
if !ok || len(permission) == 0 {
465-
return nil, fmt.Errorf("team input must include 'permission'")
597+
return nil, fmt.Errorf("team input must include permission")
466598
}
467599

468600
collaborators[i] = teamCollaborator{
@@ -498,7 +630,7 @@ func getTeamIdentity(d any) (teamIdentity, error) {
498630

499631
o, ok := m["team_id"]
500632
if !ok {
501-
return teamIdentity{}, fmt.Errorf("team input must include 'team_id'")
633+
return teamIdentity{}, fmt.Errorf("team input must include team_id")
502634
}
503635

504636
id, ok := o.(string)
@@ -509,7 +641,7 @@ func getTeamIdentity(d any) (teamIdentity, error) {
509641
return newLegacyTeamIdentity(id), nil
510642
}
511643

512-
func listUserCollaborators(ctx context.Context, client *github.Client, owner, repoName string) (userCollaborators, error) {
644+
func listUserCollaborators(ctx context.Context, client *github.Client, owner, repoName string, ignoreUsers []string) (userCollaborators, error) {
513645
col := make([]userCollaborator, 0)
514646
tflog.Debug(ctx, "Listing user collaborators", map[string]any{
515647
"owner": owner,
@@ -531,6 +663,10 @@ func listUserCollaborators(ctx context.Context, client *github.Client, owner, re
531663
}
532664

533665
for _, c := range collaborators {
666+
if slices.Contains(ignoreUsers, strings.ToLower(c.GetLogin())) {
667+
continue
668+
}
669+
534670
col = append(col, userCollaborator{
535671
userIdentity: userIdentity{
536672
login: c.GetLogin(),
@@ -642,7 +778,7 @@ func listTeamCollaborators(ctx context.Context, client *github.Client, orgName,
642778
return col, nil
643779
}
644780

645-
func updateUserCollaboratorsAndInvites(ctx context.Context, client *github.Client, owner, repoName string, inUsers userCollaborators) (userCollaborators, error) {
781+
func updateUserCollaboratorsAndInvites(ctx context.Context, client *github.Client, owner, repoName string, inUsers userCollaborators, ignoreUsers []string) (userCollaborators, error) {
646782
lookup := make(map[string]userCollaborator)
647783
seen := make(map[string]any)
648784
remove := make([]string, 0)
@@ -659,7 +795,7 @@ func updateUserCollaboratorsAndInvites(ctx context.Context, client *github.Clien
659795
"remove": remove,
660796
})
661797

662-
ghUsers, err := listUserCollaborators(ctx, client, owner, repoName)
798+
ghUsers, err := listUserCollaborators(ctx, client, owner, repoName, ignoreUsers)
663799
if err != nil {
664800
return nil, err
665801
}
@@ -717,6 +853,7 @@ func updateUserCollaboratorsAndInvites(ctx context.Context, client *github.Clien
717853
if err != nil {
718854
return nil, err
719855
}
856+
720857
// AddCollaborator returns 204 No Content (inv == nil) when the invitee
721858
// is an organization member gaining direct access without an
722859
// invitation. In that case there is no invitation ID to record.

0 commit comments

Comments
 (0)