Skip to content

Commit

Permalink
Add support for user groups
Browse files Browse the repository at this point in the history
Allow Slack user groups to be specified per schedule that get updated
automatically to point to the current on-call personnel.
  • Loading branch information
timoreimann committed Jan 4, 2021
1 parent 9783c03 commit 6b7257a
Show file tree
Hide file tree
Showing 74 changed files with 16,808 additions and 71 deletions.
56 changes: 41 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@

_pdsync_ is a tool to synchronize on-call schedules in [PagerDuty](https://www.pagerduty.com/) into third-party systems.

Right now, the only supported target is Slack: given a list of PageDuty schedules, a Slack channel, and a template, _pdsync_ will periodically poll the on-call personnel on the schedules and update the Slack channel's topic. The template accepts a variable that matches a PageDuty schedule name to fill in the corresponding on-call Slack handles.
Right now, the only supported target is Slack: given a list of PageDuty schedules, a Slack channel, and a template, _pdsync_ will periodically poll the on-call personnel on the schedules and update the Slack channel's topic. The template accepts a variable that matches a PageDuty schedule name to fill in the corresponding on-call Slack handles. Additionally, pre-existing user groups can be updated automatically to always point to the current on-call personnel.

You will need to create a Slack app with the following permissions
## How-to

- `users:read`
- `channels:read`
- `channels:manage`
- `groups:read`
- `groups:write`
You will need to create a Slack app with the following scopes

(`groups.*` is only needed for private channels)
| Scope | Optional | Used for |
|--------------------|----------|-----------------------------------|
| `users:read` | no | |
| `channels:read` | no | |
| `channels:manage` | no | |
| `groups:read` | yes | interacting with private channels |
| `groups:write` | yes | interacting with private channels |
| `usergroups:read` | yes | managing user groups |
| `usergroups:write` | yes | managing user groups |

and invite it to the target channel.

Expand All @@ -23,10 +27,19 @@ slackSyncs:
# name must be unique across all given syncs
- name: team-awesome
schedules:
# a schedule can be given by name...
# a schedule can be given by name
- name: Awesome-Primary
# ...or by ID
# a schedule may optionally have one or more user groups that get updated with the on-call personnel
userGroups:
# choose one of `id`, `name`, or `handle` to reference a pre-existing user group
- name: Team Awesome On-call (all)
- handle: team-awesome-on-call-primary
# alternatively, the schedule can be given by ID (the name is assumed to be Awesome-Secondary and referenced in the template below)
- id: D34DB33F
userGroups:
# the first user group is also defined in the primary schedule above
- name: Team Awesome On-call (all)
- handle: team-awesome-on-call-secondary
channel:
name: awesome
# a channel can also be provided by ID:
Expand All @@ -37,7 +50,10 @@ slackSyncs:
# template variables support alphanumeric characters only, so pdsync
# strips off all illegal characters.
template: |-
primary on-call: <@{{.AwesomePrimary}}> secondary on-call: <@{{.AwesomeSecondary}}>
primary on-call: <@{{.AwesomePrimary}}> (Slack handle: @team-awesome-on-call-primary)
secondary on-call: <@{{.AwesomeSecondary}}> (Slack handle: @team-awesome-on-call-secondary)
reach out to both primary and secondary via @team-awesome-on-call
# Set to true to skip updating the Slack channel topic
dryRun: false
```
Expand All @@ -52,16 +68,26 @@ This will update the topic of the `awesome` Slack channel mentioning the primary

**Note:** Go template variables take alphanumeric names only. _pdsync_ exposes channel names without unsupported characters in the template variables, which is why you will need to use `{{.AwesomePrimary}}` (as opposed to `{{.Awesome-Primary}}`) in the example above.

It is also possible to specify a single slack sync through CLI parameters:
The example will also update three Slack user groups to make it easy to ping the current primary, secondary, and all on-call personnel.

For simple cases and testing purposes, it is also possible to specify a single slack sync through CLI parameters:

```shell
pdsync --schedule-names Awesome-Primary --schedule-ids D34DB33F --channel-name awesome --template 'primary on-call: <@{{.AwesomePrimary}}> secondary on-call: <@{{.AwesomeSecondary}}>'
pdsync --schedule 'name=Awesome-Primary;userGroup=handle=team-awesome-oncall-primary' --schedule='id=D34DB33F;userGroup=name=Team Awesome On-call Secondary' --channel-name awesome --template 'primary on-call: <@{{.AwesomePrimary}}> secondary on-call: <@{{.AwesomeSecondary}}>'
```

(Add `--dry-run` to make this a no-op.)
The `--schedule` flag consists of a series of the following key/value pairs:

- `id=<schedule reference>`: the ID of a PagerDuty schedule (mutually exclusive with `name=` below)
- `name=<schedule reference>`: the name of a PagerDuty schedule (mutually exclusive with `id=` above)
- `userGroup=<key identifier>=<user group reference>`: the `id`, `name`, or `handle` (i.e., the `<key identifier>`) of a user group; can be repeated to reference multiple user groups

Add `--dry-run` to turn all mutating API requests into no-ops.

Run the tool with `--help` for details.

## status

This tool is newish, full of bugs, and without tests. Use it at your own risk but do provide feedback!
This tool is newish, has too few tests, and is possibly buggy. However, it did get quite some mileage already.

All things considered, use it at your own risk -- but do provide feedback.
24 changes: 18 additions & 6 deletions config.example.yaml
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
slackSyncs:
# name must be unique across all given syncs
# name must be unique across all given syncs
- name: team-awesome
schedules:
# a schedule can be given by name...
- name: Awesome-Primary
# ...or by ID
- id: D34DB33F
# a schedule can be given by name
- name: Awesome-Primary
# a schedule may optionally have one or more user groups that get updated with the on-call personnel
userGroups:
# choose one of `id`, `name`, or `handle` to reference a pre-existing user group
- name: Team Awesome On-call (all)
- handle: team-awesome-on-call-primary
# alternatively, the schedule can be given by ID (the name is assumed to be Awesome-Secondary and referenced in the template below)
- id: D34DB33F
userGroups:
# the first user group is also defined in the primary schedule above
- name: Team Awesome On-call (all)
- handle: team-awesome-on-call-secondary
channel:
name: awesome
# a channel can also be provided by ID:
Expand All @@ -16,6 +25,9 @@ slackSyncs:
# template variables support alphanumeric characters only, so pdsync
# strips off all illegal characters.
template: |-
primary on-call: <@{{.AwesomePrimary}}> secondary on-call: <@{{.AwesomeSecondary}}>
primary on-call: <@{{.AwesomePrimary}}> (Slack handle: @team-awesome-on-call-primary)
secondary on-call: <@{{.AwesomeSecondary}}> (Slack handle: @team-awesome-on-call-secondary)
reach out to both primary and secondary via @team-awesome-on-call
# Set to true to skip updating the Slack channel topic
dryRun: false
140 changes: 136 additions & 4 deletions config.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,61 @@
package main

import (
"errors"
"fmt"
"io/ioutil"
"strings"

"gopkg.in/yaml.v2"
)

// ConfigSchedule represents a PagerDuty schedule identified by either ID or name.
type ConfigSchedule struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
ID string `yaml:"id"`
Name string `yaml:"name"`
UserGroups UserGroups `yaml:"userGroups"`
}

func (cs ConfigSchedule) String() string {
return fmt.Sprintf("{ID:%s Name:%q}", cs.ID, cs.Name)
}

type UserGroups []UserGroup

func (ugs UserGroups) find(ug2 UserGroup) *UserGroup {
for _, ug := range ugs {
if (ug2.ID != "" && ug.ID == ug2.ID) ||
(ug2.Name != "" && ug.Name == ug2.Name) ||
(ug2.Handle != "" && ug.Handle == ug2.Handle) {
return &ug
}
}
return nil
}

func (ugs *UserGroups) getOrCreate(ug2 UserGroup) *UserGroup {
if ugs == nil {

}

for _, ug := range *ugs {
if (ug2.ID != "" && ug.ID == ug2.ID) ||
(ug2.Name != "" && ug.Name == ug2.Name) ||
(ug2.Handle != "" && ug.Handle == ug2.Handle) {
return &ug
}
}
return nil
}

type UserGroup struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Handle string `yaml:"handle"`
}

func (ug UserGroup) String() string {
return fmt.Sprintf("{ID:%s Name:%q Handle:%s}", ug.ID, ug.Name, ug.Handle)
}

// ConfigChannel represents a Slack channel identified by either ID or name.
Expand Down Expand Up @@ -51,7 +96,10 @@ func generateConfig(p params) (config, error) {
}
p.tmplString = string(b)
}
cfg = singleSlackSync(p)
cfg, err = singleSlackSync(p)
if err != nil {
return config{}, err
}
}

// If specified, let the global dry-run parameter override per-sync dry-run
Expand Down Expand Up @@ -81,7 +129,7 @@ func readConfigFile(file string) (config, error) {
return cfg, err
}

func singleSlackSync(p params) config {
func singleSlackSync(p params) (config, error) {
slackSync := ConfigSlackSync{
Name: "default",
Channel: ConfigChannel{
Expand All @@ -90,6 +138,13 @@ func singleSlackSync(p params) config {
},
Template: p.tmplString,
}
for _, schedule := range p.schedules {
cfgSchedule, err := parseSchedule(schedule)
if err != nil {
return config{}, err
}
slackSync.Schedules = append(slackSync.Schedules, cfgSchedule)
}
for _, scheduleID := range p.scheduleIDs {
slackSync.Schedules = append(slackSync.Schedules, ConfigSchedule{ID: scheduleID})
}
Expand All @@ -99,7 +154,79 @@ func singleSlackSync(p params) config {

return config{
SlackSyncs: []ConfigSlackSync{slackSync},
}, nil
}

func parseSchedule(schedule string) (ConfigSchedule, error) {
kvs := map[string][]string{}
for _, elem := range strings.Split(schedule, ";") {
kv := strings.SplitN(elem, "=", 2)
if len(kv) < 2 {
return ConfigSchedule{}, fmt.Errorf("missing separator on element %q", elem)
}
key := kv[0]
value := kv[1]
kvs[key] = append(kvs[key], value)
}

var id string
if ids := kvs["id"]; len(ids) > 0 {
if len(ids) > 1 {
return ConfigSchedule{}, errors.New(`multiple values for key "id" not allowed`)
}
id = ids[0]
delete(kvs, "id")
}
var name string
if names := kvs["name"]; len(names) > 0 {
if len(names) > 1 {
return ConfigSchedule{}, errors.New(`multiple values for key "name" not allowed`)
}
name = names[0]
delete(kvs, "name")
}

if id == "" && name == "" {
return ConfigSchedule{}, errors.New(`one of "id" or "name" must be given`)
}

if id != "" && name != "" {
return ConfigSchedule{}, errors.New(`"id" and "name" cannot be specified simultaneously`)
}

cfgSchedule := ConfigSchedule{
ID: id,
Name: name,
}

for _, userGroup := range kvs["userGroup"] {
kv := strings.Split(userGroup, "=")
if len(kv) != 2 {
return ConfigSchedule{}, fmt.Errorf("user group %s does not follow key=value pattern", userGroup)
}
ugKey := kv[0]
ugValue := kv[1]

var ug UserGroup
switch ugKey {
case "id":
ug.ID = ugValue
case "name":
ug.Name = ugValue
case "handle":
ug.Handle = ugValue
default:
return ConfigSchedule{}, fmt.Errorf("user group %s has unexpected key %q", userGroup, ugKey)
}
cfgSchedule.UserGroups = append(cfgSchedule.UserGroups, ug)
}
delete(kvs, "userGroup")

if len(kvs) > 0 {
return ConfigSchedule{}, fmt.Errorf("unsupported key/value pairs left: %s", kvs)
}

return cfgSchedule, nil
}

func validateConfig(cfg *config) error {
Expand All @@ -114,6 +241,11 @@ func validateConfig(cfg *config) error {
if cfgSchedule.ID == "" && cfgSchedule.Name == "" {
return fmt.Errorf("slack sync %q invalid: must specify either schedule ID or schedule name", sync.Name)
}
for _, cfgUserGroup := range cfgSchedule.UserGroups {
if cfgUserGroup.ID == "" && cfgUserGroup.Name == "" && cfgUserGroup.Handle == "" {
return fmt.Errorf("slack sync %q user group %s invalid: must specify either user group ID or user group name or user group handle", sync.Name, cfgUserGroup)
}
}
}

if sync.Channel.ID == "" && sync.Channel.Name == "" {
Expand Down
Loading

0 comments on commit 6b7257a

Please sign in to comment.