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