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
3 changes: 2 additions & 1 deletion .github/workflows/sync-oncall.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ jobs:
cd synconcall
./synconcall --config=../dev.oncall \
--group=dev-oncall@bytebase.com --admin-user=d@bytebase.com \
--slack-group=S0AAPDZBNQL
--slack-group=S0AAPDZBNQL \
--slack-channel=C08CMEAP63T
22 changes: 21 additions & 1 deletion synconcall/SLACK_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ To sync on-call rotations to a Slack User Group, you need a **Slack User OAuth T
- `usergroups:write` (To add/remove members)
- `users:read` (To look up user details)
- `users:read.email` (To look up users by email)
- `chat:write` (To post notification messages)

> [!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`.
Expand All @@ -32,11 +33,30 @@ To sync on-call rotations to a Slack User Group, you need a **Slack User OAuth T
- Allow the permissions.
- Copy the **User OAuth Token**. It should start with `xoxp-`.

## Finding IDs

### Finding User Group ID
1. Open Slack on desktop.
2. Go to **People & User Groups** in the sidebar.
3. Click on the desired User Group (e.g., `@dev-oncall`).
4. Click the **...** (three dots) menu near the top right of the group card.
5. Select **Copy ID**. It usually starts with `S` (e.g., `S0123456789`).

### Finding Channel ID
1. Open the desired channel in Slack.
2. Click on the **channel name** in the header to open details.
3. Scroll to the bottom of the "About" tab.
4. You will see **Channel ID**. It usually starts with `C` (e.g., `C0123456789`).

## 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
# Sync + Notify
go run ./synconcall \
--config=dev.oncall \
--slack-group=S0AAPDZBNQL \
--slack-channel=C012345ABC
```
8 changes: 5 additions & 3 deletions synconcall/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ module github.com/bytebase/oncall/synconcall

go 1.25.6

require google.golang.org/api v0.262.0
require (
github.com/slack-go/slack v0.17.3
golang.org/x/oauth2 v0.34.0
google.golang.org/api v0.262.0
)

require (
cloud.google.com/go/auth v0.18.1 // indirect
Expand All @@ -17,15 +21,13 @@ require (
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
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120174246-409b4a993575 // indirect
Expand Down
2 changes: 2 additions & 0 deletions synconcall/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
Expand Down
83 changes: 64 additions & 19 deletions synconcall/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func main() {
// 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)")
slackChannel := flag.String("slack-channel", "", "Slack Channel ID to notify on changes")

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

Expand All @@ -42,6 +43,22 @@ func main() {
}

syncPerformed := false
anyChanges := false
var currentRotation *Rotation

// Initialize Slack Client if needed (for sync or notifications)
var slackClient *SlackClient
token := *slackToken
if token == "" {
token = os.Getenv("SLACK_TOKEN")
}

if token != "" {
slackClient = NewSlackClient(token)
} else if *slackGroup != "" || *slackChannel != "" {
fmt.Fprintf(os.Stderr, "Error: Slack token is required via --slack-token or SLACK_TOKEN env var for Slack sync or notifications\n")
os.Exit(1)
}

// Google Group Sync
if *groupEmail != "" {
Expand Down Expand Up @@ -77,10 +94,16 @@ func main() {
os.Exit(1)
}

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

fmt.Printf("--- Google Group Sync Completed ---\n\n")
}

Expand All @@ -89,20 +112,17 @@ func main() {
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)
// slackClient is already initialized above

if err := runSync(*configPath, *slackGroup, slackClient); err != nil {
changed, rot, err := runSync(*configPath, *slackGroup, slackClient)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: Slack sync failed - %v\n", err)
os.Exit(1)
}
if changed {
anyChanges = true
}
currentRotation = rot // Either sync source provides valid rotation
fmt.Printf("--- Slack Sync Completed ---\n\n")
}

Expand All @@ -111,9 +131,33 @@ func main() {
printUsage()
os.Exit(1)
}

// Send notification if configured and changes were made
if anyChanges && slackClient != nil && *slackChannel != "" && currentRotation != nil {

fmt.Printf("--- Sending Notification ---\n")

primaryMention := currentRotation.Primary
if id, err := slackClient.GetUserIDByEmail(primaryMention); err == nil {
primaryMention = fmt.Sprintf("<@%s>", id)
}

secondaryMention := currentRotation.Secondary
if id, err := slackClient.GetUserIDByEmail(secondaryMention); err == nil {
secondaryMention = fmt.Sprintf("<@%s>", id)
}

msg := fmt.Sprintf("On-call rotation update.\n\nCurrent on-call:\n• Primary: %s\n• Secondary: %s",
primaryMention, secondaryMention)

fmt.Printf("Sending notification to channel %s...\n", *slackChannel)
if err := slackClient.PostMessage(*slackChannel, msg); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to send Slack notification: %v\n", err)
}
}
}

func runSync(configPath string, groupEmail string, client GroupsClient) error {
func runSync(configPath string, groupEmail string, client GroupsClient) (bool, *Rotation, error) {
fmt.Printf("Reading schedule from: %s\n", configPath)

// Get current time
Expand All @@ -122,12 +166,12 @@ func runSync(configPath string, groupEmail string, client GroupsClient) error {
// Parse schedule and find current rotation
rotations, err := ParseSchedule(configPath)
if err != nil {
return err
return false, nil, err
}

currentRotation, err := FindCurrentRotation(rotations, now)
if err != nil {
return err
return false, nil, err
}

fmt.Printf("Current rotation: %s - Primary: %s, Secondary: %s\n",
Expand All @@ -140,30 +184,29 @@ func runSync(configPath string, groupEmail string, client GroupsClient) error {

result, err := Sync(groupEmail, configPath, client, now)
if err != nil {
return err
return false, nil, err
}

// Report results
if len(result.Removed) == 0 && len(result.Added) == 0 {
fmt.Println(" No changes needed.")
return false, currentRotation, nil
} else {
for _, member := range result.Removed {
fmt.Printf(" Removed: %s\n", member)
}
for _, member := range result.Added {
fmt.Printf(" Added: %s\n", member)
}
return true, currentRotation, nil
}

fmt.Println("Sync completed successfully.")
return nil
}

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

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

Required Flags:
--config string
Expand All @@ -181,6 +224,8 @@ Slack Flags:
Slack User Group ID to sync
--slack-token string
Slack API Token (can also be set via SLACK_TOKEN env var)
--slack-channel string
Slack Channel ID to notify on changes

Optional Flags:
--help
Expand Down
18 changes: 18 additions & 0 deletions synconcall/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,21 @@ func (c *SlackClient) RemoveMember(groupID, memberEmail string) error {

return nil
}

// PostMessage sends a message to a Slack channel
func (c *SlackClient) PostMessage(channelID, message string) error {
_, _, err := c.client.PostMessage(channelID, slack.MsgOptionText(message, false))
if err != nil {
return fmt.Errorf("failed to post message to slack channel %s: %w", channelID, err)
}
return nil
}

// GetUserIDByEmail resolves an email to a Slack User ID
func (c *SlackClient) GetUserIDByEmail(email string) (string, error) {
user, err := c.client.GetUserByEmail(email)
if err != nil {
return "", fmt.Errorf("failed to get user by email %s: %w", email, err)
}
return user.ID, nil
}
Loading