...

Source file src/edge-infra.dev/pkg/tools/team/team.go

Documentation: edge-infra.dev/pkg/tools/team

     1  // Package team provides configuration driven engineering team definitions for
     2  // the purpose of integrating with other systems such as GitHub and documentation
     3  // generation.
     4  package team
     5  
     6  import (
     7  	"fmt"
     8  	"io/fs"
     9  	"path/filepath"
    10  	"sort"
    11  	"strings"
    12  
    13  	"sigs.k8s.io/yaml"
    14  )
    15  
    16  // Team represents a single engineering team and any children teams that it has.
    17  type Team struct {
    18  	// Name is the team name, used for the GitHub team name.
    19  	Name string `json:"name,omitempty"`
    20  
    21  	Leads   []string `json:"leads,omitempty"`
    22  	Members []string `json:"members,omitempty"`
    23  
    24  	// Teams are any children teams.
    25  	Teams []*Team `json:"teams,omitempty"`
    26  
    27  	// Charter is a path to the team's charter relative to the repo root.
    28  	Charter string `json:"charter,omitempty"`
    29  
    30  	// Mission is a one line statement of scope and goals.
    31  	Mission string `json:"mission_statement,omitempty"`
    32  
    33  	// Contact contains the primary contact information for the team.
    34  	Contact *Contact `json:"contact,omitempty"`
    35  
    36  	// Slack contains additional Slack channels maintained by the team.
    37  	Slack []Slack `json:"slack,omitempty"`
    38  
    39  	// Meetings describes recurring meetings that the team hosts.
    40  	Meetings []Meeting `json:"meetings,omitempty"`
    41  
    42  	// Any additional links that the team wants to provide, like issue trackers
    43  	// or project boards.
    44  	Links map[string]string `json:"links,omitempty"`
    45  
    46  	// File is a path to the team's configuration. It is mutually exclusive with
    47  	// any other fields.
    48  	File string `json:"file,omitempty"`
    49  
    50  	parent *Team
    51  }
    52  
    53  type Contact struct {
    54  	Slack Slack `json:"slack,omitempty"`
    55  }
    56  
    57  type Slack struct {
    58  	Name string `json:"name"`
    59  	Link string `json:"link"`
    60  }
    61  
    62  type Meeting struct {
    63  	Description string `json:"description,omitempty"`
    64  	Day         string `json:"day,omitempty"`
    65  	Time        string `json:"time,omitempty"`
    66  	TZ          string `json:"tz,omitempty"`
    67  	Frequency   string `json:"frequency,omitempty"`
    68  	URL         string `json:"url,omitempty"`
    69  	Agenda      string `json:"agenda,omitempty"`
    70  	Minutes     string `json:"minutes,omitempty"`
    71  }
    72  
    73  func (t *Team) IsValid() error {
    74  	if t.File != "" {
    75  		// Ensure all other fields are empty/unset
    76  		if t.Name != "" || t.Teams != nil || t.Leads != nil || t.Members != nil ||
    77  			t.Charter != "" || t.Mission != "" || t.Contact != nil ||
    78  			t.Slack != nil || t.Meetings != nil {
    79  			return fmt.Errorf("can't provide team configuration and 'file', all " +
    80  				"team config must come from referenced file if present")
    81  		}
    82  		// No need for further validation
    83  		return nil
    84  	}
    85  
    86  	if t.Name == "" {
    87  		return fmt.Errorf("'name' is required")
    88  	}
    89  
    90  	if err := isUniq(t.Leads); err != nil {
    91  		return fmt.Errorf("leads has duplicate participants: %w", err)
    92  	}
    93  
    94  	if err := isUniq(t.Members); err != nil {
    95  		return fmt.Errorf("members has duplicate participants: %w", err)
    96  	}
    97  
    98  	for _, l := range t.Leads {
    99  		for _, m := range t.Members {
   100  			if l == m {
   101  				return fmt.Errorf("lead %s is also specified as a member", l)
   102  			}
   103  		}
   104  	}
   105  
   106  	for _, child := range t.Teams {
   107  		if err := child.IsValid(); err != nil {
   108  			return fmt.Errorf("%s 'teams' invalid: %w", t.Name, err)
   109  		}
   110  	}
   111  
   112  	return nil
   113  }
   114  
   115  func (t *Team) HasParent() bool {
   116  	return t.parent != nil
   117  }
   118  
   119  func (t *Team) Parent() *Team {
   120  	return t.parent
   121  }
   122  
   123  // AllMembers returns all of the members for the entire team tree. For all but
   124  // the root team, Leads are considered Members, for example:
   125  //
   126  //	Team Foo contains team Bar led by Bill. Bill is a member of Foo, not a lead.
   127  func (t *Team) AllMembers() []string {
   128  	if len(t.Teams) == 0 {
   129  		return t.Members
   130  	}
   131  	m := append([]string{}, t.Members...)
   132  	for _, c := range t.Teams {
   133  		m = append(m, c.AllParticipants()...)
   134  	}
   135  	return dedupe(m)
   136  }
   137  
   138  // AllParticipants returns all of the people participating in any role for
   139  // the entire team tree.
   140  func (t *Team) AllParticipants() []string {
   141  	p := t.Participants()
   142  	for _, c := range t.Teams {
   143  		p = append(p, c.AllParticipants()...)
   144  	}
   145  	return dedupe(p)
   146  }
   147  
   148  // Participants returns the direct members and leads of the team, omitting
   149  // participants of child teams.
   150  func (t *Team) Participants() []string {
   151  	return append(t.Leads, t.Members...)
   152  }
   153  
   154  // Load loads and resolves a [Team] from FS. If a directory is provided, the
   155  // default file "teams.yaml" is used.
   156  func Load(fsys fs.FS, path string) (*Team, error) {
   157  	stat, err := fs.Stat(fsys, path)
   158  	if err != nil {
   159  		return nil, err
   160  	}
   161  	if stat.IsDir() {
   162  		path = filepath.Join(path, "teams.yaml")
   163  	}
   164  
   165  	return load(fsys, path)
   166  }
   167  
   168  func load(fsys fs.FS, path string) (*Team, error) {
   169  	data, err := fs.ReadFile(fsys, path)
   170  	if err != nil {
   171  		return nil, err
   172  	}
   173  
   174  	t := &Team{}
   175  	if err := yaml.Unmarshal(data, t); err != nil {
   176  		return nil, err
   177  	}
   178  
   179  	normalize(t)
   180  
   181  	if err := t.IsValid(); err != nil {
   182  		return nil, fmt.Errorf("%s is invalid: %w", path, err)
   183  	}
   184  
   185  	if t.File != "" {
   186  		return load(fsys, filepath.Join(filepath.Dir(path), t.File))
   187  	}
   188  
   189  	t.Teams, err = loadTeams(fsys, filepath.Dir(path), t.Teams)
   190  	if err != nil {
   191  		return nil, err
   192  	}
   193  
   194  	for i := range t.Teams {
   195  		t.Teams[i].parent = t
   196  	}
   197  
   198  	return t, nil
   199  }
   200  
   201  func loadTeams(fsys fs.FS, dir string, tt []*Team) ([]*Team, error) {
   202  	for i := range tt {
   203  		if tt[i].File != "" {
   204  			var err error
   205  			p := filepath.Join(dir, tt[i].File)
   206  			tt[i], err = load(fsys, p)
   207  			if err != nil {
   208  				return nil, err
   209  			}
   210  		}
   211  		if len(tt[i].Teams) > 0 {
   212  			var err error
   213  			tt[i].Teams, err = loadTeams(fsys, dir, tt[i].Teams)
   214  			if err != nil {
   215  				return nil, err
   216  			}
   217  			for j := range tt[i].Teams {
   218  				tt[i].Teams[j].parent = tt[i]
   219  			}
   220  		}
   221  	}
   222  	return tt, nil
   223  }
   224  
   225  func normalize(t *Team) {
   226  	// Ensure all user names are lower case.
   227  	for i := range t.Leads {
   228  		t.Leads[i] = strings.ToLower(t.Leads[i])
   229  	}
   230  	for i := range t.Members {
   231  		t.Members[i] = strings.ToLower(t.Members[i])
   232  	}
   233  }
   234  
   235  func dedupe(s []string) []string {
   236  	sort.Strings(s)
   237  	j := 0
   238  	for i := 1; i < len(s); i++ {
   239  		if s[j] == s[i] {
   240  			continue
   241  		}
   242  		j++
   243  		s[j] = s[i]
   244  	}
   245  	result := s[:j+1]
   246  	return result
   247  }
   248  
   249  func isUniq(s []string) error {
   250  	sort.Strings(s)
   251  	j := 0
   252  	for i := 1; i < len(s); i++ {
   253  		if s[j] == s[i] {
   254  			return fmt.Errorf("%s appears twice", s[j])
   255  		}
   256  	}
   257  	return nil
   258  }
   259  

View as plain text