2020-06-14 00:43:09 +02:00
package main
import (
"context"
2020-06-14 22:51:53 +02:00
"crypto/tls"
2020-06-14 00:43:09 +02:00
"flag"
2020-06-14 22:51:53 +02:00
"fmt"
2020-06-14 00:43:09 +02:00
"io/ioutil"
2021-05-22 15:56:47 +02:00
"net"
2020-06-14 00:43:09 +02:00
"os"
2020-06-14 21:17:50 +02:00
"os/signal"
2020-06-14 22:51:53 +02:00
"strings"
2020-06-14 00:43:09 +02:00
"time"
2020-06-14 22:51:53 +02:00
"code.tvl.fyi/fun/clbot/backoffutil"
2020-06-14 00:43:09 +02:00
"code.tvl.fyi/fun/clbot/gerrit"
2020-06-14 22:51:53 +02:00
"code.tvl.fyi/fun/clbot/gerrit/gerritevents"
2020-06-14 00:43:09 +02:00
log "github.com/golang/glog"
"golang.org/x/crypto/ssh"
2020-06-14 22:51:53 +02:00
"gopkg.in/irc.v3"
2020-06-14 00:43:09 +02:00
)
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" )
2020-06-14 22:51:53 +02:00
gerritAuthUsername = flag . String ( "gerrit_ssh_auth_username" , "" , "Gerrit SSH username" )
gerritAuthKeyPath = flag . String ( "gerrit_ssh_auth_key" , "" , "Gerrit SSH private key path" )
2021-05-22 22:55:41 +02:00
ircServer = flag . String ( "irc_server" , "irc.hackint.org:6697" , "IRC server to connect to" )
2021-05-22 15:56:47 +02:00
ircTls = flag . Bool ( "irc_tls" , false , "Does the server connection need TLS?" )
2020-06-14 22:51:53 +02:00
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" )
2021-05-22 22:55:41 +02:00
ircChannel = flag . String ( "irc_channel" , "#tvl" , "Channel to send messages to" )
2020-06-14 22:51:53 +02:00
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" )
2020-06-24 03:39:05 +02:00
notifyRepo = flag . String ( "notify_repo" , "depot" , "Repo name to notify about" )
notifyBranches = stringSetFlag { }
2022-01-28 12:07:05 +01:00
2024-05-13 17:54:34 +02:00
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)" )
2020-06-14 00:43:09 +02:00
)
2020-06-24 03:39:05 +02:00
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 {
2024-05-27 09:44:55 +02:00
return fmt . Sprintf ( "%v" , map [ string ] bool ( f ) )
2020-06-24 03:39:05 +02:00
}
func ( f stringSetFlag ) Set ( s string ) error {
if s == "" {
return nil
}
for _ , k := range strings . Split ( s , "," ) {
if k != "" {
f [ k ] = true
}
}
return nil
}
2020-06-14 00:43:09 +02:00
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 )
}
2020-06-14 21:17:50 +02:00
var shutdownFuncs [ ] func ( )
func callOnShutdown ( f func ( ) ) {
shutdownFuncs = append ( shutdownFuncs , f )
}
2020-06-15 18:59:22 +02:00
// Unicode U+200B zero-width-space, to avoid triggering other bots
// or highlighting people on IRC.
const zeroWidthSpace = "\u200b"
2020-06-14 22:51:53 +02:00
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 ( )
2021-05-22 15:56:47 +02:00
var ircConn net . Conn
var err error
if * ircTls {
ircConn , err = tls . Dial ( "tcp" , * ircServer , nil )
} else {
ircConn , err = net . Dial ( "tcp" , * ircServer )
}
2020-06-14 22:51:53 +02:00
if err != nil {
2021-05-22 15:56:47 +02:00
log . Errorf ( "connecting to IRC at tcp/%s (tls: %v): %v" , * ircServer , * ircTls , err )
2020-06-14 22:51:53 +02:00
return
}
2021-05-22 15:56:47 +02:00
2020-06-14 22:51:53 +02:00
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 )
2020-06-15 18:59:22 +02:00
ircClient . Writef ( "PRIVMSG %s :%s%s" , * ircChannel , zeroWidthSpace , msg )
2020-06-14 22:51:53 +02:00
}
}
} ( )
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
}
} ) ( )
}
}
2021-12-10 14:19:53 +01:00
func username ( a gerritevents . Account ) string {
2020-06-14 22:51:53 +02:00
options := [ ] string {
2021-12-10 14:19:53 +01:00
a . Username ,
a . Name ,
a . Email ,
2020-06-14 22:51:53 +02:00
}
for _ , opt := range options {
if opt != "" {
return opt
}
}
return "UNKNOWN USER"
}
2020-06-15 18:59:22 +02:00
// 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 {
2020-06-16 20:22:58 +02:00
un := [ ] rune ( user )
return string ( un [ 0 : 1 ] ) + zeroWidthSpace + string ( un [ 1 : ] )
2020-06-15 18:59:22 +02:00
}
2021-03-27 13:05:47 +01:00
// 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.
2022-01-28 12:07:05 +01:00
//
// Also applies noping to all instances of the words in `neverPing`.
2021-03-27 13:05:47 +01:00
func nopingAll ( username , message string ) string {
2022-01-28 12:07:05 +01:00
for _ , word := range strings . Split ( * neverPing , "," ) {
message = strings . ReplaceAll ( message , word , noping ( word ) )
}
2021-03-27 13:05:47 +01:00
return strings . ReplaceAll ( message , username , noping ( username ) )
}
2024-05-13 17:54:34 +02:00
// 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 don’ t want to filter
if onlyDisplay == "" {
return false
}
for _ , needle := range strings . Split ( onlyDisplay , "," ) {
if strings . Contains ( changeSubject , needle ) {
return false
}
}
return true
}
2020-06-14 22:51:53 +02:00
func patchSetURL ( c gerritevents . Change , p gerritevents . PatchSet ) string {
return fmt . Sprintf ( "https://cl.tvl.fyi/%d" , c . Number )
}
2020-06-14 00:43:09 +02:00
func main ( ) {
flag . Parse ( )
2020-06-14 22:51:53 +02:00
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 )
}
2020-06-14 00:43:09 +02:00
2020-06-14 21:17:50 +02:00
shutdownCh := make ( chan os . Signal )
signal . Notify ( shutdownCh , os . Interrupt )
go func ( ) {
<- shutdownCh
2020-06-14 22:51:53 +02:00
signal . Reset ( os . Interrupt )
for n := len ( shutdownFuncs ) - 1 ; n >= 0 ; n -- {
2020-06-14 21:17:50 +02:00
shutdownFuncs [ n ] ( )
}
} ( )
ctx , cancel := context . WithCancel ( context . Background ( ) )
callOnShutdown ( cancel )
2020-06-14 00:43:09 +02:00
cfg := & ssh . ClientConfig {
User : * gerritAuthUsername ,
Auth : [ ] ssh . AuthMethod { mustPrivateKey ( * gerritAuthKeyPath ) } ,
HostKeyCallback : mustFixedHostKey ( * gerritSSHHostKey ) ,
Timeout : * gerritSSHTimeout ,
}
cfg . SetDefaults ( )
2020-06-14 21:17:50 +02:00
2020-06-14 00:43:09 +02:00
gw , err := gerrit . New ( ctx , "tcp" , * gerritAddr , cfg )
if err != nil {
2020-06-14 22:51:53 +02:00
log . Exitf ( "gerrit.New(%q): %v" , * gerritAddr , err )
2020-06-14 00:43:09 +02:00
}
2020-06-14 21:17:50 +02:00
callOnShutdown ( func ( ) {
2020-06-14 22:51:53 +02:00
ctx , cancel := context . WithTimeout ( context . Background ( ) , 1 * time . Second )
2020-06-14 21:17:50 +02:00
defer cancel ( )
gw . Close ( ctx )
} )
2020-06-14 00:43:09 +02:00
2020-06-14 22:51:53 +02:00
sendMsgChan := make ( chan string , 5 )
go func ( ) {
for e := range gw . Events ( ) {
var parsedMsg string
switch e := e . ( type ) {
case * gerritevents . PatchSetCreated :
2024-05-13 17:54:34 +02:00
if e . Change . Project != * notifyRepo || ! notifyBranches [ e . Change . Branch ] || e . PatchSet . Number != 1 || changeShouldBeSkipped ( * onlyDisplay , e . Change . Subject ) {
2020-06-14 22:51:53 +02:00
continue
}
2021-12-10 14:19:53 +01:00
user := username ( e . PatchSet . Uploader )
2021-04-02 21:43:32 +02:00
parsedMsg = nopingAll ( user , fmt . Sprintf ( "CL/%d proposed by %s - %s - %s" , e . Change . Number , user , e . Change . Subject , patchSetURL ( e . Change , e . PatchSet ) ) )
2020-06-14 22:51:53 +02:00
case * gerritevents . ChangeMerged :
2024-05-13 17:54:34 +02:00
if e . Change . Project != * notifyRepo || ! notifyBranches [ e . Change . Branch ] || changeShouldBeSkipped ( * onlyDisplay , e . Change . Subject ) {
2020-06-14 22:51:53 +02:00
continue
}
2021-12-10 14:19:53 +01:00
owner := username ( e . Change . Owner )
2021-12-10 12:59:25 +01:00
submitter := e . Submitter . Username
url := patchSetURL ( e . Change , e . PatchSet )
if submitter != owner && submitter == "clbot" {
2021-12-14 03:36:39 +01:00
parsedMsg = nopingAll ( owner , fmt . Sprintf ( "CL/%d by %s autosubmitted - %s - %s" , e . Change . Number , owner , e . Change . Subject , url ) )
2021-12-10 12:59:25 +01:00
} else {
parsedMsg = nopingAll ( owner , fmt . Sprintf ( "CL/%d applied by %s - %s - %s" , e . Change . Number , owner , e . Change . Subject , url ) )
}
2020-06-14 22:51:53 +02:00
}
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 ( )
2020-06-14 00:43:09 +02:00
}