blob: 60eb1e0aea848cfd3cadee62952bb383821fe403 [file] [log] [blame]
package main
import (
"bytes"
"context"
"encoding/json"
"log"
"os"
"regexp"
"strconv"
"strings"
"sync"
"text/template"
"time"
"cloud.google.com/go/pubsub"
"github.com/ReneKroon/ttlcache/v2"
"go.skia.org/infra/go/monorail/v1"
"google.golang.org/api/option"
)
var (
gcloudProjectId = "avm99963-bugs"
pubsubSubscriptionId = "gerrit-events-sub"
monorailEndpointURL = "https://bugs.avm99963.com/_ah/api/monorail/v1/"
)
type Bug struct {
Project string
Number int64
WillClose bool
}
type Account struct {
Name string `json:"name"`
Email string `json:"email"`
Username string `json:"username"`
}
type Message struct {
Timestamp int64 `json:"timestamp"`
Reviewer Account `json:"reviewer"`
Message string `json:"message"`
}
type Approval struct {
ApprovalType string `json:"type"`
Description string `json:"description"`
Value string `json:"value"`
OldValue string `json:"oldValue"`
GrantedOn int64 `json:"grantedOn"`
By Account `json:"by"`
}
type File struct {
File string `json:"file"`
FileOld string `json:"fileOld"`
FileType string `json:"type"`
Insertions int `json:"insertions"`
Deletions int `json:"deletions"`
}
type PatchsetComment struct {
File string `json:"file"`
Line int `json:"line"`
Reviewer Account `json:"reviewer"`
Message string `json:"message"`
}
type PatchSet struct {
Number int `json:"number"`
Revision string `json:"revision"`
Parents []string `json:"parents"`
Ref string `json:"ref"`
Uploader Account `json:"uploader"`
Author Account `json:"author"`
CreatedOn int64 `json:"createdOn"`
Kind string `json:"kind"`
Approvals []Approval `json:"approvals"`
Comments []PatchsetComment `json:"comments"`
Files []File `json:"files"`
SizeInsertions int `json:"sizeInsertions"`
SizeDeletions int `json:"sizeDeletions"`
}
type Dependency struct {
Id string `json:"id"`
Number int `json:"number"`
Revision string `json:"revision"`
Ref string `json:"ref"`
IsCurrentPatchSet bool `json:"isCurrentPatchSet"`
}
type Label struct {
Label string `json:"label"`
Status string `json:"status"`
By Account `json:"by"`
}
type Requirement struct {
FallbackText string `json:"fallbackText"`
RequirementType string `json:"type"`
Data map[string]interface{} `json:"data"`
}
type SubmitRecord struct {
Status string `json:"status"`
Labels []Label `json:"labels"`
Requirements []Requirement `json:"requirements"`
}
type Change struct {
Project string `json:"project"`
Branch string `json:"branch"`
Id string `json:"id"`
Number int `json:"number"`
Subject string `json:"subject"`
Owner Account `json:"owner"`
Url string `json:"url"`
CommitMessage string `json:"commitMessage"`
Hashtags []string `json:"hashtags"`
CreatedOn int64 `json:"createdOn"`
LastUpdated int64 `json:"lastUpdated"`
Open bool `json:"open"`
Status string `json:"status"`
Private bool `json:"private"`
Wip bool `json:"wip"`
Comments []Message `json:"comments"`
TrackingIds []int `json:"trackingIds"`
CurrentPatchSet PatchSet `json:"currentPatchSet"`
PatchSets []PatchSet `json:"patchSets"`
DependsOn []Dependency `json:"dependsOn"`
NeededBy []Dependency `json:"neededBy"`
SubmitRecords SubmitRecord `json:"submitRecords"`
AllReviewers []Account `json:"allReviewers"`
}
type ChangeMergedEvent struct {
EventType string `json:"type"`
Change Change `json:"change"`
PatchSet PatchSet `json:"patchSet"`
Submitter Account `json:"submitter"`
NewRev string `json:"newRev"`
EventCreatedOn int64 `json:"eventCreatedOn"`
}
var (
bugsRegex = regexp.MustCompile(`(Bug:|BUG=|Fixed:)((?:\s*(?:[a-z]\w[-\w]*:)?\d+,)*\s*(?:[a-z]\w[-\w]*:)?\d+),?`)
)
func getBugsList(message string) []Bug {
bugsFooters := bugsRegex.FindAllStringSubmatch(message, -1)
var allBugs []Bug
for _, footerMatches := range bugsFooters {
// This shouldn't happen, but we protect against this case
// TODO(avm99963): This should be replaced by something like what DCHECK() does in Chromium
if len(footerMatches) < 3 {
continue
}
willCloseBug := footerMatches[1] == "Fixed:"
bugs := strings.Split(footerMatches[2], ",")
for i, _ := range bugs {
bug := strings.TrimSpace(bugs[i])
res := strings.Split(bug, ":")
if len(res) > 2 {
continue
}
if len(res) == 1 {
number, err := strconv.ParseInt(res[0], 10, 64)
if err != nil {
log.Printf("Couldn't parse bug number from %v: %v", bug, err)
}
allBugs = append(allBugs, Bug{
Project: "misc",
Number: number,
WillClose: willCloseBug,
})
} else if len(res) == 2 {
number, err := strconv.ParseInt(res[1], 10, 64)
if err != nil {
log.Printf("Couldn't parse bug number from %v: %v", bug, err)
}
allBugs = append(allBugs, Bug{
Project: res[0],
Number: number,
WillClose: willCloseBug,
})
}
}
}
return allBugs
}
func handlePubSubMessage(ctx context.Context, msg *pubsub.Message, monorailSvc *monorail.Service, template *template.Template, mu sync.Mutex, cache ttlcache.SimpleCache) error {
mu.Lock()
defer mu.Unlock()
var m ChangeMergedEvent
err := json.Unmarshal(msg.Data, &m)
if err != nil {
msg.Ack()
return err
}
// We're only interested in merged changes
//
// Even if the struct is meant for change-merged events, unknown fields are
// ignored.
if m.EventType != "change-merged" {
log.Printf("Ignoring message with event '%v'.\n", m.EventType)
msg.Ack()
return nil
}
if _, err := cache.Get(strconv.Itoa(m.Change.Number)); err != ttlcache.ErrNotFound {
log.Printf("Not processing CL:%d since it has been processed before", m.Change.Number)
msg.Ack()
return nil
}
log.Printf("Processing CL:%d\n", m.Change.Number)
patchSetTime := time.Unix(m.PatchSet.CreatedOn, 0)
patchSetTimeString := patchSetTime.Format(time.RFC1123Z)
messageBuf := new(bytes.Buffer)
template.Execute(messageBuf, struct {
Event ChangeMergedEvent
CommitURL string
Date string
}{
m,
"undefined",
patchSetTimeString,
})
if err != nil {
msg.Ack()
return err
}
message := messageBuf.String()
ctxRequest, cancel := context.WithDeadline(ctx, time.Now().Add(45*time.Second))
defer cancel()
bugs := getBugsList(m.Change.CommitMessage)
log.Printf("[CL:%d] Bugs: %v\n", m.Change.Number, bugs)
for _, b := range bugs {
comment := monorail.ProtoApiPb2V1IssueCommentWrapper{
Content: message,
Updates: &monorail.ProtoApiPb2V1Update{},
}
if b.WillClose {
comment.Updates.Status = "Fixed"
}
_, err := monorailSvc.Issues.Comments.Insert(b.Project, b.Number, &comment).Context(ctxRequest).Do()
if err != nil {
log.Printf("[CL:%d] Couldn't add comment to bug %v: %v\n", m.Change.Number, b, err)
}
log.Printf("[CL:%d] Added comment to bug %v\n", m.Change.Number, b)
}
cache.Set(strconv.Itoa(m.Change.Number), true)
msg.Ack()
return nil
}
func main() {
log.Println("Starting gitwatcher...")
ctx := context.Background()
template, err := template.New("MonorailMessage").Parse(`The following change refers to this bug:
{{.Event.Change.Url}}
commit {{.Event.NewRev}}
Author: {{.Event.PatchSet.Author.Name}} <{{.Event.PatchSet.Author.Email}}>
Date: {{.Date}}
{{.Event.Change.CommitMessage}}`)
if err != nil {
log.Fatal(err)
}
cache := ttlcache.NewCache()
cache.SetTTL(time.Duration(10 * time.Minute))
pubsubClient, err := pubsub.NewClient(ctx, gcloudProjectId)
if err != nil {
log.Fatal(err)
}
defer pubsubClient.Close()
jsonFile, ok := os.LookupEnv("GOOGLE_APPLICATION_CREDENTIALS")
if !ok {
log.Println("GOOGLE_APPLICATION_CREDENTIALS environment variable is not set.")
return
}
monorailSvc, err := monorail.NewService(ctx, option.WithEndpoint(monorailEndpointURL), option.WithCredentialsFile(jsonFile))
if err != nil {
log.Fatal(err)
}
var mu sync.Mutex
sub := pubsubClient.Subscription(pubsubSubscriptionId)
err = sub.Receive(ctx, func(ctx context.Context, msg *pubsub.Message) {
err := handlePubSubMessage(ctx, msg, monorailSvc, template, mu, cache)
if err != nil {
log.Printf("Couldn't handle pubsub message: %v\n", err)
}
})
}