diff --git a/.github/workflows/sync-oncall.yml b/.github/workflows/sync-oncall.yml index 8dade9e..97c4c71 100644 --- a/.github/workflows/sync-oncall.yml +++ b/.github/workflows/sync-oncall.yml @@ -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 diff --git a/synconcall/SLACK_SETUP.md b/synconcall/SLACK_SETUP.md index 3bc0f19..f445179 100644 --- a/synconcall/SLACK_SETUP.md +++ b/synconcall/SLACK_SETUP.md @@ -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`. @@ -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 ``` diff --git a/synconcall/go.mod b/synconcall/go.mod index 037b8e4..0c4bb4c 100644 --- a/synconcall/go.mod +++ b/synconcall/go.mod @@ -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 @@ -17,7 +21,6 @@ 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 @@ -25,7 +28,6 @@ require ( 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 diff --git a/synconcall/go.sum b/synconcall/go.sum index 4683f73..4099489 100644 --- a/synconcall/go.sum +++ b/synconcall/go.sum @@ -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= diff --git a/synconcall/main.go b/synconcall/main.go index 7a9ce62..23b018f 100644 --- a/synconcall/main.go +++ b/synconcall/main.go @@ -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") @@ -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 != "" { @@ -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") } @@ -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") } @@ -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 @@ -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", @@ -140,12 +184,13 @@ 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) @@ -153,17 +198,15 @@ func runSync(configPath string, groupEmail string, client GroupsClient) error { 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= [--group= --admin-user=] [--slack-group= --slack-token=] + synconcall --config= [--group= --admin-user=] [--slack-group= --slack-token=] [--slack-channel=] Required Flags: --config string @@ -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 diff --git a/synconcall/slack.go b/synconcall/slack.go index 41a32bf..56f859e 100644 --- a/synconcall/slack.go +++ b/synconcall/slack.go @@ -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 +}