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
5 changes: 4 additions & 1 deletion .github/workflows/sync-oncall.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ jobs:
- name: Sync oncall rotation
env:
GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_SERVICE_ACCOUNT }}
SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
run: |
cd synconcall
./synconcall --config=../dev.oncall --group=dev-oncall@bytebase.com --admin-user=d@bytebase.com
./synconcall --config=../dev.oncall \
--group=dev-oncall@bytebase.com --admin-user=d@bytebase.com \
--slack-group=S0AAPDZBNQL
42 changes: 42 additions & 0 deletions synconcall/SLACK_SETUP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Slack Sync Setup Guide

To sync on-call rotations to a Slack User Group, you need a **Slack User OAuth Token**.

## Prerequisites
- You must be a **Workspace Admin** or **Owner** to manage User Groups.
- Your workspace must be on a paid Slack plan (Standard/Plus/Enterprise) to use User Groups.

## Steps to Create Token

1. **Create a Slack App**
- Go to [Slack API: Your Apps](https://api.slack.com/apps).
- Click **Create New App**.
- Select **From scratch**.
- Name it `On-Call Sync` and select your workspace.

2. **Configure Scopes**
- In the left sidebar, click **OAuth & Permissions**.
- Scroll down to **User Token Scopes** (NOT Bot Token Scopes).
- Add the following scopes:
- `usergroups:read` (To list group members)
- `usergroups:write` (To add/remove members)
- `users:read` (To look up user details)
- `users:read.email` (To look up users by email)

> [!IMPORTANT]
> **DO NOT** select scopes starting with `admin.` (e.g., `admin.usergroups:write`). These require an Enterprise Grid plan and will cause installation errors. Ensure you select the standard `usergroups:write`.

3. **Install App & Get Token**
- Scroll up to **OAuth Tokens for Your Workspace**.
- Click **Install to Workspace**.
- Allow the permissions.
- Copy the **User OAuth Token**. It should start with `xoxp-`.

## Usage

Run the tool with your new token:

```bash
export SLACK_TOKEN="xoxp-your-token-here"
go run ./synconcall --config=dev.oncall --slack-group=S0AAPDZBNQL
```
2 changes: 2 additions & 0 deletions synconcall/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/slack-go/slack v0.17.3 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
Expand Down
4 changes: 4 additions & 0 deletions synconcall/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,12 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dq
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
Expand Down
126 changes: 83 additions & 43 deletions synconcall/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ func main() {

// Define flags
configPath := flag.String("config", "", "Path to the oncall schedule file (required)")
groupEmail := flag.String("group", "", "Google Group email address to sync (required)")
adminUser := flag.String("admin-user", "", "Domain admin user email for impersonation (required)")
// Google Group flags
groupEmail := flag.String("group", "", "Google Group email address to sync")
adminUser := flag.String("admin-user", "", "Domain admin user email for impersonation (Google Group only)")
// Slack flags
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)")

showHelp := flag.Bool("help", false, "Show usage information")

flag.Parse()
Expand All @@ -36,44 +41,74 @@ func main() {
os.Exit(1)
}

if *groupEmail == "" {
fmt.Fprintf(os.Stderr, "Error: --group flag is required\n\n")
printUsage()
os.Exit(1)
}
syncPerformed := false

if *adminUser == "" {
fmt.Fprintf(os.Stderr, "Error: --admin-user flag is required\n\n")
printUsage()
os.Exit(1)
}
// Google Group Sync
if *groupEmail != "" {
syncPerformed = true
fmt.Println("--- Starting Google Group Sync ---")

// Get credentials from environment
credentialsJSON := os.Getenv("GOOGLE_CREDENTIALS")
if credentialsJSON == "" {
fmt.Fprintf(os.Stderr, "Error: GOOGLE_CREDENTIALS environment variable not set\n")
fmt.Fprintf(os.Stderr, "Please set it to your Google service account JSON key content.\n")
os.Exit(1)
}
if *adminUser == "" {
fmt.Fprintf(os.Stderr, "Error: --admin-user flag is required for Google Group sync\n\n")
printUsage()
os.Exit(1)
}

// Validate credentials format
if err := ValidateCredentials([]byte(credentialsJSON)); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
// Get credentials from environment
credentialsJSON := os.Getenv("GOOGLE_CREDENTIALS")
if credentialsJSON == "" {
fmt.Fprintf(os.Stderr, "Error: GOOGLE_CREDENTIALS environment variable not set\n")
fmt.Fprintf(os.Stderr, "Please set it to your Google service account JSON key content.\n")
os.Exit(1)
}

// Validate credentials format
if err := ValidateCredentials([]byte(credentialsJSON)); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}

// Create Google Groups client
ctx := context.Background()
googleClient, err := NewGoogleGroupsClient(ctx, []byte(credentialsJSON), *adminUser)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: authentication failed - %v\n", err)
fmt.Fprintf(os.Stderr, "Please check that your service account has domain-wide delegation enabled.\n")
os.Exit(1)
}

if err := runSync(*configPath, *groupEmail, googleClient); err != nil {
fmt.Fprintf(os.Stderr, "Error: Google Group sync failed - %v\n", err)
os.Exit(1)
}
fmt.Printf("--- Google Group Sync Completed ---\n\n")
}

// Create Google Groups client
ctx := context.Background()
client, err := NewGoogleGroupsClient(ctx, []byte(credentialsJSON), *adminUser)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: authentication failed - %v\n", err)
fmt.Fprintf(os.Stderr, "Please check that your service account has domain-wide delegation enabled.\n")
os.Exit(1)
// Slack Sync
if *slackGroup != "" {
syncPerformed = true
fmt.Println("--- Starting Slack Sync ---")

token := *slackToken
if token == "" {
token = os.Getenv("SLACK_TOKEN")
}
if token == "" {
fmt.Fprintf(os.Stderr, "Error: Slack token is required via --slack-token or SLACK_TOKEN env var\n")
os.Exit(1)
}
slackClient := NewSlackClient(token)

if err := runSync(*configPath, *slackGroup, slackClient); err != nil {
fmt.Fprintf(os.Stderr, "Error: Slack sync failed - %v\n", err)
os.Exit(1)
}
fmt.Printf("--- Slack Sync Completed ---\n\n")
}

// Run sync
if err := runSync(*configPath, *groupEmail, client); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
if !syncPerformed {
fmt.Fprintf(os.Stderr, "Error: No sync target specified. Provide --group (Google Groups) or --slack-group (Slack) or both.\n\n")
printUsage()
os.Exit(1)
}
}
Expand Down Expand Up @@ -125,35 +160,40 @@ func runSync(configPath string, groupEmail string, client GroupsClient) error {
}

func printUsage() {
fmt.Fprintf(os.Stderr, `synconcall - Sync oncall rotation to Google Group
fmt.Fprintf(os.Stderr, `synconcall - Sync oncall rotation to Google Group or Slack User Group

Usage:
synconcall --config=<path> --group=<email> --admin-user=<email>
synconcall --config=<path> [--group=<email> --admin-user=<email>] [--slack-group=<id> --slack-token=<token>]

Required Flags:
--config string
Path to the oncall schedule file (CSV format)

Google Group Flags:
--group string
Google Group email address to sync
--admin-user string
Domain admin email for service account impersonation
(Requires GOOGLE_CREDENTIALS environment variable)

Slack Flags:
--slack-group string
Slack User Group ID to sync
--slack-token string
Slack API Token (can also be set via SLACK_TOKEN env var)

Optional Flags:
--help
Show this usage information

Environment Variables:
GOOGLE_CREDENTIALS (required)
Google service account JSON key content

Examples:
# GitHub Actions
# Google Groups (GitHub Actions)
GOOGLE_CREDENTIALS="${{ secrets.GOOGLE_SERVICE_ACCOUNT }}" \
synconcall --config=dev.oncall --group=dev-oncall@bytebase.com --admin-user=admin@bytebase.com

# Local testing
GOOGLE_CREDENTIALS="$(cat service-account.json)" \
synconcall --config=dev.oncall --group=dev-oncall@bytebase.com --admin-user=admin@bytebase.com
# Slack (GitHub Actions)
SLACK_TOKEN="${{ secrets.SLACK_TOKEN }}" \
synconcall --config=dev.oncall --slack-group=S0123456789

Schedule File Format:
CSV format with 3 columns: timestamp,primary_email,secondary_email
Expand Down
134 changes: 134 additions & 0 deletions synconcall/slack.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package main

import (
"fmt"
"strings"

"github.com/slack-go/slack"
)

// SlackClient implements GroupsClient using Slack API
type SlackClient struct {
client *slack.Client
}

// NewSlackClient creates a new Slack client
func NewSlackClient(token string) *SlackClient {
return &SlackClient{
client: slack.New(token),
}
}

// ListMembers retrieves all member email addresses from a Slack User Group
func (c *SlackClient) ListMembers(groupID string) ([]string, error) {
// Get members IDs of the group
memberIDs, err := c.client.GetUserGroupMembers(groupID)
if err != nil {
return nil, fmt.Errorf("failed to list slack group members: %w", err)
}

if len(memberIDs) == 0 {
return []string{}, nil
}

// To translate IDs to emails efficiently, it's often better to fetch all users
// if the group is large, or fetch individually if small.
// For robustness, let's fetch all users to build a map.
// Note: In very large workspaces, this might be slow.
// An alternative is to use GetUserInfo for each member if count is small.
// Let's assume on-call groups are relatively small (< 20 people), so fetching info one by one
// might actually be faster than fetching 10k users.
// However, Sync() calls `ListMembers` once.
// Let's try fetching info individually for now as it's safer for large workspaces,
// unless we hit rate limits. Parallelism could help here but let's keep it simple.

var emails []string
for _, id := range memberIDs {
user, err := c.client.GetUserInfo(id)
if err != nil {
return nil, fmt.Errorf("failed to get user info for %s: %w", id, err)
}
// Skip bots or deleted users if necessary? Usually on-call are real users.
if user.IsBot || user.Deleted {
continue
}
emails = append(emails, user.Profile.Email)
}

return emails, nil
}

// AddMember adds a member to a Slack User Group
func (c *SlackClient) AddMember(groupID, memberEmail string) error {
// 1. Resolve email to User ID
user, err := c.client.GetUserByEmail(memberEmail)
if err != nil {
return fmt.Errorf("failed to resolve email %s to slack user: %w", memberEmail, err)
}

// 2. Get current members
currentMemberIDs, err := c.client.GetUserGroupMembers(groupID)
if err != nil {
return fmt.Errorf("failed to get current group members: %w", err)
}

// 3. Check if already present
for _, id := range currentMemberIDs {
if id == user.ID {
return nil // Already a member
}
}

// 4. Add to list
newMemberIDs := append(currentMemberIDs, user.ID)

// 5. Update group
_, err = c.client.UpdateUserGroupMembers(groupID, strings.Join(newMemberIDs, ","))
if err != nil {
return fmt.Errorf("failed to update slack group members: %w", err)
}

return nil
}

// RemoveMember removes a member from a Slack User Group
func (c *SlackClient) RemoveMember(groupID, memberEmail string) error {
// 1. Resolve email to User ID
user, err := c.client.GetUserByEmail(memberEmail)
if err != nil {
return fmt.Errorf("failed to resolve email %s to slack user: %w", memberEmail, err)
}

// 2. Get current members
currentMemberIDs, err := c.client.GetUserGroupMembers(groupID)
if err != nil {
return fmt.Errorf("failed to get current group members: %w", err)
}

// 3. Remove from list
newMemberIDs := make([]string, 0, len(currentMemberIDs))
found := false
for _, id := range currentMemberIDs {
if id == user.ID {
found = true
continue
}
newMemberIDs = append(newMemberIDs, id)
}

if !found {
return nil // Not a member
}

// 4. Update group
// Slack requires at least one member?
// If newMemberIDs is empty, this call might fail if Slack disables empty groups?
// The API doc doesn't explicitly forbid empty groups but the behavior might vary.
// Let's assume it's allowed or the group isn't meant to be empty.
_, err = c.client.UpdateUserGroupMembers(groupID, strings.Join(newMemberIDs, ","))
if err != nil {
return fmt.Errorf("failed to update slack group members: %w", err)
}

return nil
}
Loading