Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/sync-oncall.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
10 changes: 10 additions & 0 deletions github-users.csv
Original file line number Diff line number Diff line change
@@ -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
183 changes: 183 additions & 0 deletions synconcall/github.go
Original file line number Diff line number Diff line change
@@ -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
}
65 changes: 65 additions & 0 deletions synconcall/github_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
64 changes: 63 additions & 1 deletion synconcall/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
Loading