tvl-depot/fun/clbot/clbot.go
Vincent Ambo b508605084 fix(clbot): Avoid pinging users for their own user folder CLs
Instead of only "nopinging" the username in the templated message,
replace all instances of the CL owner's name with one that does
not (tries to not) highlight them.

This way, CLs sent to another user's folder will still highlight them.

Change-Id: I9a3d8563ab32befc1a1b1412851026343c170dd3
Reviewed-on: https://cl.tvl.fyi/c/depot/+/2688
Tested-by: BuildkiteCI
Reviewed-by: sterni <sternenseemann@systemli.org>
Reviewed-by: glittershark <grfn@gws.fyi>
Reviewed-by: lukegb <lukegb@tvl.fyi>
2021-03-28 01:21:42 +00:00

262 lines
7.4 KiB
Go

package main
import (
"context"
"crypto/tls"
"flag"
"fmt"
"io/ioutil"
"os"
"os/signal"
"strings"
"time"
"code.tvl.fyi/fun/clbot/backoffutil"
"code.tvl.fyi/fun/clbot/gerrit"
"code.tvl.fyi/fun/clbot/gerrit/gerritevents"
log "github.com/golang/glog"
"golang.org/x/crypto/ssh"
"gopkg.in/irc.v3"
)
var (
gerritAddr = flag.String("gerrit_host", "cl.tvl.fyi:29418", "Gerrit SSH host:port")
gerritSSHHostKey = flag.String("gerrit_ssh_pubkey", "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBIUNYBYPCCBNDFSd0BuCR+8kgeuJ7IA5S2nTNQmkQUYNyXK+ot5os7rHtCk96+grd5+J8jFCuFBWisUe8h8NC0Q=", "Gerrit SSH public key")
gerritSSHTimeout = flag.Duration("gerrit_tcp_timeout", 5*time.Second, "Gerrit SSH TCP connect timeout")
gerritAuthUsername = flag.String("gerrit_ssh_auth_username", "", "Gerrit SSH username")
gerritAuthKeyPath = flag.String("gerrit_ssh_auth_key", "", "Gerrit SSH private key path")
ircServer = flag.String("irc_server", "chat.freenode.net:7000", "IRC server to connect to")
ircNick = flag.String("irc_nick", "clbot", "Nick to use when connecting to IRC")
ircUser = flag.String("irc_user", "clbot", "User string to use for IRC")
ircName = flag.String("irc_name", "clbot", "Name string to use for IRC")
ircChannel = flag.String("irc_channel", "##tvl", "Channel to send messages to")
ircPassword = flag.String("irc_pass", "", "Password to use for IRC")
ircSendLimit = flag.Duration("irc_send_limit", 100*time.Millisecond, "Delay between messages")
ircSendBurst = flag.Int("irc_send_burst", 10, "Number of messages which can be sent in a burst")
notifyRepo = flag.String("notify_repo", "depot", "Repo name to notify about")
notifyBranches = stringSetFlag{}
)
func init() {
flag.Var(&notifyBranches, "notify_branches", "Branch names (comma-separated, or repeated flags, or both) to notify users about")
}
type stringSetFlag map[string]bool
func (f stringSetFlag) String() string {
return fmt.Sprintf("%q", map[string]bool(f))
}
func (f stringSetFlag) Set(s string) error {
if s == "" {
return nil
}
for _, k := range strings.Split(s, ",") {
if k != "" {
f[k] = true
}
}
return nil
}
func mustFixedHostKey(f string) ssh.HostKeyCallback {
pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(f))
if err != nil {
log.Exitf("ParseAuthorizedKey(%q): %v", f, err)
}
return ssh.FixedHostKey(pk)
}
func mustPrivateKey(p string) ssh.AuthMethod {
pkBytes, err := ioutil.ReadFile(p)
if err != nil {
log.Exitf("reading SSH private key from %q: %v", p, err)
}
pk, err := ssh.ParsePrivateKey(pkBytes)
if err != nil {
log.Exitf("parsing private key from %q: %v", p, err)
}
return ssh.PublicKeys(pk)
}
var shutdownFuncs []func()
func callOnShutdown(f func()) {
shutdownFuncs = append(shutdownFuncs, f)
}
// Unicode U+200B zero-width-space, to avoid triggering other bots
// or highlighting people on IRC.
const zeroWidthSpace = "\u200b"
func runIRC(ctx context.Context, ircCfg irc.ClientConfig, sendMsg <-chan string) {
bo := backoffutil.NewDefaultBackOff()
ircCfg.Handler = irc.HandlerFunc(func(c *irc.Client, m *irc.Message) {
if m.Command == "NOTICE" && m.Prefix.Name == "NickServ" && strings.Contains(m.Trailing(), "dentified") {
// We're probably identified now, go join the channel.
c.Writef("JOIN %s", *ircChannel)
}
})
for {
timer := time.NewTimer(bo.NextBackOff())
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
break
}
(func() {
connectedStart := time.Now()
ircConn, err := tls.Dial("tcp", *ircServer, nil)
if err != nil {
log.Errorf("connecting to IRC at tcp/%s: %v", *ircServer, err)
return
}
ircClient := irc.NewClient(ircConn, ircCfg)
ircClientCtx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
for {
select {
case <-ircClientCtx.Done():
return
case msg := <-sendMsg:
log.Infof("sending message %q to %v", msg, *ircChannel)
ircClient.Writef("PRIVMSG %s :%s%s", *ircChannel, zeroWidthSpace, msg)
}
}
}()
log.Infof("connecting to IRC on tcp/%s", *ircServer)
if err := ircClient.RunContext(ircClientCtx); err != nil {
connectedEnd := time.Now()
connectedFor := connectedEnd.Sub(connectedStart)
if connectedFor > 60*time.Second {
bo.Reset()
}
log.Errorf("IRC RunContext: %v", err)
return
}
})()
}
}
func username(p gerritevents.PatchSet) string {
options := []string{
p.Uploader.Username,
p.Uploader.Name,
p.Uploader.Email,
}
for _, opt := range options {
if opt != "" {
return opt
}
}
return "UNKNOWN USER"
}
// noping inserts a Unicode zero-width space between the first and rest characters of `user`
// in an effort to avoid pinging that user on IRC.
func noping(user string) string {
un := []rune(user)
return string(un[0:1]) + zeroWidthSpace + string(un[1:])
}
// Apply noping to each instance of the username in the supplied
// message. With this users will not be pinged for their own CLs, but
// they will be notified if someone else writes a CL that includes
// their username.
func nopingAll(username, message string) string {
return strings.ReplaceAll(message, username, noping(username))
}
func patchSetURL(c gerritevents.Change, p gerritevents.PatchSet) string {
return fmt.Sprintf("https://cl.tvl.fyi/%d", c.Number)
}
func main() {
flag.Parse()
failed := false
if *gerritAuthUsername == "" {
log.Errorf("gerrit_ssh_auth_username must be set")
failed = true
}
if *gerritAuthKeyPath == "" {
log.Errorf("gerrit_ssh_auth_key must be set")
failed = true
}
if failed {
os.Exit(2)
}
shutdownCh := make(chan os.Signal)
signal.Notify(shutdownCh, os.Interrupt)
go func() {
<-shutdownCh
signal.Reset(os.Interrupt)
for n := len(shutdownFuncs) - 1; n >= 0; n-- {
shutdownFuncs[n]()
}
}()
ctx, cancel := context.WithCancel(context.Background())
callOnShutdown(cancel)
cfg := &ssh.ClientConfig{
User: *gerritAuthUsername,
Auth: []ssh.AuthMethod{mustPrivateKey(*gerritAuthKeyPath)},
HostKeyCallback: mustFixedHostKey(*gerritSSHHostKey),
Timeout: *gerritSSHTimeout,
}
cfg.SetDefaults()
gw, err := gerrit.New(ctx, "tcp", *gerritAddr, cfg)
if err != nil {
log.Exitf("gerrit.New(%q): %v", *gerritAddr, err)
}
callOnShutdown(func() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
gw.Close(ctx)
})
sendMsgChan := make(chan string, 5)
go func() {
for e := range gw.Events() {
var parsedMsg string
switch e := e.(type) {
case *gerritevents.PatchSetCreated:
if e.Change.Project != *notifyRepo || !notifyBranches[e.Change.Branch] || e.PatchSet.Number != 1 {
continue
}
user := username(e.PatchSet)
parsedMsg = nopingAll(user, fmt.Sprintf("CL/%d: %q proposed by %s - %s", e.Change.Number, e.Change.Subject, user, patchSetURL(e.Change, e.PatchSet)))
case *gerritevents.ChangeMerged:
if e.Change.Project != *notifyRepo || !notifyBranches[e.Change.Branch] {
continue
}
user := username(e.PatchSet)
parsedMsg = nopingAll(user, fmt.Sprintf("CL/%d: %q applied by %s - %s", e.Change.Number, e.Change.Subject, user, patchSetURL(e.Change, e.PatchSet)))
}
if parsedMsg != "" {
sendMsgChan <- parsedMsg
}
}
}()
ircCtx, ircCancel := context.WithCancel(ctx)
callOnShutdown(ircCancel)
go runIRC(ircCtx, irc.ClientConfig{
Nick: *ircNick,
User: *ircUser,
Name: *ircName,
Pass: *ircPassword,
SendLimit: *ircSendLimit,
SendBurst: *ircSendBurst,
}, sendMsgChan)
<-ctx.Done()
}