// Package team provides configuration driven engineering team definitions for // the purpose of integrating with other systems such as GitHub and documentation // generation. package team import ( "fmt" "io/fs" "path/filepath" "sort" "strings" "sigs.k8s.io/yaml" ) // Team represents a single engineering team and any children teams that it has. type Team struct { // Name is the team name, used for the GitHub team name. Name string `json:"name,omitempty"` Leads []string `json:"leads,omitempty"` Members []string `json:"members,omitempty"` // Teams are any children teams. Teams []*Team `json:"teams,omitempty"` // Charter is a path to the team's charter relative to the repo root. Charter string `json:"charter,omitempty"` // Mission is a one line statement of scope and goals. Mission string `json:"mission_statement,omitempty"` // Contact contains the primary contact information for the team. Contact *Contact `json:"contact,omitempty"` // Slack contains additional Slack channels maintained by the team. Slack []Slack `json:"slack,omitempty"` // Meetings describes recurring meetings that the team hosts. Meetings []Meeting `json:"meetings,omitempty"` // Any additional links that the team wants to provide, like issue trackers // or project boards. Links map[string]string `json:"links,omitempty"` // File is a path to the team's configuration. It is mutually exclusive with // any other fields. File string `json:"file,omitempty"` parent *Team } type Contact struct { Slack Slack `json:"slack,omitempty"` } type Slack struct { Name string `json:"name"` Link string `json:"link"` } type Meeting struct { Description string `json:"description,omitempty"` Day string `json:"day,omitempty"` Time string `json:"time,omitempty"` TZ string `json:"tz,omitempty"` Frequency string `json:"frequency,omitempty"` URL string `json:"url,omitempty"` Agenda string `json:"agenda,omitempty"` Minutes string `json:"minutes,omitempty"` } func (t *Team) IsValid() error { if t.File != "" { // Ensure all other fields are empty/unset if t.Name != "" || t.Teams != nil || t.Leads != nil || t.Members != nil || t.Charter != "" || t.Mission != "" || t.Contact != nil || t.Slack != nil || t.Meetings != nil { return fmt.Errorf("can't provide team configuration and 'file', all " + "team config must come from referenced file if present") } // No need for further validation return nil } if t.Name == "" { return fmt.Errorf("'name' is required") } if err := isUniq(t.Leads); err != nil { return fmt.Errorf("leads has duplicate participants: %w", err) } if err := isUniq(t.Members); err != nil { return fmt.Errorf("members has duplicate participants: %w", err) } for _, l := range t.Leads { for _, m := range t.Members { if l == m { return fmt.Errorf("lead %s is also specified as a member", l) } } } for _, child := range t.Teams { if err := child.IsValid(); err != nil { return fmt.Errorf("%s 'teams' invalid: %w", t.Name, err) } } return nil } func (t *Team) HasParent() bool { return t.parent != nil } func (t *Team) Parent() *Team { return t.parent } // AllMembers returns all of the members for the entire team tree. For all but // the root team, Leads are considered Members, for example: // // Team Foo contains team Bar led by Bill. Bill is a member of Foo, not a lead. func (t *Team) AllMembers() []string { if len(t.Teams) == 0 { return t.Members } m := append([]string{}, t.Members...) for _, c := range t.Teams { m = append(m, c.AllParticipants()...) } return dedupe(m) } // AllParticipants returns all of the people participating in any role for // the entire team tree. func (t *Team) AllParticipants() []string { p := t.Participants() for _, c := range t.Teams { p = append(p, c.AllParticipants()...) } return dedupe(p) } // Participants returns the direct members and leads of the team, omitting // participants of child teams. func (t *Team) Participants() []string { return append(t.Leads, t.Members...) } // Load loads and resolves a [Team] from FS. If a directory is provided, the // default file "teams.yaml" is used. func Load(fsys fs.FS, path string) (*Team, error) { stat, err := fs.Stat(fsys, path) if err != nil { return nil, err } if stat.IsDir() { path = filepath.Join(path, "teams.yaml") } return load(fsys, path) } func load(fsys fs.FS, path string) (*Team, error) { data, err := fs.ReadFile(fsys, path) if err != nil { return nil, err } t := &Team{} if err := yaml.Unmarshal(data, t); err != nil { return nil, err } normalize(t) if err := t.IsValid(); err != nil { return nil, fmt.Errorf("%s is invalid: %w", path, err) } if t.File != "" { return load(fsys, filepath.Join(filepath.Dir(path), t.File)) } t.Teams, err = loadTeams(fsys, filepath.Dir(path), t.Teams) if err != nil { return nil, err } for i := range t.Teams { t.Teams[i].parent = t } return t, nil } func loadTeams(fsys fs.FS, dir string, tt []*Team) ([]*Team, error) { for i := range tt { if tt[i].File != "" { var err error p := filepath.Join(dir, tt[i].File) tt[i], err = load(fsys, p) if err != nil { return nil, err } } if len(tt[i].Teams) > 0 { var err error tt[i].Teams, err = loadTeams(fsys, dir, tt[i].Teams) if err != nil { return nil, err } for j := range tt[i].Teams { tt[i].Teams[j].parent = tt[i] } } } return tt, nil } func normalize(t *Team) { // Ensure all user names are lower case. for i := range t.Leads { t.Leads[i] = strings.ToLower(t.Leads[i]) } for i := range t.Members { t.Members[i] = strings.ToLower(t.Members[i]) } } func dedupe(s []string) []string { sort.Strings(s) j := 0 for i := 1; i < len(s); i++ { if s[j] == s[i] { continue } j++ s[j] = s[i] } result := s[:j+1] return result } func isUniq(s []string) error { sort.Strings(s) j := 0 for i := 1; i < len(s); i++ { if s[j] == s[i] { return fmt.Errorf("%s appears twice", s[j]) } } return nil }