From c1753c241d633441483cda75c0039431fd64b006 Mon Sep 17 00:00:00 2001 From: Alexander Didenko Date: Wed, 12 Feb 2025 16:25:37 +0100 Subject: [PATCH 1/3] feat: updates and features New features and updating base images + go ver --- Dockerfile | 6 +- README.md | 16 ++++ fixtures/config.yaml | 21 +++++ internal/cfg/cfg.go | 72 ++++++++++++++- internal/cfg/cfg_test.go | 62 +++++++++++++ internal/gh/gh.go | 27 ++++++ internal/gh/gh_test.go | 68 ++++++++++++++ internal/slack/slack.go | 189 +++++++++++++++++++++++++++++++++++++++ main.go | 104 ++++++++++++++++++--- 9 files changed, 550 insertions(+), 15 deletions(-) create mode 100644 internal/gh/gh_test.go diff --git a/Dockerfile b/Dockerfile index 7a7405d..4cd1f68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # Simple tool to watch directory for new files and upload them to S3 # -FROM golang:1.23.6 AS test +FROM golang:1.24.6 AS test WORKDIR /build ENV GOPATH=/go ENV PATH="$PATH:$GOPATH/bin" @@ -20,8 +20,8 @@ ENV GOPATH=/go ENV PATH="$PATH:$GOPATH/bin" RUN make build -# FROM gcr.io/distroless/base-debian11 -FROM alpine:3.21 +FROM alpine:3.22 WORKDIR / +RUN apk add --no-cache tzdata COPY --from=build /build/output/pr-notify /pr-notify ENTRYPOINT ["/pr-notify"] diff --git a/README.md b/README.md index 04aa06b..0482a2a 100644 --- a/README.md +++ b/README.md @@ -48,3 +48,19 @@ github_pr_notifications: channel_id: "DEF456000" message_header: ":warning: Please review at your earliest convenience @some-group-handle" ``` + +## Local development + +Build an image: + +```bash +docker build . --tag pr-notify:latest +``` + +Prepare env variables in `.env` file and run the image: + +```bash +docker run --rm -it --env GITHUB_APP_PRIVATE_KEY="$(cat /path/pr-notify.private-key.pem)" \ + --env-file .env -v .config.yaml:/etc/pr-notify.yaml pr-notify:latest \ + -logtostderr -v 8 +``` diff --git a/fixtures/config.yaml b/fixtures/config.yaml index 2011b3b..12875a9 100644 --- a/fixtures/config.yaml +++ b/fixtures/config.yaml @@ -1,3 +1,22 @@ +slack_config: + # Where to find users to match them to GH users, we'll build a mapping of + # GitHub users to Slack users based on this. This is needed for slack DM + # notifications for assigned and unattended PRs. + github_users_channels: + - id: C01234567 # some-channel + periodic_notifications: + notify_users: true # Whether to periodically notify users without GH info in this channel + schedule: "CRON_TZ=Europe/Berlin 00 11 * * 1-5" # schedule to ping users without GH info + message: | + This channel requires users to have GH login configured. + Please update your Slack profile with your GitHub username and URL. + message_reply_notifications: + notify_users: true # Whether to notify users without GH info when they post PR in the channel + message_regex: ".*https://github.com/impossiblecloud/infrastructure/pull/.*" + reply: "Please update your Slack profile with Github account info." + reply_in_thread: true + slack_users_pull_interval_seconds: 600 # how often to pull info from Slack and cache in memory + github_pr_notifications: - gh_owner: my-org @@ -7,6 +26,8 @@ github_pr_notifications: gh_pr_include_drafts: true gh_pr_ignore_approved: true gh_pr_ignore_changes_requested: true + gh_pr_conditions: + older_than_seconds: 3600 # Mon-Fri every 2 hours during business hours schedule: "CRON_TZ=Europe/Berlin 00 10-18/2 * * 1-5" notify: diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index 4bee288..f4973f6 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -7,21 +7,67 @@ import ( "gopkg.in/yaml.v3" ) +const ( + defaultGithubCustomFieldPattern = "github.com" + defaultSlackUsersPullInterval = 3600 +) + // AppConfig is the main app runtime config type AppConfig struct { Metrics metrics.AppMetrics PrNotifications []PrNotification + SlackConfig SlackConfig + + SlackToGithubUserMap map[string]string // Map of Slack user IDs to GitHub logins +} + +// UserMapping is a struct for mapping GitHub users to other notification channels +type UserMapping struct { + GithubLogin string `yaml:"github_login"` + SlackUID string `yaml:"slack_uid"` } // appConfigFile is the main app config file type appConfigFile struct { PrNotifications []PrNotification `yaml:"github_pr_notifications"` + PrUserMappings []UserMapping `yaml:"github_user_mappings"` + SlackConfig SlackConfig `yaml:"slack_config"` +} + +// SlackConfig is the configuration for Slack integration +type SlackConfig struct { + GithubUsersChannels []GHUsersChannel `yaml:"github_users_channels"` + GithubCustomFieldPattern string `yaml:"github_custom_field_pattern"` + SlackUsersPullIntervalSeconds int `yaml:"slack_users_pull_interval_seconds"` +} + +// GHUsersChannel struct describes a GitHub users channel mapping +type GHUsersChannel struct { + ID string `yaml:"id"` + PeriodicNotifications PeriodicNotifications `yaml:"periodic_notifications"` + MessageReplyNotifications MessageReplyNotifications `yaml:"message_reply_notifications"` +} + +// PeriodicNotifications struct describes the periodic notification settings for users without GH info +type PeriodicNotifications struct { + NotifyUsers bool `yaml:"notify_users"` + Schedule string `yaml:"schedule"` + Message string `yaml:"message"` +} + +// MessageReplyNotifications struct describes the message reply notification settings +type MessageReplyNotifications struct { + NotifyUsers bool `yaml:"notify_users"` + MessageRegex string `yaml:"message_regex"` + Reply string `yaml:"reply"` + ReplyInThread bool `yaml:"reply_in_thread"` } // SlackNotification struct describes slack notification config type SlackNotification struct { - ChannelID string `yaml:"channel_id"` - Header string `yaml:"message_header"` + ChannelID string `yaml:"channel_id"` + Header string `yaml:"message_header"` + NotifyAssignee bool `yaml:"notify_assignee"` } // Notification struct describes desired notification routes @@ -29,6 +75,11 @@ type Notification struct { Slack SlackNotification `yaml:"slack"` } +// PrConditions struct describes additional conditions for PRs +type PrConditions struct { + OlderThanSeconds int `yaml:"older_than_seconds"` +} + // PrNotification is a struct for a single GH repo PRs notifications type PrNotification struct { Owner string `yaml:"gh_owner"` @@ -38,6 +89,7 @@ type PrNotification struct { IncludeDrafts bool `yaml:"gh_pr_include_drafts"` IgnoreApproved bool `yaml:"gh_pr_ignore_approved"` IgnoreChangesRequested bool `yaml:"gh_pr_ignore_changes_requested"` + Conditions PrConditions `yaml:"gh_pr_conditions"` Notifications Notification `yaml:"notify"` } @@ -56,5 +108,21 @@ func (config *AppConfig) LoadConfig(cf string) error { } config.PrNotifications = configFile.PrNotifications + config.SlackConfig = configFile.SlackConfig + + if config.SlackConfig.GithubCustomFieldPattern == "" { + config.SlackConfig.GithubCustomFieldPattern = defaultGithubCustomFieldPattern + } + if config.SlackConfig.SlackUsersPullIntervalSeconds <= 0 { + config.SlackConfig.SlackUsersPullIntervalSeconds = defaultSlackUsersPullInterval + } return nil } + +// GetSlackUID returns Slack user ID for a given GitHub login +func (config *AppConfig) GetSlackUID(githubLogin string) (string, bool) { + if slackUID, exists := config.SlackToGithubUserMap[githubLogin]; exists { + return slackUID, true + } + return "", false +} diff --git a/internal/cfg/cfg_test.go b/internal/cfg/cfg_test.go index 960390a..013745f 100644 --- a/internal/cfg/cfg_test.go +++ b/internal/cfg/cfg_test.go @@ -22,4 +22,66 @@ func TestLoadConfig(t *testing.T) { if config.PrNotifications[0].Notifications.Slack.ChannelID != "ABC123000" { t.Error("Did not find expected Slack channel ID in notification config") } + + if config.PrNotifications[0].Conditions.OlderThanSeconds != 3600 { + t.Errorf("Expected gh_pr_conditions.older_than_seconds=3600") + } +} + +func TestSlackConfigLoad(t *testing.T) { + config := AppConfig{} + err := config.LoadConfig("../../fixtures/config.yaml") + if err != nil { + t.Errorf("Failed to load ./fixtures/config.yaml: %s", err.Error()) + } + + if config.SlackConfig.GithubCustomFieldPattern != "github.com" { + t.Errorf("Expected github_custom_field_pattern to be 'github.com', but got %q", config.SlackConfig.GithubCustomFieldPattern) + } + + if config.SlackConfig.SlackUsersPullIntervalSeconds != 600 { + t.Errorf("Expected slack_users_pull_interval_seconds to be 600, but got %d", config.SlackConfig.SlackUsersPullIntervalSeconds) + } + + if len(config.SlackConfig.GithubUsersChannels) != 1 { + t.Errorf("Expected 1 GitHub users channel, but got %d", len(config.SlackConfig.GithubUsersChannels)) + } + + if config.SlackConfig.GithubUsersChannels[0].ID != "C01234567" { + t.Errorf("Expected GitHub users channel ID to be 'C01234567', but got %q", config.SlackConfig.GithubUsersChannels[0].ID) + } + + if len(config.SlackConfig.GithubUsersChannels[0].PeriodicNotifications.Message) == 0 { + t.Errorf("Expected periodic_notifications.message to be set, but got empty") + } + + if !config.SlackConfig.GithubUsersChannels[0].PeriodicNotifications.NotifyUsers { + t.Errorf("Expected periodic_notifications.notify_users to be true, but got false") + } +} + +func TestGetSlackUID(t *testing.T) { + config := AppConfig{} + err := config.LoadConfig("../../fixtures/config.yaml") + if err != nil { + t.Errorf("Failed to load ./fixtures/config.yaml: %s", err.Error()) + } + + config.SlackToGithubUserMap = map[string]string{ + "bob": "U12345678", + "someuser": "U87654321", + } + + slackUID, found := config.GetSlackUID("bob") + if !found { + t.Errorf("Expected to find Slack user ID for GitHub login 'bob', but did not") + } + if slackUID != "U12345678" { + t.Errorf("Expected Slack user ID 'U12345678' for GitHub login 'bob', but got %q", slackUID) + } + + _, found = config.GetSlackUID("nonexistent-gh-user") + if found { + t.Errorf("Did not expect to find Slack user ID for GitHub login 'nonexistent-gh-user', but found one") + } } diff --git a/internal/gh/gh.go b/internal/gh/gh.go index 1fb55af..cceaa44 100644 --- a/internal/gh/gh.go +++ b/internal/gh/gh.go @@ -4,6 +4,7 @@ import ( "context" "os" "strconv" + "time" "github.com/golang/glog" "github.com/google/go-github/v69/github" @@ -112,3 +113,29 @@ func (g *Github) GetPullRequests(prn cfg.PrNotification) ([]*github.PullRequest, return result, nil } + +// MatchesConditions checks if a PR matches the conditions defined in the config +func (g *Github) MatchesConditions(pr *github.PullRequest, prn cfg.PrNotification) bool { + if prn.Conditions.OlderThanSeconds > 0 { + createdAt := pr.CreatedAt.Time + prOlderThan := createdAt.Add(time.Duration(prn.Conditions.OlderThanSeconds) * time.Second) + isAfter := time.Now().After(prOlderThan) + if !isAfter { + return false + } + } + return true +} + +// ------------------------ DEBUG STUFF BELOW ------------------------ + +// LogUserInfo for debugging GH users +func (g *Github) LogUserInfo(githubLogin string) { + glog.Infof("Logging info for GitHub user: %q", githubLogin) + user, _, err := g.Client.Users.Get(context.Background(), githubLogin) + if err != nil { + glog.Errorf("Failed to get user info for %q: %s", githubLogin, err.Error()) + return + } + glog.Infof("User info for %q: %+v", githubLogin, user) +} diff --git a/internal/gh/gh_test.go b/internal/gh/gh_test.go new file mode 100644 index 0000000..49bdaa2 --- /dev/null +++ b/internal/gh/gh_test.go @@ -0,0 +1,68 @@ +package gh + +import ( + "testing" + "time" + + "github.com/google/go-github/v69/github" + "github.com/impossiblecloud/pr-notify/internal/cfg" +) + +func TestMatchesConditions_OlderThanSeconds(t *testing.T) { + g := &Github{} + + // PR created 2 hours ago + pr := &github.PullRequest{ + CreatedAt: &github.Timestamp{Time: time.Now().Add(-2 * time.Hour)}, + } + + // Condition: PR older than 1 hour + prn := cfg.PrNotification{ + Conditions: cfg.PrConditions{ + OlderThanSeconds: 3600, // 1 hour + }, + } + + if !g.MatchesConditions(pr, prn) { + t.Error("Expected MatchesConditions to return true for PR older than OlderThanSeconds") + } +} + +func TestMatchesConditions_NotOlderThanSeconds(t *testing.T) { + g := &Github{} + + // PR created 30 minutes ago + pr := &github.PullRequest{ + CreatedAt: &github.Timestamp{Time: time.Now().Add(-30 * time.Minute)}, + } + + // Condition: PR older than 1 hour + prn := cfg.PrNotification{ + Conditions: cfg.PrConditions{ + OlderThanSeconds: 3600, // 1 hour + }, + } + + if g.MatchesConditions(pr, prn) { + t.Error("Expected MatchesConditions to return false for PR not older than OlderThanSeconds") + } +} + +func TestMatchesConditions_ZeroOlderThanSeconds(t *testing.T) { + g := &Github{} + + pr := &github.PullRequest{ + CreatedAt: &github.Timestamp{Time: time.Now()}, + } + + // Condition: OlderThanSeconds is zero + prn := cfg.PrNotification{ + Conditions: cfg.PrConditions{ + OlderThanSeconds: 0, + }, + } + + if !g.MatchesConditions(pr, prn) { + t.Error("Expected MatchesConditions to return true when OlderThanSeconds is zero") + } +} diff --git a/internal/slack/slack.go b/internal/slack/slack.go index 48dc3ce..fdc6d50 100644 --- a/internal/slack/slack.go +++ b/internal/slack/slack.go @@ -5,7 +5,9 @@ import ( "log" "os" "strings" + "time" + "github.com/golang/glog" "github.com/impossiblecloud/pr-notify/internal/cfg" "github.com/slack-go/slack" "github.com/slack-go/slack/socketmode" @@ -53,6 +55,7 @@ func (s *Slack) Init(debug bool) error { // SendMessage sends a slack message based on PR notification config func (s *Slack) SendMessage(prn cfg.PrNotification, message string) error { + glog.V(8).Infof("SLACK Sending message to slack channel %q", prn.Notifications.Slack.ChannelID) _, _, err := s.Client.PostMessage(prn.Notifications.Slack.ChannelID, slack.MsgOptionText(message, false), slack.MsgOptionAsUser(true), @@ -60,3 +63,189 @@ func (s *Slack) SendMessage(prn cfg.PrNotification, message string) error { ) return err } + +// DM sends a direct message to a user +func (s *Slack) DM(userID string, message string) error { + glog.V(8).Infof("SLACK Sending DM to slack user %q", userID) + _, _, err := s.Client.PostMessage(userID, + slack.MsgOptionText(message, false), + slack.MsgOptionAsUser(true), + slack.MsgOptionLinkNames(true), + ) + return err +} + +// MakeGithubToSlackUserMap builds a map of ALL slack users with their github logins +// based on the info found in Slack custom profile fields +func (s *Slack) MakeGithubToSlackUserMap() (map[string]string, error) { + glog.V(8).Infof("Building GitHub to Slack user map for all users in Slack organization") + userMap := make(map[string]string) + users, err := s.Client.GetUsers() + if err != nil { + glog.Errorf("Failed to get users: %s", err.Error()) + return nil, err + } + for _, user := range users { + if user.IsBot || user.Deleted { + glog.V(10).Infof("Skipping bot or deleted user: %q", user.ID) + continue + } + glog.V(10).Infof("User custom fields info for %q: %+v", user.ID, user.Profile.Fields) + githubLogin, err := s.GetUserGithubLogin(user.ID) + if err != nil { + glog.Errorf("Failed to get GitHub login for user %q: %s", user.ID, err.Error()) + continue + } + if githubLogin != "" { + userMap[user.ID] = githubLogin + } + } + return userMap, nil +} + +// GetUserGithubLogin returns a github login for a Slack user based on custom fields +func (s *Slack) GetUserGithubLogin(slackUserID string) (string, error) { + glog.V(8).Infof("Getting GitHub login for Slack user: %q", slackUserID) + profile, err := s.Client.GetUserProfile(&slack.GetUserProfileParameters{ + UserID: slackUserID, + }) + if err != nil { + glog.Errorf("Failed to get user profile for %q: %s", slackUserID, err.Error()) + return "", err + } + for k, v := range profile.Fields.ToMap() { + glog.V(10).Infof("User profile field for %q: %q: %+v", slackUserID, k, v) + if v.Value != "" && strings.Contains(strings.ToLower(v.Value), "github") { + return v.Alt, nil + } + } + return "", nil +} + +// GetConversationMembers returns a list of slack users in a channel +func (s *Slack) GetConversationMembers(channelID string) ([]string, error) { + glog.V(8).Infof("Getting conversation members for channel: %q", channelID) + + users, _, err := s.Client.GetUsersInConversation(&slack.GetUsersInConversationParameters{ + ChannelID: channelID, + }) + if err != nil { + glog.Errorf("Failed to get conversation members for channel %q: %s", channelID, err.Error()) + return nil, err + } + + return users, nil +} + +// MakeGithubtoSlackUserMapInChannels builds a map of slack users found in the channel +// with their github logins based on the info found in Slack custom profile fields +func (s *Slack) MakeGithubToSlackUserMapInChannels(channelIDs []string) (map[string]string, error) { + glog.V(8).Infof("Building Slack to GitHub user map for channels: %v", channelIDs) + userMap := make(map[string]string) + users := []string{} + for _, chID := range channelIDs { + channelUsers, err := s.GetConversationMembers(chID) + if err != nil { + glog.Errorf("Failed to get conversation members for channel %q: %s", chID, err.Error()) + return nil, err + } + users = append(users, channelUsers...) + } + + for _, user := range users { + if _, exists := userMap[user]; exists { + continue + } + githubLogin, err := s.GetUserGithubLogin(user) + if err != nil { + glog.Errorf("Failed to get GitHub login for user %q: %s", user, err.Error()) + continue + } + if githubLogin != "" { + userMap[githubLogin] = user + } + } + return userMap, nil +} + +// SlackToGithubUpdateLoop runs a loop forever and updates the Slack to GitHub user mapping +func (s *Slack) SlackToGithubUpdateLoop(conf *cfg.AppConfig) { + channels := []string{} + for _, slackChannel := range conf.SlackConfig.GithubUsersChannels { + channels = append(channels, slackChannel.ID) + } + + for { + glog.V(8).Infof("Updating Slack to GitHub user mapping for channels: %v", channels) + userMap, err := s.MakeGithubToSlackUserMapInChannels(channels) + if err != nil { + glog.Errorf("Failed to update Slack to GitHub user mapping: %s", err.Error()) + } else { + glog.V(8).Infof("Updated Slack to GitHub user mapping: %+v", userMap) + } + conf.SlackToGithubUserMap = userMap + time.Sleep(time.Duration(conf.SlackConfig.SlackUsersPullIntervalSeconds) * time.Second) + } +} + +// ------------------------ DEBUG STUFF BELOW ------------------------ + +// LogUserInfo logs info about a Slack user by their user ID +func (s *Slack) LogUserInfo(slackUserID string) { + glog.Infof("Logging info for Slack user: %q", slackUserID) + user, err := s.Client.GetUserInfo(slackUserID) + if err != nil { + glog.Errorf("Failed to get user info for %q: %s", slackUserID, err.Error()) + return + } + glog.Infof("User info for %q: %+v", slackUserID, user) + profile, err := s.Client.GetUserProfile(&slack.GetUserProfileParameters{ + UserID: slackUserID, + }) + if err != nil { + glog.Errorf("Failed to get user profile for %q: %s", slackUserID, err.Error()) + return + } + glog.Infof("User profile for %q: %+v", slackUserID, profile) + for k, v := range profile.Fields.ToMap() { + glog.Infof("User profile field for %q: %q: %+v", slackUserID, k, v) + } + + githubLogin, err := s.GetUserGithubLogin(slackUserID) + if err != nil { + glog.Errorf("Failed to get GitHub login for user %q: %s", slackUserID, err.Error()) + return + } + glog.Infof("GitHub login for user %q: %q", slackUserID, githubLogin) +} + +// LogGithubSlackUserMappings logs mappings of slack to GH for debug +func (s *Slack) LogGithubSlackUserMappings() { + userMap, err := s.MakeGithubToSlackUserMap() + if err != nil { + glog.Errorf("Failed to make Slack to GitHub user map: %s", err.Error()) + return + } + for slackID, ghLogin := range userMap { + glog.Infof("Slack user %q is mapped to GitHub user %q", slackID, ghLogin) + } +} + +// LogsUsersInChannel logs users in the channel +func (s *Slack) LogsUsersInChannel(channelID string) { + users, err := s.GetConversationMembers(channelID) + if err != nil { + glog.Errorf("Failed to get conversation members for channel %q: %s", channelID, err.Error()) + return + } + glog.Infof("Users in channel %q: %v", channelID, users) + + userMap, err := s.MakeGithubToSlackUserMapInChannels([]string{channelID}) + if err != nil { + glog.Errorf("Failed to make Slack to GitHub user map for channel %q: %s", channelID, err.Error()) + return + } + for ghLogin, slackID := range userMap { + glog.Infof("GitHub user %q is mapped to Slack user %q", ghLogin, slackID) + } +} diff --git a/main.go b/main.go index 102b4a8..7112b55 100644 --- a/main.go +++ b/main.go @@ -61,7 +61,9 @@ func runMainWebServer(config cfg.AppConfig, listen string) { } // prNotificationsCall is basically our main loop call -func prNotificationsCall(g *gh.Github, s *slack.Slack, prn cfg.PrNotification) { +func prNotificationsCall(config *cfg.AppConfig, g *gh.Github, s *slack.Slack, prn cfg.PrNotification) { + glog.V(8).Infof("PR notification call start for %s/%s", prn.Owner, prn.Repo) + prs, err := g.GetPullRequests(prn) if err != nil { glog.Fatalf("Failed to pull PRs: %s", err.Error()) @@ -78,19 +80,62 @@ func prNotificationsCall(g *gh.Github, s *slack.Slack, prn cfg.PrNotification) { } message += fmt.Sprintf("Pull requests from %s/%s repository:\n", prn.Owner, prn.Repo) + // Prepare direct messages map + directMessages := make(map[string]string) + + // Loop over all discovered PRs and compile messages for _, pr := range prs { - glog.Infof("PR-%d: %s", *pr.Number, *pr.Title) - message += fmt.Sprintf("- %s - %s\n", *pr.HTMLURL, *pr.Title) + glog.V(10).Infof("Checking PR-%d: %s", *pr.Number, *pr.Title) + additionalInfo := "" + + // Skip the PR is it does not match additional conditions + if !g.MatchesConditions(pr, prn) { + glog.V(8).Infof("PR-%d does not match conditions", *pr.Number) + continue + } + + // Add PR to the assignee DM queue + if pr.Assignee != nil { + additionalInfo += fmt.Sprintf(" (assignee: %s)", *pr.Assignee.Login) + directMessages[*pr.Assignee.Login] += fmt.Sprintf("- You have a PR assigned: %s - %s\n", *pr.HTMLURL, *pr.Title) + glog.V(8).Infof("Added PR %s for assignee %q", *pr.HTMLURL, *pr.Assignee.Login) + } + + // Add PR to the main message + message += fmt.Sprintf("- %s - %s%s\n", *pr.HTMLURL, *pr.Title, additionalInfo) } - if err := s.SendMessage(prn, message); err != nil { - glog.Errorf("Failed to send message to slack: %s", err.Error()) + // Send individual messages to assignees if configured + if prn.Notifications.Slack.NotifyAssignee { + for ghLogin, dm := range directMessages { + glog.V(8).Infof("Checking PR slack notifications created for %q", ghLogin) + if slackUID, ok := config.GetSlackUID(ghLogin); ok { + glog.V(6).Infof("Sending DM to %s (slack UID: %s)", ghLogin, slackUID) + message := "" + if prn.Notifications.Slack.Header != "" { + message += prn.Notifications.Slack.Header + "\n" + } + if err := s.DM(slackUID, message+dm); err != nil { + glog.Errorf("Failed to send DM to %s (slack UID: %s): %s", ghLogin, slackUID, err.Error()) + } + } else { + glog.Warning("No Slack UID mapping found for GitHub user: ", ghLogin) + } + } + } + + // Send summary message to the specified channel + if prn.Notifications.Slack.ChannelID != "" { + glog.V(6).Infof("Sending message to slack channel %q", prn.Notifications.Slack.ChannelID) + if err := s.SendMessage(prn, message); err != nil { + glog.Errorf("Failed to send message to slack: %s", err.Error()) + } } } func main() { - var listen, configFile string - var showVersion, slackDebug bool + var listen, configFile, ghUser, slackUser, slackChannel string + var showVersion, slackDebug, debugSlackToGithubUserMapping bool // Init config config := cfg.AppConfig{} @@ -99,6 +144,10 @@ func main() { flag.BoolVar(&showVersion, "version", false, "Show version and exit") flag.BoolVar(&slackDebug, "slack-debug", false, "Slack API debug mode") flag.StringVar(&listen, "listen", ":8765", "Address:port to listen on") + flag.StringVar(&ghUser, "debug-gh-user", "", "GitHub user login to debug and pull info about and exit") + flag.StringVar(&slackUser, "debug-slack-user", "", "Slack user ID to debug and pull info about and exit") + flag.StringVar(&slackChannel, "debug-slack-channel", "", "Debug Slack users in channel and exit") + flag.BoolVar(&debugSlackToGithubUserMapping, "debug-slack-gh-users", false, "Debug Slack to GitHub user mapping and exit") flag.Parse() // Show and exit functions @@ -121,18 +170,53 @@ func main() { // Init clients ghClient := gh.Github{} + glog.Info("Initializing Github client") err = ghClient.Init() if err != nil { glog.Fatalf("Failed to initialize Github Client: %s", err.Error()) } + + glog.Info("Initializing Slack client") slackClient := slack.Slack{} - slackClient.Init(slackDebug) + err = slackClient.Init(slackDebug) + if err != nil { + glog.Fatalf("Failed to initialize Slack Client: %s", err.Error()) + } + + // Log debug info for a specific GitHub user and exit + if ghUser != "" { + ghClient.LogUserInfo(ghUser) + return + } + + // Log debug info for a specific Slack user and exit + if slackUser != "" { + slackClient.LogUserInfo(slackUser) + return + } + + // Log debug info about slack to GH user mappings + if debugSlackToGithubUserMapping { + slackClient.LogGithubSlackUserMappings() + return + } + + // Log debug info about slack channel and exit + if slackChannel != "" { + slackClient.LogsUsersInChannel(slackChannel) + return + } // Add cron job schedulers for all PR notification configs - for _, prn := range config.PrNotifications { - cronJob.AddFunc(prn.Schedule, func() { prNotificationsCall(&ghClient, &slackClient, prn) }) + glog.Info("Starting cron job schedulers") + for id, prn := range config.PrNotifications { + cronJob.AddFunc(prn.Schedule, func() { prNotificationsCall(&config, &ghClient, &slackClient, prn) }) + glog.Infof("Added cronjob scheduler %d for %s/%s", id, prn.Owner, prn.Repo) } + // Start Slack to GitHub user mapping update loop + go slackClient.SlackToGithubUpdateLoop(&config) + cronJob.Start() runMainWebServer(config, listen) } From 3334f723fc4ba7b9f347eea774b2284cfa64394b Mon Sep 17 00:00:00 2001 From: Alexander Didenko Date: Mon, 15 Sep 2025 16:25:54 +0200 Subject: [PATCH 2/3] Add git version and slack channel watch mode --- Dockerfile | 1 + Makefile | 7 +-- internal/cfg/cfg.go | 10 ++++ internal/slack/slack.go | 115 ++++++++++++++++++++++++++++++++++++++-- main.go | 17 ++++-- 5 files changed, 139 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4cd1f68..da2d29b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,7 @@ COPY go.mod go.mod COPY go.sum go.sum COPY internal/ internal/ COPY fixtures/ fixtures/ +COPY .git .git RUN make test FROM test AS build diff --git a/Makefile b/Makefile index 3c42e85..ba2e27d 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ # VENDOR_DIR = vendor +GIT_VERSION := $(shell git rev-parse --short HEAD) .PHONY: get-deps get-deps: $(VENDOR_DIR) @@ -17,15 +18,15 @@ $(OUTPUT_DIR): .PHONY: build build: $(VENDOR_DIR) $(OUTPUT_DIR) - GOOS=linux CGO_ENABLED=0 go build -a -ldflags '-extldflags "-static"' -o output/pr-notify . + GOOS=linux CGO_ENABLED=0 go build -a -ldflags '-extldflags "-static"' -ldflags="-X main.Version=$(GIT_VERSION)" -o output/pr-notify . .PHONY: local-build local-build: $(VENDOR_DIR) $(OUTPUT_DIR) - CGO_ENABLED=1 go build -a -ldflags '-extldflags "-static"' -o output/pr-notify . + CGO_ENABLED=1 go build -a -ldflags '-extldflags "-static"' -ldflags="-X main.Version=$(GIT_VERSION)" -o output/pr-notify . .PHONY: local-build-wo-cgo local-build-wo-cgo: $(VENDOR_DIR) $(OUTPUT_DIR) - CGO_ENABLED=0 go build -a -ldflags '-extldflags "-static"' -o output/pr-notify . + CGO_ENABLED=0 go build -a -ldflags '-extldflags "-static"' -ldflags="-X main.Version=$(GIT_VERSION)" -o output/pr-notify . .PHONY: clean clean: diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index f4973f6..1459f03 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -126,3 +126,13 @@ func (config *AppConfig) GetSlackUID(githubLogin string) (string, bool) { } return "", false } + +// GetGithubLogin returns GitHub login for a given Slack user ID +func (config *AppConfig) GetGithubLogin(slackUID string) (string, bool) { + for ghLogin, sUID := range config.SlackToGithubUserMap { + if sUID == slackUID { + return ghLogin, true + } + } + return "", false +} diff --git a/internal/slack/slack.go b/internal/slack/slack.go index fdc6d50..e6cd233 100644 --- a/internal/slack/slack.go +++ b/internal/slack/slack.go @@ -4,12 +4,14 @@ import ( "fmt" "log" "os" + "regexp" "strings" "time" "github.com/golang/glog" "github.com/impossiblecloud/pr-notify/internal/cfg" "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" "github.com/slack-go/slack/socketmode" ) @@ -50,6 +52,13 @@ func (s *Slack) Init(debug bool) error { socketmode.OptionLog(log.New(os.Stdout, "socketmode: ", log.Lshortfile|log.LstdFlags)), ) + info, err := api.AuthTest() + if err != nil { + glog.Fatalf("Got error while running api.AuthTest(): %s", err.Error()) + } + + glog.V(8).Infof("Slack Debug: %+v", info) + return nil } @@ -105,7 +114,7 @@ func (s *Slack) MakeGithubToSlackUserMap() (map[string]string, error) { // GetUserGithubLogin returns a github login for a Slack user based on custom fields func (s *Slack) GetUserGithubLogin(slackUserID string) (string, error) { - glog.V(8).Infof("Getting GitHub login for Slack user: %q", slackUserID) + glog.V(9).Infof("Getting GitHub login for Slack user: %q", slackUserID) profile, err := s.Client.GetUserProfile(&slack.GetUserProfileParameters{ UserID: slackUserID, }) @@ -124,7 +133,7 @@ func (s *Slack) GetUserGithubLogin(slackUserID string) (string, error) { // GetConversationMembers returns a list of slack users in a channel func (s *Slack) GetConversationMembers(channelID string) ([]string, error) { - glog.V(8).Infof("Getting conversation members for channel: %q", channelID) + glog.V(10).Infof("Getting conversation members for channel: %q", channelID) users, _, err := s.Client.GetUsersInConversation(&slack.GetUsersInConversationParameters{ ChannelID: channelID, @@ -140,7 +149,7 @@ func (s *Slack) GetConversationMembers(channelID string) ([]string, error) { // MakeGithubtoSlackUserMapInChannels builds a map of slack users found in the channel // with their github logins based on the info found in Slack custom profile fields func (s *Slack) MakeGithubToSlackUserMapInChannels(channelIDs []string) (map[string]string, error) { - glog.V(8).Infof("Building Slack to GitHub user map for channels: %v", channelIDs) + glog.V(10).Infof("Building Slack to GitHub user map for channels: %v", channelIDs) userMap := make(map[string]string) users := []string{} for _, chID := range channelIDs { @@ -188,6 +197,106 @@ func (s *Slack) SlackToGithubUpdateLoop(conf *cfg.AppConfig) { } } +// WatchChannelAndReply watches a Slack channel and replies to messages with PR links +// if the user does not have GitHub info in their profile +func (s *Slack) WatchChannelAndReply(ghuChannel cfg.GHUsersChannel, conf *cfg.AppConfig) { + glog.Infof("Starting to watch Slack channel %q for auto replies", ghuChannel.ID) + for evt := range s.Client.Events { + glog.Infof("Received Events API event type: %+v", evt.Type) + switch evt.Type { + case socketmode.EventTypeConnecting: + glog.Info("Connecting to Slack with Socket Mode...") + case socketmode.EventTypeConnectionError: + glog.Error("Connection failed. Retrying later...") + case socketmode.EventTypeConnected: + glog.Info("Connected to Slack with Socket Mode.") + case socketmode.EventTypeEventsAPI: + glog.V(8).Infof("Received Events API event type: %+v", evt.Type) + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + glog.Errorf("Could not type cast the event to the EventsAPIEvent: %v\n", evt) + continue + } + s.Client.Ack(*evt.Request) + + switch eventsAPIEvent.Type { + case slackevents.CallbackEvent: + innerEvent := eventsAPIEvent.InnerEvent + switch ev := innerEvent.Data.(type) { + case *slackevents.MessageEvent: + glog.V(8).Infof("Received message event: %+v", ev) + if ev.BotID != "" { + glog.V(10).Infof("Skipping bot message: %+v", ev) + continue + } + + // Check messages that are not in Slack threads + if ev.ThreadTimeStamp == "" { + matched, err := regexp.MatchString(ghuChannel.MessageReplyNotifications.MessageRegex, ev.Text) + if err != nil { + glog.Errorf("Failed to match regex %q: %s", ghuChannel.MessageReplyNotifications.MessageRegex, err.Error()) + continue + } + if matched { + glog.V(8).Infof("Message matches regex %q: %+v", ghuChannel.MessageReplyNotifications.MessageRegex, ev) + // Check memory cache first + if githubLogin, exists := conf.GetGithubLogin(ev.User); exists && githubLogin != "" { + glog.V(8).Infof("User %q has GitHub login %q in profile, not replying", ev.User, githubLogin) + continue + } + // Fetch user profile and check again + githubLogin, err := s.GetUserGithubLogin(ev.User) + if err != nil { + glog.Errorf("Failed to get GitHub login for user %q: %s", ev.User, err.Error()) + continue + } + // Not github login found, reply to the user + if githubLogin == "" { + glog.V(8).Infof("User %q does not have GitHub login in profile, replying...", ev.User) + replyOptions := []slack.MsgOption{ + slack.MsgOptionText(ghuChannel.MessageReplyNotifications.Reply, false), + slack.MsgOptionAsUser(true), + slack.MsgOptionLinkNames(true), + } + if ghuChannel.MessageReplyNotifications.ReplyInThread { + replyOptions = append(replyOptions, slack.MsgOptionTS(ev.TimeStamp)) + } + // TODO: Uncomment to enable actual replies + glog.Infof("Replying in channel %q user %q", ev.Channel, ev.User) + // _, _, err := s.Client.PostMessage(ev.Channel, replyOptions...) + // if err != nil { + // glog.Errorf("Failed to post message to channel %q: %s", ev.Channel, err.Error()) + // continue + // } + } else { + glog.V(8).Infof("Channel %q User %q has GitHub login %q in profile, not replying", ev.Channel, ev.User, githubLogin) + } + } else { + glog.V(10).Infof("Message does not match regex %q: %+v", ghuChannel.MessageReplyNotifications.MessageRegex, ev) + } + } + default: + glog.V(8).Infof("Unsupported inner event type: %T", innerEvent.Data) + } + default: + glog.V(8).Infof("Unsupported Events API event type: %v", eventsAPIEvent.Type) + } + default: + glog.V(8).Infof("Unsupported event type: %v", evt.Type) + } + } + glog.Infof("Exiting WatchChannelAndReply loop for channel %q", ghuChannel.ID) +} + +// SlackChannelAutoReplyLoop starts a loop to watch configured channels and reply to messages +func (s *Slack) SlackChannelAutoReplyLoop(conf *cfg.AppConfig) { + for _, ghUsersChannel := range conf.SlackConfig.GithubUsersChannels { + if ghUsersChannel.MessageReplyNotifications.NotifyUsers && ghUsersChannel.MessageReplyNotifications.MessageRegex != "" { + go s.WatchChannelAndReply(ghUsersChannel, conf) + } + } +} + // ------------------------ DEBUG STUFF BELOW ------------------------ // LogUserInfo logs info about a Slack user by their user ID diff --git a/main.go b/main.go index 7112b55..d43c1d0 100644 --- a/main.go +++ b/main.go @@ -19,7 +19,7 @@ import ( ) // Constants -const version = "0.0.1" +var Version string // Prometheus metrics handler func handleMetrics(config cfg.AppConfig) http.HandlerFunc { @@ -35,7 +35,7 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { glog.V(10).Info("Got HTTP request for /") w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, "Up and running. Version: %s", version) + fmt.Fprintf(w, "Up and running.") } // Health handler @@ -137,6 +137,10 @@ func main() { var listen, configFile, ghUser, slackUser, slackChannel string var showVersion, slackDebug, debugSlackToGithubUserMapping bool + if Version == "" { + Version = "unknown" + } + // Init config config := cfg.AppConfig{} @@ -152,10 +156,10 @@ func main() { // Show and exit functions if showVersion { - fmt.Printf("Version: %s\n", version) + fmt.Printf("Version: %s\n", Version) os.Exit(0) } - glog.V(4).Infof("Starting application. Version: %s", version) + glog.V(4).Infof("Starting application. Version: %s", Version) err := config.LoadConfig(configFile) if err != nil { @@ -164,7 +168,7 @@ func main() { glog.V(6).Infof("Loaded PR notifications: %+v", config.PrNotifications) // Init metric and cron - config.Metrics = metrics.InitMetrics(version) + config.Metrics = metrics.InitMetrics(Version) cronJob := cron.New() defer cronJob.Stop() @@ -217,6 +221,9 @@ func main() { // Start Slack to GitHub user mapping update loop go slackClient.SlackToGithubUpdateLoop(&config) + // Start Slack channel auto-reply loop + slackClient.SlackChannelAutoReplyLoop(&config) + cronJob.Start() runMainWebServer(config, listen) } From ce2989d4015aba7e982105dafddfe9db856fa088 Mon Sep 17 00:00:00 2001 From: Alexander Didenko Date: Tue, 16 Sep 2025 17:07:52 +0200 Subject: [PATCH 3/3] Refactor all the slack stuff --- Dockerfile | 2 +- go.mod | 32 +++++------ go.sum | 86 ++++++++++++++-------------- internal/cfg/cfg.go | 21 +++++++ internal/slack/slack.go | 124 +++++++++++++++++++--------------------- main.go | 23 ++++++-- 6 files changed, 159 insertions(+), 129 deletions(-) diff --git a/Dockerfile b/Dockerfile index da2d29b..8e6a7d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # Simple tool to watch directory for new files and upload them to S3 # -FROM golang:1.24.6 AS test +FROM golang:1.25.1 AS test WORKDIR /build ENV GOPATH=/go ENV PATH="$PATH:$GOPATH/bin" diff --git a/go.mod b/go.mod index 571cc80..49e06e7 100644 --- a/go.mod +++ b/go.mod @@ -1,32 +1,32 @@ module github.com/impossiblecloud/pr-notify -go 1.23 +go 1.25 require ( - github.com/golang/glog v1.2.4 - github.com/google/go-github/v69 v69.0.0 + github.com/golang/glog v1.2.5 + github.com/google/go-github/v69 v69.2.0 github.com/gorilla/mux v1.8.1 - github.com/jferrl/go-githubauth v1.1.1 - github.com/prometheus/client_golang v1.20.5 + github.com/jferrl/go-githubauth v1.4.0 + github.com/prometheus/client_golang v1.23.2 github.com/robfig/cron/v3 v3.0.1 - github.com/slack-go/slack v0.16.0 - golang.org/x/oauth2 v0.23.0 + github.com/slack-go/slack v0.17.3 + golang.org/x/oauth2 v0.31.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/golang-jwt/jwt/v5 v5.2.1 // indirect - github.com/google/go-github/v64 v64.0.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/google/go-github/v73 v73.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/gorilla/websocket v1.4.2 // indirect - github.com/klauspost/compress v1.17.9 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/kr/text v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.55.0 // indirect - github.com/prometheus/procfs v0.15.1 // indirect - golang.org/x/sys v0.22.0 // indirect - google.golang.org/protobuf v1.34.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/go.sum b/go.sum index 1b46037..ba167ff 100644 --- a/go.sum +++ b/go.sum @@ -5,68 +5,70 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= -github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/glog v1.2.4 h1:CNNw5U8lSiiBk7druxtSHHTsRWcxKoac6kZKm2peBBc= -github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +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-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-github/v64 v64.0.0 h1:4G61sozmY3eiPAjjoOHponXDBONm+utovTKbyUb2Qdg= -github.com/google/go-github/v64 v64.0.0/go.mod h1:xB3vqMQNdHzilXBiO2I+M7iEFtHf+DP/omBOv6tQzVo= -github.com/google/go-github/v69 v69.0.0 h1:YnFvZ3pEIZF8KHmI8xyQQe3mYACdkhnaTV2hr7CP2/w= -github.com/google/go-github/v69 v69.0.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE= +github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM= +github.com/google/go-github/v73 v73.0.0 h1:aR+Utnh+Y4mMkS+2qLQwcQ/cF9mOTpdwnzlaw//rG24= +github.com/google/go-github/v73 v73.0.0/go.mod h1:fa6w8+/V+edSU0muqdhCVY7Beh1M8F1IlQPZIANKIYw= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/jferrl/go-githubauth v1.1.1 h1:HfF3eeWFL+9jV9KHAatBaEnFGm9R2LkTqo5Z2GcDk20= -github.com/jferrl/go-githubauth v1.1.1/go.mod h1:FC1jqgik3xdaZDg8CUmGbvDwfP/egXkrq6Ygl9pSz/Y= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +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/jferrl/go-githubauth v1.4.0 h1:lb3LUKOXnqtjb6PdB+qw74zOvWdRyIp/lWjDlPNIA8M= +github.com/jferrl/go-githubauth v1.4.0/go.mod h1:B+IZ+R0heTfIGxhm7wC7b52B3ADh/AfD0bKY5vktBV4= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/migueleliasweb/go-github-mock v1.0.1 h1:amLEECVny28RCD1ElALUpQxrAimamznkg9rN2O7t934= -github.com/migueleliasweb/go-github-mock v1.0.1/go.mod h1:8PJ7MpMoIiCBBNpuNmvndHm0QicjsE+hjex1yMGmjYQ= +github.com/migueleliasweb/go-github-mock v1.4.0 h1:pQ6K8r348m2q79A8Khb0PbEeNQV7t3h1xgECV+jNpXk= +github.com/migueleliasweb/go-github-mock v1.4.0/go.mod h1:/DUmhXkxrgVlDOVBqGoUXkV4w0ms5n1jDQHotYm135o= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= -github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/slack-go/slack v0.16.0 h1:khp/WCFv+Hb/B/AJaAwvcxKun0hM6grN0bUZ8xG60P8= -github.com/slack-go/slack v0.16.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= -golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= -golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +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.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= +golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index 1459f03..8f65299 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -2,7 +2,9 @@ package cfg import ( "os" + "regexp" + "github.com/golang/glog" "github.com/impossiblecloud/pr-notify/internal/metrics" "gopkg.in/yaml.v3" ) @@ -136,3 +138,22 @@ func (config *AppConfig) GetGithubLogin(slackUID string) (string, bool) { } return "", false } + +// GetSlackMessageReply returns a reply message for a given Slack channel ID and message text +func (config *AppConfig) GetSlackMessageReply(channelID, messageText string) (string, bool) { + for _, ghUsersChannel := range config.SlackConfig.GithubUsersChannels { + if ghUsersChannel.ID == channelID { + if ghUsersChannel.MessageReplyNotifications.NotifyUsers && ghUsersChannel.MessageReplyNotifications.MessageRegex != "" { + matched, err := regexp.MatchString(ghUsersChannel.MessageReplyNotifications.MessageRegex, messageText) + if err != nil { + glog.Errorf("Error matching regex %q: %s", ghUsersChannel.MessageReplyNotifications.MessageRegex, err.Error()) + return "", false + } + if matched { + return ghUsersChannel.MessageReplyNotifications.Reply, ghUsersChannel.MessageReplyNotifications.ReplyInThread + } + } + } + } + return "", false +} diff --git a/internal/slack/slack.go b/internal/slack/slack.go index e6cd233..6626680 100644 --- a/internal/slack/slack.go +++ b/internal/slack/slack.go @@ -4,7 +4,6 @@ import ( "fmt" "log" "os" - "regexp" "strings" "time" @@ -57,7 +56,9 @@ func (s *Slack) Init(debug bool) error { glog.Fatalf("Got error while running api.AuthTest(): %s", err.Error()) } - glog.V(8).Infof("Slack Debug: %+v", info) + if debug { + glog.Infof("Slack Debug: %+v", info) + } return nil } @@ -197,12 +198,13 @@ func (s *Slack) SlackToGithubUpdateLoop(conf *cfg.AppConfig) { } } -// WatchChannelAndReply watches a Slack channel and replies to messages with PR links -// if the user does not have GitHub info in their profile -func (s *Slack) WatchChannelAndReply(ghuChannel cfg.GHUsersChannel, conf *cfg.AppConfig) { - glog.Infof("Starting to watch Slack channel %q for auto replies", ghuChannel.ID) +// SlackSocketModeHandler handles Slack events in Socket Mode +func (s *Slack) SlackSocketModeHandler(conf *cfg.AppConfig) { + glog.Infof("SlackSocketModeHandler started") + defer glog.Infof("Exiting SlackSocketModeHandler") + for evt := range s.Client.Events { - glog.Infof("Received Events API event type: %+v", evt.Type) + glog.V(12).Infof("Received Events API event type: %+v", evt.Type) switch evt.Type { case socketmode.EventTypeConnecting: glog.Info("Connecting to Slack with Socket Mode...") @@ -211,88 +213,80 @@ func (s *Slack) WatchChannelAndReply(ghuChannel cfg.GHUsersChannel, conf *cfg.Ap case socketmode.EventTypeConnected: glog.Info("Connected to Slack with Socket Mode.") case socketmode.EventTypeEventsAPI: - glog.V(8).Infof("Received Events API event type: %+v", evt.Type) + glog.V(10).Infof("Received Events API event type: %+v", evt.Type) eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) if !ok { glog.Errorf("Could not type cast the event to the EventsAPIEvent: %v\n", evt) continue } s.Client.Ack(*evt.Request) - switch eventsAPIEvent.Type { case slackevents.CallbackEvent: innerEvent := eventsAPIEvent.InnerEvent switch ev := innerEvent.Data.(type) { case *slackevents.MessageEvent: - glog.V(8).Infof("Received message event: %+v", ev) + glog.V(10).Infof("Received message event: %+v", ev) if ev.BotID != "" { glog.V(10).Infof("Skipping bot message: %+v", ev) continue } - // Check messages that are not in Slack threads - if ev.ThreadTimeStamp == "" { - matched, err := regexp.MatchString(ghuChannel.MessageReplyNotifications.MessageRegex, ev.Text) + // Skip messages that are in Slack threads + if ev.ThreadTimeStamp != "" { + glog.V(10).Infof("Skipping message in thread: %+v", ev) + continue + } + + // Find response for the channel and message + response, replyInThread := conf.GetSlackMessageReply(ev.Channel, ev.Text) + if response == "" { + glog.V(10).Infof("No response found for channel %q and message", ev.Channel) + continue + } + glog.V(10).Infof("Found response for channel %q and message: %q", ev.Channel, response) + + // Check cache + if githubLogin, exists := conf.GetGithubLogin(ev.User); exists && githubLogin != "" { + glog.V(8).Infof("User %q from channel %q has GitHub login %q in profile, not replying", ev.User, ev.Channel, githubLogin) + continue + } + + // Fetch user profile and check again + githubLogin, err := s.GetUserGithubLogin(ev.User) + if err != nil { + glog.Errorf("Failed to get GitHub login for user %q: %s", ev.User, err.Error()) + continue + } + + // No github login found, reply to the user + if githubLogin == "" { + glog.V(8).Infof("User %q from channel %q does not have GitHub login in profile, replying...", ev.User, ev.Channel) + replyOptions := []slack.MsgOption{ + slack.MsgOptionText(response, false), + slack.MsgOptionAsUser(true), + slack.MsgOptionLinkNames(true), + } + if replyInThread { + replyOptions = append(replyOptions, slack.MsgOptionTS(ev.TimeStamp)) + } + // Send reply in Slack + glog.V(10).Infof("SLACK Replying in channel %q to user %q with text %q", ev.Channel, ev.User, response) + _, _, err := s.Client.PostMessage(ev.Channel, replyOptions...) if err != nil { - glog.Errorf("Failed to match regex %q: %s", ghuChannel.MessageReplyNotifications.MessageRegex, err.Error()) + glog.Errorf("Failed to post message to channel %q: %s", ev.Channel, err.Error()) continue } - if matched { - glog.V(8).Infof("Message matches regex %q: %+v", ghuChannel.MessageReplyNotifications.MessageRegex, ev) - // Check memory cache first - if githubLogin, exists := conf.GetGithubLogin(ev.User); exists && githubLogin != "" { - glog.V(8).Infof("User %q has GitHub login %q in profile, not replying", ev.User, githubLogin) - continue - } - // Fetch user profile and check again - githubLogin, err := s.GetUserGithubLogin(ev.User) - if err != nil { - glog.Errorf("Failed to get GitHub login for user %q: %s", ev.User, err.Error()) - continue - } - // Not github login found, reply to the user - if githubLogin == "" { - glog.V(8).Infof("User %q does not have GitHub login in profile, replying...", ev.User) - replyOptions := []slack.MsgOption{ - slack.MsgOptionText(ghuChannel.MessageReplyNotifications.Reply, false), - slack.MsgOptionAsUser(true), - slack.MsgOptionLinkNames(true), - } - if ghuChannel.MessageReplyNotifications.ReplyInThread { - replyOptions = append(replyOptions, slack.MsgOptionTS(ev.TimeStamp)) - } - // TODO: Uncomment to enable actual replies - glog.Infof("Replying in channel %q user %q", ev.Channel, ev.User) - // _, _, err := s.Client.PostMessage(ev.Channel, replyOptions...) - // if err != nil { - // glog.Errorf("Failed to post message to channel %q: %s", ev.Channel, err.Error()) - // continue - // } - } else { - glog.V(8).Infof("Channel %q User %q has GitHub login %q in profile, not replying", ev.Channel, ev.User, githubLogin) - } - } else { - glog.V(10).Infof("Message does not match regex %q: %+v", ghuChannel.MessageReplyNotifications.MessageRegex, ev) - } + } else { + // Add user to cache + conf.SlackToGithubUserMap[ev.User] = githubLogin + glog.V(8).Infof("User %q from channel %q has GitHub login %q in profile, not replying", ev.User, ev.Channel, githubLogin) } - default: - glog.V(8).Infof("Unsupported inner event type: %T", innerEvent.Data) } default: - glog.V(8).Infof("Unsupported Events API event type: %v", eventsAPIEvent.Type) + glog.V(12).Infof("Unsupported inner event type: %T", eventsAPIEvent.InnerEvent.Data) } default: - glog.V(8).Infof("Unsupported event type: %v", evt.Type) - } - } - glog.Infof("Exiting WatchChannelAndReply loop for channel %q", ghuChannel.ID) -} - -// SlackChannelAutoReplyLoop starts a loop to watch configured channels and reply to messages -func (s *Slack) SlackChannelAutoReplyLoop(conf *cfg.AppConfig) { - for _, ghUsersChannel := range conf.SlackConfig.GithubUsersChannels { - if ghUsersChannel.MessageReplyNotifications.NotifyUsers && ghUsersChannel.MessageReplyNotifications.MessageRegex != "" { - go s.WatchChannelAndReply(ghUsersChannel, conf) + glog.V(12).Infof("Unsupported Events API event type: %v", evt.Type) } } } diff --git a/main.go b/main.go index d43c1d0..b3f133b 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "flag" "fmt" "net/http" @@ -48,6 +49,8 @@ func healthHandler(w http.ResponseWriter, r *http.Request) { // Main web server func runMainWebServer(config cfg.AppConfig, listen string) { + glog.Infof("Starting main web server on %s", listen) + // Setup http router router := mux.NewRouter().StrictSlash(true) @@ -90,7 +93,7 @@ func prNotificationsCall(config *cfg.AppConfig, g *gh.Github, s *slack.Slack, pr // Skip the PR is it does not match additional conditions if !g.MatchesConditions(pr, prn) { - glog.V(8).Infof("PR-%d does not match conditions", *pr.Number) + glog.V(10).Infof("PR-%d does not match conditions", *pr.Number) continue } @@ -141,6 +144,9 @@ func main() { Version = "unknown" } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Init config config := cfg.AppConfig{} @@ -165,7 +171,10 @@ func main() { if err != nil { glog.Fatalf("Failed to load config file %q: %s", configFile, err.Error()) } + + // Some quick debug info glog.V(6).Infof("Loaded PR notifications: %+v", config.PrNotifications) + glog.V(6).Infof("Loaded Slack config: %+v", config.SlackConfig) // Init metric and cron config.Metrics = metrics.InitMetrics(Version) @@ -217,13 +226,17 @@ func main() { cronJob.AddFunc(prn.Schedule, func() { prNotificationsCall(&config, &ghClient, &slackClient, prn) }) glog.Infof("Added cronjob scheduler %d for %s/%s", id, prn.Owner, prn.Repo) } + cronJob.Start() // Start Slack to GitHub user mapping update loop go slackClient.SlackToGithubUpdateLoop(&config) - // Start Slack channel auto-reply loop - slackClient.SlackChannelAutoReplyLoop(&config) + // Start main web server in a separate goroutine + go runMainWebServer(config, listen) - cronJob.Start() - runMainWebServer(config, listen) + // Start Slack channel auto-reply loop and run the Slack SocketMode client blocking call + go slackClient.SlackSocketModeHandler(&config) + if err := slackClient.Client.RunContext(ctx); err != nil { + glog.Fatalf("Error running Slack socketmode: %v", err) + } }