tvl-depot/fun/clbot/clbot.go
Profpatsch 9559ef56e3 feat(fun/clbot,ops/machines/whitby): filter tvix-dev clbot
In #tvix-dev, we want to display only CLs that relate to tvix and
related projects.

So use a pretty dumb allow-list for which CLs to display in that
channel.

Change-Id: I3ef50b64e3d7fbc27a6690be6a10f1b55c04cd6e
Reviewed-on: https://cl.tvl.fyi/c/depot/+/11658
Reviewed-by: flokli <flokli@flokli.de>
Reviewed-by: lukegb <lukegb@tvl.fyi>
Tested-by: BuildkiteCI
2024-06-03 19:35:34 +00:00

305 lines
8.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"context"
"crypto/tls"
"flag"
"fmt"
"io/ioutil"
"net"
"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", "irc.hackint.org:6697", "IRC server to connect to")
ircTls = flag.Bool("irc_tls", false, "Does the server connection need TLS?")
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{}
neverPing = flag.String("never_ping", "marcus", "Comma-separated terms that should never ping users")
onlyDisplay = flag.String("only_display", "", "Comma-separated substrings of the gerrit CL Change Subject that should be shown (everything else is dropped)")
)
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("%v", 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()
var ircConn net.Conn
var err error
if *ircTls {
ircConn, err = tls.Dial("tcp", *ircServer, nil)
} else {
ircConn, err = net.Dial("tcp", *ircServer)
}
if err != nil {
log.Errorf("connecting to IRC at tcp/%s (tls: %v): %v", *ircServer, *ircTls, 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(a gerritevents.Account) string {
options := []string{
a.Username,
a.Name,
a.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.
//
// Also applies noping to all instances of the words in `neverPing`.
func nopingAll(username, message string) string {
for _, word := range strings.Split(*neverPing, ",") {
message = strings.ReplaceAll(message, word, noping(word))
}
return strings.ReplaceAll(message, username, noping(username))
}
// changeShouldBeSkipped applies the list of channels in `onlyDisplay`
// to whether we should skip displaying a CL.
func changeShouldBeSkipped(onlyDisplay string, changeSubject string) bool {
// case when we dont want to filter
if onlyDisplay == "" {
return false
}
for _, needle := range strings.Split(onlyDisplay, ",") {
if strings.Contains(changeSubject, needle) {
return false
}
}
return true
}
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 || changeShouldBeSkipped(*onlyDisplay, e.Change.Subject) {
continue
}
user := username(e.PatchSet.Uploader)
parsedMsg = nopingAll(user, fmt.Sprintf("CL/%d proposed by %s - %s - %s", e.Change.Number, user, e.Change.Subject, patchSetURL(e.Change, e.PatchSet)))
case *gerritevents.ChangeMerged:
if e.Change.Project != *notifyRepo || !notifyBranches[e.Change.Branch] || changeShouldBeSkipped(*onlyDisplay, e.Change.Subject) {
continue
}
owner := username(e.Change.Owner)
submitter := e.Submitter.Username
url := patchSetURL(e.Change, e.PatchSet)
if submitter != owner && submitter == "clbot" {
parsedMsg = nopingAll(owner, fmt.Sprintf("CL/%d by %s autosubmitted - %s - %s", e.Change.Number, owner, e.Change.Subject, url))
} else {
parsedMsg = nopingAll(owner, fmt.Sprintf("CL/%d applied by %s - %s - %s", e.Change.Number, owner, e.Change.Subject, url))
}
}
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()
}