diff --git a/config.go b/config.go index d16f943..cab27ce 100644 --- a/config.go +++ b/config.go @@ -1,10 +1,12 @@ package main import ( + "context" "errors" "fmt" - "io/ioutil" + "os" "strings" + "text/template" "gopkg.in/yaml.v2" ) @@ -70,12 +72,46 @@ func (cc ConfigChannel) String() string { // ConfigSlackSync represents a synchronization between a set of PagerDuty schedules and a Slack channel. type ConfigSlackSync struct { - Name string `yaml:"name"` - Schedules []ConfigSchedule `yaml:"schedules"` - Channel ConfigChannel `yaml:"channel"` - Template string `yaml:"template"` - PretendUsers bool `yaml:"pretendUsers"` - DryRun bool `yaml:"dryRun"` + Name string `yaml:"name"` + Schedules []ConfigSchedule `yaml:"schedules"` + Channel *ConfigChannel `yaml:"channel"` + Template *template.Template `yaml:"-"` + templateString string `yaml:"template"` + PretendUsers bool `yaml:"pretendUsers"` + DryRun bool `yaml:"dryRun"` +} + +func (css *ConfigSlackSync) populateChannel(_ context.Context, allChannels channelList) error { + if css.Channel == nil { + return nil + } + + slChannel := allChannels.find(css.Channel.ID, css.Channel.Name) + if slChannel == nil { + return fmt.Errorf("failed to find configured Slack channel %s", css.Channel) + } + + css.Channel = &ConfigChannel{ + ID: slChannel.ID, + Name: slChannel.Name, + } + + fmt.Printf("Slack sync %s: found Slack channel %q (ID %s)\n", css.Name, css.Channel.Name, css.Channel.ID) + return nil +} + +func (css *ConfigSlackSync) populateTemplate(_ context.Context) error { + if css.templateString == "" { + return nil + } + + var err error + css.Template, err = template.New("topic").Parse(css.templateString) + if err != nil { + return fmt.Errorf("failed to parse %s's template %q: %s", css.Name, css.templateString, err) + } + + return nil } type config struct { @@ -95,7 +131,7 @@ func generateConfig(p params) (config, error) { } } else { if p.tmplFile != "" { - b, err := ioutil.ReadFile(p.tmplFile) + b, err := os.ReadFile(p.tmplFile) if err != nil { return config{}, err } @@ -114,22 +150,18 @@ func generateConfig(p params) (config, error) { cfg.SlackSyncs[i].PretendUsers = *p.pretendUsers } } + if p.dryRun != nil { for i := range cfg.SlackSyncs { cfg.SlackSyncs[i].DryRun = *p.dryRun } } - err = validateConfig(&cfg) - if err != nil { - return config{}, err - } - return cfg, err } func readConfigFile(file string) (config, error) { - content, err := ioutil.ReadFile(file) + content, err := os.ReadFile(file) if err != nil { return config{}, err } @@ -142,11 +174,13 @@ func readConfigFile(file string) (config, error) { func singleSlackSync(p params) (config, error) { slackSync := ConfigSlackSync{ Name: "default", - Channel: ConfigChannel{ + } + + if len(p.channelID) != 0 || len(p.channelName) != 0 { + slackSync.Channel = &ConfigChannel{ ID: p.channelID, Name: p.channelName, - }, - Template: p.tmplString, + } } for _, schedule := range p.schedules { cfgSchedule, err := parseSchedule(schedule) @@ -229,7 +263,7 @@ func parseSchedule(schedule string) (ConfigSchedule, error) { return cfgSchedule, nil } -func validateConfig(cfg *config) error { +func (cfg *config) validateConfig() error { foundNames := map[string]bool{} for _, sync := range cfg.SlackSyncs { if _, ok := foundNames[sync.Name]; ok { @@ -248,8 +282,8 @@ func validateConfig(cfg *config) error { } } - channelGiven := sync.Channel.ID != "" || sync.Channel.Name != "" - if sync.Template != "" { + channelGiven := sync.Channel != nil + if sync.Template != nil { if !channelGiven { return fmt.Errorf("slack sync %q invalid: must specify either channel ID or channel name when topic is given", sync.Name) } diff --git a/config_test.go b/config_test.go index b194f16..8f1886e 100644 --- a/config_test.go +++ b/config_test.go @@ -1,6 +1,8 @@ package main import ( + "context" + "encoding/json" "strings" "testing" @@ -53,7 +55,7 @@ func TestParseSchedule(t *testing.T) { name: "valid schedule with all user group specifiers", inSchedule: "id=schedule;userGroup=id=123;userGroup=name=user group 2;userGroup=handle=my-ug", wantCfg: ConfigSchedule{ - ID: "schedule", + ID: "schedule", UserGroups: UserGroups{ { ID: "123", @@ -86,3 +88,58 @@ func TestParseSchedule(t *testing.T) { }) } } + +func TestPopulateChannel(t *testing.T) { + allChannels := channelList{} + err := json.Unmarshal([]byte(`[{ "id": "1", "name": "Foo" }, { "name": "Bar", "id": "2" }]`), &allChannels) + if err != nil { + t.Fatalf("Unable to convert json to slack: %s", err) + } + + tests := []struct { + title string + name string + id string + wantErrStr string + wantChannel *ConfigChannel + }{ + { + title: "no match", + name: "foo", + wantErrStr: `failed to find configured Slack channel {ID: Name:"foo"}`, + }, + { + title: "By Name", + name: "Foo", + wantChannel: &ConfigChannel{Name: "Foo", ID: "1"}, + }, + { + title: "By ID", + id: "2", + wantChannel: &ConfigChannel{Name: "Bar", ID: "2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.title, func(t *testing.T) { + cfg := &ConfigSlackSync{ + Channel: &ConfigChannel{ + ID: tt.id, + Name: tt.name, + }, + } + err := cfg.populateChannel(context.Background(), allChannels) + if tt.wantErrStr != "" { + var gotErrStr string + if err != nil { + gotErrStr = err.Error() + } + if !strings.Contains(gotErrStr, tt.wantErrStr) { + t.Errorf("got error string %q, want %q", gotErrStr, tt.wantErrStr) + } + } else if diff := cmp.Diff(tt.wantChannel, cfg.Channel); diff != "" { + t.Errorf("ConfigChannel mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/main.go b/main.go index 90fbeef..be6ed8b 100644 --- a/main.go +++ b/main.go @@ -62,6 +62,7 @@ By default, the program will terminate after a single run. Use the --daemon flag &cli.StringFlag{ Name: "config", Usage: "config file to use", + EnvVars: []string{"CONFIG_FILE"}, Destination: &p.config, }, &cli.StringSliceFlag{ @@ -179,11 +180,33 @@ func realMain(p params) error { } fmt.Printf("Found %d Slack user group(s)\n", len(sp.slackUserGroups)) + fmt.Println("Getting Slack channels") + slChannels, err := sp.slClient.getChannels(ctx) + if err != nil { + return fmt.Errorf("failed to get channels: %s", err) + } + fmt.Printf("Got %d Slack channel(s)\n", len(slChannels)) + + for _, cfgSlSync := range cfg.SlackSyncs { + if err := cfgSlSync.populateChannel(ctx, slChannels); err != nil { + return fmt.Errorf("failed to populate channel %s", err) + } + + if err := cfgSlSync.populateTemplate(ctx); err != nil { + return fmt.Errorf("failed to populate templates: %s", err) + } + } + slSyncs, err := sp.createSlackSyncs(ctx, cfg) if err != nil { return fmt.Errorf("failed to create Slack syncs: %s", err) } + err = cfg.validateConfig() + if err != nil { + return fmt.Errorf("failed to validate config: %s", err) + } + syncer := newSyncer(sp) runFunc := func() error { diff --git a/syncer.go b/syncer.go index 3cbf6e2..f4020e0 100644 --- a/syncer.go +++ b/syncer.go @@ -14,9 +14,10 @@ type runSlackSync struct { name string pdSchedules pdSchedules slackChannelID string - tmpl *template.Template + topicTemplate *template.Template dryRun bool pretendUsers bool + slChannels *channelList } type syncerParams struct { @@ -29,13 +30,6 @@ type syncerParams struct { func (sp syncerParams) createSlackSyncs(ctx context.Context, cfg config) ([]runSlackSync, error) { var slSyncs []runSlackSync - fmt.Println("Getting Slack channels") - slChannels, err := sp.slClient.getChannels(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get channels: %s", err) - } - fmt.Printf("Got %d Slack channel(s)\n", len(slChannels)) - for _, cfgSlSync := range cfg.SlackSyncs { slSync := runSlackSync{ name: cfgSlSync.Name, @@ -43,22 +37,8 @@ func (sp syncerParams) createSlackSyncs(ctx context.Context, cfg config) ([]runS dryRun: cfgSlSync.DryRun, } - if cfgSlSync.Template == "" { + if cfgSlSync.Template == nil { fmt.Printf("Slack sync %s: skipping topic handling because template is undefined\n", slSync.name) - } else { - var err error - slSync.tmpl, err = template.New("topic").Parse(cfgSlSync.Template) - if err != nil { - return nil, fmt.Errorf("failed to create slack sync %q: failed to parse template %q: %s", slSync.name, cfgSlSync.Template, err) - } - - cfgChannel := cfgSlSync.Channel - slChannel := slChannels.find(cfgChannel.ID, cfgChannel.Name) - if slChannel == nil { - return nil, fmt.Errorf("failed to create slack sync %q: failed to find configured Slack channel %s", slSync.name, cfgChannel) - } - slSync.slackChannelID = slChannel.ID - fmt.Printf("Slack sync %s: found Slack channel %q (ID %s)\n", slSync.name, slChannel.Name, slChannel.ID) } pdSchedules := pdSchedules{} @@ -127,20 +107,58 @@ func (s *syncer) Run(ctx context.Context, slackSyncs []runSlackSync, failFast bo return nil } -func (s *syncer) runSlackSync(ctx context.Context, slackSync runSlackSync) error { - if !slackSync.dryRun { - joined, err := s.slClient.joinChannel(ctx, slackSync.slackChannelID) - if err != nil { - if strings.Contains(err.Error(), "missing_scope") { - fmt.Printf(`cannot automatically join channel with ID %s because of missing scope "channels:join" -- please add the scope or join pdsync manually`, slackSync.slackChannelID) - } else { - return fmt.Errorf("failed to join channel with ID %s: %s", slackSync.slackChannelID, err) - } - } - if joined { - fmt.Printf("joined channel with ID %s\n", slackSync.slackChannelID) +func (s *syncer) joinChannel(ctx context.Context, slackSync runSlackSync) error { + if slackSync.dryRun { + return nil + } + + if len(slackSync.slackChannelID) == 0 { + fmt.Printf("no channel for %s slack sync, so skip joining", slackSync.name) + return nil + } + + joined, err := s.slClient.joinChannel(ctx, slackSync.slackChannelID) + if err != nil { + if strings.Contains(err.Error(), "missing_scope") { + fmt.Printf(`cannot automatically join channel with ID %s because of missing scope "channels:join" -- please add the scope or join pdsync manually`, slackSync.slackChannelID) + } else { + return fmt.Errorf("failed to join channel with ID %s: %s", slackSync.slackChannelID, err) } } + if joined { + fmt.Printf("joined channel with ID %s\n", slackSync.slackChannelID) + } + + return nil +} + +func (s *syncer) updateTopic(ctx context.Context, slackSync runSlackSync, slackUserIDByScheduleName map[string]string) error { + if slackSync.dryRun { + return nil + } + + if slackSync.topicTemplate == nil { + fmt.Println("Skipping topic update") + return nil + } + + var buf bytes.Buffer + fmt.Printf("Executing template with Slack user IDs by schedule name: %s\n", slackUserIDByScheduleName) + err := slackSync.topicTemplate.Execute(&buf, slackUserIDByScheduleName) + if err != nil { + return fmt.Errorf("failed to render template: %s", err) + } + + topic := buf.String() + err = s.slClient.updateTopic(ctx, slackSync.slackChannelID, topic, slackSync.dryRun) + if err != nil { + return fmt.Errorf("failed to update topic: %s", err) + } + return nil +} + +func (s *syncer) runSlackSync(ctx context.Context, slackSync runSlackSync) error { + s.joinChannel(ctx, slackSync) ocgs := oncallGroups{} slackUserIDByScheduleName := map[string]string{} @@ -174,21 +192,8 @@ func (s *syncer) runSlackSync(ctx context.Context, slackSync runSlackSync) error return fmt.Errorf("failed to update on-call user group members: %s", err) } - if slackSync.tmpl == nil { - fmt.Println("Skipping topic update") - } else { - var buf bytes.Buffer - fmt.Printf("Executing template with Slack user IDs by schedule name: %s\n", slackUserIDByScheduleName) - err := slackSync.tmpl.Execute(&buf, slackUserIDByScheduleName) - if err != nil { - return fmt.Errorf("failed to render template: %s", err) - } - - topic := buf.String() - err = s.slClient.updateTopic(ctx, slackSync.slackChannelID, topic, slackSync.dryRun) - if err != nil { - return fmt.Errorf("failed to update topic: %s", err) - } + if err := s.updateTopic(ctx, slackSync, slackUserIDByScheduleName); err != nil { + return fmt.Errorf("failed to channel template: %s", err) } return nil