1 package teamster
2
3 import (
4 "context"
5 "fmt"
6 "strings"
7
8 "github.com/google/go-github/v47/github"
9
10 "edge-infra.dev/pkg/lib/cli/sink"
11 "edge-infra.dev/pkg/lib/fog"
12 "edge-infra.dev/pkg/tools/github/ghx"
13 "edge-infra.dev/pkg/tools/team"
14 )
15
16
17
18
19
20
21 func NewCmdSync() *sink.Command {
22 var (
23 ref, repo, org string
24 gh = &ghx.GHX{}
25 )
26
27 cmd := &sink.Command{
28 Use: "sync [flags] <teams file>",
29 Short: "Sync GitHub team rosters from configuration files",
30 Flags: commonFlags(&org, &repo, &ref),
31 Extensions: []sink.Extension{gh},
32 Exec: func(ctx context.Context, r sink.Run) error {
33 if len(r.Args()) < 1 {
34 return fmt.Errorf("path to teams file is required")
35 }
36 if org == "" {
37 return fmt.Errorf("--org is required to sync GitHub teams")
38 }
39
40 client := gh.V3
41 t, err := getTeam(ctx, r.Args()[0], org, repo, ref, gh.V3)
42 if err != nil {
43 return err
44 }
45
46 s := &syncer{client, org}
47 return s.sync(fog.IntoContext(ctx, r.Log), t)
48 },
49 }
50
51 return cmd
52 }
53
54
55 const (
56 roleMember = "member"
57 roleMaintainer = "maintainer"
58 )
59
60
61 type syncer struct {
62 gh *github.Client
63 org string
64 }
65
66 func (s *syncer) sync(ctx context.Context, t *team.Team) error {
67 log := fog.FromContext(ctx).WithName(t.Name)
68
69 ghteam, res, err := s.gh.Teams.GetTeamBySlug(ctx, s.org, t.Name)
70 switch {
71 case res.StatusCode == 404:
72
73
74
75 return s.initTeam(fog.IntoContext(ctx, log), t)
76 case err != nil:
77 return err
78 }
79
80 ctx = fog.IntoContext(ctx, log)
81
82
83 if err := s.reconcileParent(ctx, t, ghteam); err != nil {
84 return err
85 }
86
87
88
89
90
91
92 if err := s.reconcileChildTeams(ctx, t, ghteam); err != nil {
93 return err
94 }
95
96
97
98 if err := s.reconcileMembers(ctx, t, ghteam); err != nil {
99 return err
100 }
101 log.Info("reconciled team")
102
103 return nil
104 }
105
106
107
108 func (s *syncer) reconcileParent(ctx context.Context, t *team.Team, ghteam *github.Team) error {
109
110 var (
111 edit = false
112 deleteParent = false
113 updatedOpts = github.NewTeam{Name: *ghteam.Name, Privacy: ghteam.Privacy}
114 )
115
116
117
118 if *ghteam.Privacy != *closed() {
119 edit = true
120 updatedOpts.Privacy = closed()
121 }
122
123
124
125 if t.HasParent() &&
126 (ghteam.Parent == nil || (ghteam.Parent.Slug != nil && *ghteam.Parent.Slug != t.Parent().Name)) {
127 edit = true
128
129 ghparent, _, err := s.gh.Teams.GetTeamBySlug(ctx, s.org, t.Parent().Name)
130
131
132 if err != nil {
133 return err
134 }
135 updatedOpts.ParentTeamID = ghparent.ID
136 }
137
138 if !t.HasParent() && ghteam.Parent != nil {
139 edit = true
140 deleteParent = true
141 }
142
143
144 if edit {
145 if _, _, err := s.gh.Teams.EditTeamBySlug(
146 ctx,
147 s.org,
148 *ghteam.Slug,
149 updatedOpts,
150 deleteParent,
151 ); err != nil {
152 return err
153 }
154
155 vals := []any{
156 "deletedParent", deleteParent,
157 "privacy", *updatedOpts.Privacy,
158 }
159 if updatedOpts.ParentTeamID != nil {
160 vals = append(vals, "parent", *updatedOpts.ParentTeamID)
161 }
162 fog.FromContext(ctx, vals...).Info("updated team")
163 }
164
165 return nil
166 }
167
168
169
170
171 func (s *syncer) reconcileChildTeams(ctx context.Context, t *team.Team, ghteam *github.Team) error {
172 children, err := s.listChildTeams(ctx, *ghteam.Slug)
173 if err != nil {
174 return err
175 }
176
177 for _, gc := range children {
178 found := false
179 for _, c := range t.Teams {
180 if *gc.Slug == c.Name {
181 found = true
182 break
183 }
184 }
185 if !found {
186 if err := s.deleteTeam(ctx, *gc.Slug); err != nil {
187 return err
188 }
189 }
190 }
191
192 for _, c := range t.Teams {
193 if err := s.sync(ctx, c); err != nil {
194 return err
195 }
196 }
197
198 return err
199 }
200
201
202
203 func (s *syncer) reconcileMembers(
204 ctx context.Context,
205 t *team.Team,
206 ghteam *github.Team,
207 ) error {
208 var (
209 add = map[string]string{}
210 desired = map[string]string{}
211 log = fog.FromContext(ctx)
212 )
213
214
215 for _, login := range t.Leads {
216 desired[login] = roleMaintainer
217 }
218 for _, login := range t.Members {
219 desired[login] = roleMember
220 }
221
222
223
224 current, err := s.getUsers(ctx, *ghteam.Slug)
225 if err != nil {
226 return err
227 }
228 log.V(1).Info("current team state in github", "users", current)
229
230
231
232 for login, role := range desired {
233 switch current[login] {
234 case "":
235 add[login] = role
236 case role:
237
238 log.V(1).Info("user is expected role",
239 "user", login,
240 "role", role,
241 )
242 default:
243
244
245
246
247
248 if current[login] == "maintainer" && desired[login] == "member" {
249 orgmem, _, err := s.gh.Organizations.GetOrgMembership(ctx, login, s.org)
250 if err != nil {
251 return fmt.Errorf("failed to read org membership for %s: %w", login, err)
252 }
253 if orgmem.GetRole() == "admin" {
254 log.V(1).Info("team member is org admin, ignoring maintainer status",
255 "user", login,
256 )
257 continue
258 }
259 }
260 add[login] = reverseRole(current[login])
261 log.V(1).Info("users role needs updated",
262 "user", login,
263 "role", current[login],
264 "desired", add[login],
265 )
266 }
267 }
268
269
270
271
272 for _, login := range t.AllParticipants() {
273 delete(current, login)
274 }
275
276
277 for login := range current {
278 if err := s.removeUser(ctx, *ghteam.Slug, login); err != nil {
279 return err
280 }
281 }
282
283
284 for login, role := range add {
285 if err := s.addUser(ctx, *ghteam.Slug, login, role); err != nil {
286 return err
287 }
288 }
289
290 return nil
291 }
292
293 func (s *syncer) deleteTeam(ctx context.Context, slug string) error {
294 _, err := s.gh.Teams.DeleteTeamBySlug(ctx, s.org, slug)
295 if err != nil {
296 return err
297 }
298 fog.FromContext(ctx).Info("deleted team", "team", slug)
299
300 return nil
301 }
302
303 func (s *syncer) createTeam(ctx context.Context, ghteam github.NewTeam) (*github.Team, error) {
304 t, _, err := s.gh.Teams.CreateTeam(ctx, s.org, ghteam)
305 if err != nil {
306 return nil, err
307 }
308 fog.FromContext(ctx).Info("created team")
309
310 return t, nil
311 }
312
313 func (s *syncer) listChildTeams(ctx context.Context, name string) ([]*github.Team, error) {
314 teams, _, err := s.gh.Teams.ListChildTeamsByParentSlug(ctx, s.org, name, nil)
315 return teams, err
316 }
317
318
319
320
321 func (s *syncer) initTeam(ctx context.Context, t *team.Team) error {
322 opts := github.NewTeam{
323 Name: t.Name,
324 Maintainers: t.Leads,
325 Privacy: closed(),
326 }
327 if t.HasParent() {
328 ghparent, _, err := s.gh.Teams.GetTeamBySlug(ctx, s.org, t.Parent().Name)
329
330
331 if err != nil {
332 return err
333 }
334 opts.ParentTeamID = ghparent.ID
335 }
336 ghteam, err := s.createTeam(ctx, opts)
337 if err != nil {
338 return err
339 }
340 if err := s.addUsers(ctx, *ghteam.Slug, t.Members, roleMember); err != nil {
341 return err
342 }
343 for _, c := range t.Teams {
344 if err := s.sync(ctx, c); err != nil {
345 return err
346 }
347 }
348 return nil
349 }
350
351 func (s *syncer) removeUser(ctx context.Context, slug, user string) error {
352 _, err := s.gh.Teams.RemoveTeamMembershipBySlug(
353 ctx,
354 s.org,
355 slug,
356 user,
357 )
358 if err != nil {
359 return err
360 }
361
362 fog.FromContext(ctx).Info("removed user", "user", user)
363 return nil
364 }
365
366 func (s *syncer) addUser(ctx context.Context, slug, user, role string) error {
367 _, _, err := s.gh.Teams.AddTeamMembershipBySlug(
368 ctx,
369 s.org,
370 slug,
371 user,
372 &github.TeamAddTeamMembershipOptions{Role: role},
373 )
374 if err != nil {
375 return err
376 }
377
378 fog.FromContext(ctx).Info("added user", "user", user, "role", role)
379 return nil
380 }
381
382 func (s *syncer) addUsers(ctx context.Context, slug string, users []string, role string) error {
383 for _, u := range users {
384 if err := s.addUser(ctx, slug, u, role); err != nil {
385 return err
386 }
387 }
388 return nil
389 }
390
391 func (s *syncer) getUsersByRole(
392 ctx context.Context,
393 slug string,
394 role string,
395 ) ([]*github.User, error) {
396 members, _, err := s.gh.Teams.ListTeamMembersBySlug(
397 ctx, s.org, slug, &github.TeamListTeamMembersOptions{
398 Role: role,
399 ListOptions: github.ListOptions{PerPage: 100},
400 },
401 )
402 return members, err
403 }
404
405 func (s *syncer) getUsers(
406 ctx context.Context,
407 slug string,
408 ) (map[string]string, error) {
409 members, err := s.getUsersByRole(ctx, slug, roleMember)
410 if err != nil {
411 return nil, err
412 }
413 maintainers, err := s.getUsersByRole(ctx, slug, roleMaintainer)
414 if err != nil {
415 return nil, err
416 }
417
418 users := make(map[string]string, len(members)+len(maintainers))
419 for _, m := range members {
420 users[strings.ToLower(*m.Login)] = roleMember
421 }
422 for _, m := range maintainers {
423 users[strings.ToLower(*m.Login)] = roleMaintainer
424 }
425
426 return users, nil
427 }
428
429 func reverseRole(r string) string {
430 switch r {
431 case roleMember:
432 return roleMaintainer
433 case roleMaintainer:
434 return roleMember
435 default:
436
437
438 panic(fmt.Sprintf("unexpected team role: %s", r))
439 }
440 }
441
442 func closed() *string {
443 c := "closed"
444 return &c
445 }
446
View as plain text