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)
+		}
+	})
+}