First version
Fixed: misc:3
Change-Id: I44a2e62e30dc58cf13839087acbffff8eeb69100
diff --git a/gitwatcher.go b/gitwatcher.go
new file mode 100644
index 0000000..60eb1e0
--- /dev/null
+++ b/gitwatcher.go
@@ -0,0 +1,320 @@
+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)
+ }
+ })
+}