diff --git a/.github/workflows/sync-oncall.yml b/.github/workflows/sync-oncall.yml index 2a0a1a6..eebb1d7 100644 --- a/.github/workflows/sync-oncall.yml +++ b/.github/workflows/sync-oncall.yml @@ -1,4 +1,4 @@ -name: Sync Oncall to Google Group and Slack Group +name: Sync Oncall to Google Group, Slack Group, and GitHub Team on: push: @@ -29,9 +29,12 @@ jobs: env: GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_SERVICE_ACCOUNT }} SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }} + GH_TOKEN: ${{ secrets.GHA_DISPATCH_TOKEN }} run: | cd synconcall ./synconcall --config=../dev.oncall \ --group=dev-oncall@bytebase.com --admin-user=d@bytebase.com \ --slack-group=S0AAPDZBNQL \ - --slack-channel=C08CMEAP63T + --slack-channel=C08CMEAP63T \ + --github-org=bytebase --github-team=bytebase-oncall \ + --github-users=../github-users.csv diff --git a/github-users.csv b/github-users.csv new file mode 100644 index 0000000..9bb4d47 --- /dev/null +++ b/github-users.csv @@ -0,0 +1,10 @@ +# Maps oncall emails (used in *.oncall schedules) to GitHub usernames. +# Used by synconcall to sync the GitHub org team (e.g. bytebase-oncall). +# Format: email,github_username +d@bytebase.com,d-bytebase +xz@bytebase.com,RainbowDashy +zp@bytebase.com,h3n4l +vh@bytebase.com,vsai12 +jy@bytebase.com,rebelice +ed@bytebase.com,ecmadao +sl@bytebase.com,boojack diff --git a/synconcall/github.go b/synconcall/github.go new file mode 100644 index 0000000..1769fbc --- /dev/null +++ b/synconcall/github.go @@ -0,0 +1,183 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" +) + +const githubAPIBase = "https://api.github.com" + +// GitHubTeamClient implements GroupsClient for a GitHub organization team. +// +// The rest of the tool works in terms of oncall emails, but GitHub teams are +// keyed by username. This client holds an email <-> username mapping (loaded +// from a CSV file) and translates between the two, mirroring how SlackClient +// translates email <-> Slack user ID. +type GitHubTeamClient struct { + org string + token string + http *http.Client + emailToUser map[string]string + userToEmail map[string]string +} + +// NewGitHubTeamClient creates a client for the given org using the supplied +// token and email->username mapping. +func NewGitHubTeamClient(org, token string, emailToUser map[string]string) *GitHubTeamClient { + userToEmail := make(map[string]string, len(emailToUser)) + for email, user := range emailToUser { + userToEmail[strings.ToLower(user)] = email + } + return &GitHubTeamClient{ + org: org, + token: token, + http: &http.Client{}, + emailToUser: emailToUser, + userToEmail: userToEmail, + } +} + +// LoadUserMapping reads an email,github_username CSV file into a map. +// Blank lines and lines starting with '#' are ignored. +func LoadUserMapping(path string) (map[string]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open user mapping %s: %w", path, err) + } + defer file.Close() + + mapping := make(map[string]string) + scanner := bufio.NewScanner(file) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.Split(line, ",") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid mapping on line %d: expected email,github_username", lineNo) + } + email := strings.TrimSpace(parts[0]) + user := strings.TrimSpace(parts[1]) + if email == "" || user == "" { + return nil, fmt.Errorf("invalid mapping on line %d: empty field", lineNo) + } + mapping[email] = user + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("failed to read user mapping %s: %w", path, err) + } + return mapping, nil +} + +// usernameFor resolves an oncall email to a GitHub username. If the value is +// already a username (returned by ListMembers for an unmapped member), it is +// returned unchanged. +func (c *GitHubTeamClient) usernameFor(member string) string { + if user, ok := c.emailToUser[member]; ok { + return user + } + return member +} + +// ListMembers returns the team members keyed by email when known, falling back +// to the raw GitHub username for members not present in the mapping (so the +// team is fully reconciled to the current rotation). +func (c *GitHubTeamClient) ListMembers(teamSlug string) ([]string, error) { + var members []string + page := 1 + for { + path := fmt.Sprintf("/orgs/%s/teams/%s/members?per_page=100&page=%d", c.org, teamSlug, page) + var batch []struct { + Login string `json:"login"` + } + if err := c.do(http.MethodGet, path, &batch); err != nil { + return nil, fmt.Errorf("failed to list team members: %w", err) + } + if len(batch) == 0 { + break + } + for _, m := range batch { + if email, ok := c.userToEmail[strings.ToLower(m.Login)]; ok { + members = append(members, email) + } else { + members = append(members, m.Login) + } + } + page++ + } + return members, nil +} + +// AddMember adds (or affirms) a member's team membership. Sync only ever asks +// to add desired-state oncall emails, so the email must be present in the +// mapping. +func (c *GitHubTeamClient) AddMember(teamSlug, member string) error { + username, ok := c.emailToUser[member] + if !ok { + return fmt.Errorf("no github username mapped for %s", member) + } + path := fmt.Sprintf("/orgs/%s/teams/%s/memberships/%s", c.org, teamSlug, username) + body := strings.NewReader(`{"role":"member"}`) + if err := c.doBody(http.MethodPut, path, body, nil); err != nil { + return fmt.Errorf("failed to add member %s: %w", username, err) + } + return nil +} + +// RemoveMember removes a member from the team. +func (c *GitHubTeamClient) RemoveMember(teamSlug, member string) error { + username := c.usernameFor(member) + path := fmt.Sprintf("/orgs/%s/teams/%s/memberships/%s", c.org, teamSlug, username) + if err := c.doBody(http.MethodDelete, path, nil, nil); err != nil { + return fmt.Errorf("failed to remove member %s: %w", username, err) + } + return nil +} + +func (c *GitHubTeamClient) do(method, path string, out any) error { + return c.doBody(method, path, nil, out) +} + +func (c *GitHubTeamClient) doBody(method, path string, body *strings.Reader, out any) error { + var reqBody *strings.Reader + if body != nil { + reqBody = body + } else { + reqBody = strings.NewReader("") + } + req, err := http.NewRequest(method, githubAPIBase+path, reqBody) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var apiErr struct { + Message string `json:"message"` + } + _ = json.NewDecoder(resp.Body).Decode(&apiErr) + return fmt.Errorf("github API %s %s: %d %s", method, path, resp.StatusCode, apiErr.Message) + } + + if out != nil { + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + } + return nil +} diff --git a/synconcall/github_test.go b/synconcall/github_test.go new file mode 100644 index 0000000..3f4279d --- /dev/null +++ b/synconcall/github_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadUserMapping(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "users.csv") + content := `# comment line +d@bytebase.com,d-bytebase + +xz@bytebase.com, RainbowDashy +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + mapping, err := LoadUserMapping(path) + if err != nil { + t.Fatalf("LoadUserMapping: %v", err) + } + if got, want := len(mapping), 2; got != want { + t.Fatalf("mapping size = %d, want %d", got, want) + } + if got := mapping["d@bytebase.com"]; got != "d-bytebase" { + t.Errorf("d@bytebase.com -> %q, want d-bytebase", got) + } + if got := mapping["xz@bytebase.com"]; got != "RainbowDashy" { + t.Errorf("xz@bytebase.com -> %q, want RainbowDashy (trimmed)", got) + } +} + +func TestLoadUserMappingInvalid(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.csv") + if err := os.WriteFile(path, []byte("only-one-field\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadUserMapping(path); err == nil { + t.Fatal("expected error for malformed mapping, got nil") + } +} + +func TestGitHubTeamClientReverseMap(t *testing.T) { + c := NewGitHubTeamClient("bytebase", "token", map[string]string{ + "d@bytebase.com": "d-bytebase", + "xz@bytebase.com": "RainbowDashy", + }) + + // usernameFor resolves a mapped email and passes through a raw login. + if got := c.usernameFor("d@bytebase.com"); got != "d-bytebase" { + t.Errorf("usernameFor(email) = %q, want d-bytebase", got) + } + if got := c.usernameFor("someone-manual"); got != "someone-manual" { + t.Errorf("usernameFor(login) = %q, want passthrough", got) + } + + // Reverse map is case-insensitive on the GitHub login. + if got := c.userToEmail["rainbowdashy"]; got != "xz@bytebase.com" { + t.Errorf("userToEmail[rainbowdashy] = %q, want xz@bytebase.com", got) + } +} diff --git a/synconcall/main.go b/synconcall/main.go index 23b018f..2025665 100644 --- a/synconcall/main.go +++ b/synconcall/main.go @@ -24,6 +24,11 @@ func main() { slackGroup := flag.String("slack-group", "", "Slack User Group ID to sync") slackToken := flag.String("slack-token", "", "Slack API Token (can also be set via SLACK_TOKEN env var)") slackChannel := flag.String("slack-channel", "", "Slack Channel ID to notify on changes") + // GitHub team flags + githubOrg := flag.String("github-org", "", "GitHub organization that owns the team to sync") + githubTeam := flag.String("github-team", "", "GitHub team slug to sync (e.g. bytebase-oncall)") + githubUsers := flag.String("github-users", "", "Path to the email,github_username mapping CSV") + githubToken := flag.String("github-token", "", "GitHub token (can also be set via GH_TOKEN or GITHUB_TOKEN env var)") showHelp := flag.Bool("help", false, "Show usage information") @@ -126,8 +131,55 @@ func main() { fmt.Printf("--- Slack Sync Completed ---\n\n") } + // GitHub Team Sync + if *githubTeam != "" { + syncPerformed = true + fmt.Println("--- Starting GitHub Team Sync ---") + + if *githubOrg == "" { + fmt.Fprintf(os.Stderr, "Error: --github-org flag is required for GitHub team sync\n\n") + printUsage() + os.Exit(1) + } + if *githubUsers == "" { + fmt.Fprintf(os.Stderr, "Error: --github-users flag is required for GitHub team sync\n\n") + printUsage() + os.Exit(1) + } + + ghToken := *githubToken + if ghToken == "" { + ghToken = os.Getenv("GH_TOKEN") + } + if ghToken == "" { + ghToken = os.Getenv("GITHUB_TOKEN") + } + if ghToken == "" { + fmt.Fprintf(os.Stderr, "Error: GitHub token is required via --github-token, GH_TOKEN, or GITHUB_TOKEN env var\n") + os.Exit(1) + } + + mapping, err := LoadUserMapping(*githubUsers) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + githubClient := NewGitHubTeamClient(*githubOrg, ghToken, mapping) + changed, rot, err := runSync(*configPath, *githubTeam, githubClient) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: GitHub team sync failed - %v\n", err) + os.Exit(1) + } + if changed { + anyChanges = true + } + currentRotation = rot + fmt.Printf("--- GitHub Team Sync Completed ---\n\n") + } + if !syncPerformed { - fmt.Fprintf(os.Stderr, "Error: No sync target specified. Provide --group (Google Groups) or --slack-group (Slack) or both.\n\n") + fmt.Fprintf(os.Stderr, "Error: No sync target specified. Provide --group (Google Groups), --slack-group (Slack), or --github-team (GitHub).\n\n") printUsage() os.Exit(1) } @@ -227,6 +279,16 @@ Slack Flags: --slack-channel string Slack Channel ID to notify on changes +GitHub Team Flags: + --github-org string + GitHub organization that owns the team + --github-team string + GitHub team slug to sync (e.g. bytebase-oncall) + --github-users string + Path to the email,github_username mapping CSV + --github-token string + GitHub token (can also be set via GH_TOKEN or GITHUB_TOKEN env var) + Optional Flags: --help Show this usage information