Adrià Vilanova MartÃnez | 250457f | 2021-08-08 01:23:14 +0200 | [diff] [blame^] | 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "log" |
| 8 | "os" |
| 9 | "regexp" |
| 10 | "strconv" |
| 11 | "strings" |
| 12 | "sync" |
| 13 | "text/template" |
| 14 | "time" |
| 15 | |
| 16 | "cloud.google.com/go/pubsub" |
| 17 | "github.com/ReneKroon/ttlcache/v2" |
| 18 | "go.skia.org/infra/go/monorail/v1" |
| 19 | "google.golang.org/api/option" |
| 20 | ) |
| 21 | |
| 22 | var ( |
| 23 | gcloudProjectId = "avm99963-bugs" |
| 24 | pubsubSubscriptionId = "gerrit-events-sub" |
| 25 | monorailEndpointURL = "https://bugs.avm99963.com/_ah/api/monorail/v1/" |
| 26 | ) |
| 27 | |
| 28 | type Bug struct { |
| 29 | Project string |
| 30 | Number int64 |
| 31 | WillClose bool |
| 32 | } |
| 33 | |
| 34 | type Account struct { |
| 35 | Name string `json:"name"` |
| 36 | Email string `json:"email"` |
| 37 | Username string `json:"username"` |
| 38 | } |
| 39 | |
| 40 | type Message struct { |
| 41 | Timestamp int64 `json:"timestamp"` |
| 42 | Reviewer Account `json:"reviewer"` |
| 43 | Message string `json:"message"` |
| 44 | } |
| 45 | |
| 46 | type Approval struct { |
| 47 | ApprovalType string `json:"type"` |
| 48 | Description string `json:"description"` |
| 49 | Value string `json:"value"` |
| 50 | OldValue string `json:"oldValue"` |
| 51 | GrantedOn int64 `json:"grantedOn"` |
| 52 | By Account `json:"by"` |
| 53 | } |
| 54 | |
| 55 | type File struct { |
| 56 | File string `json:"file"` |
| 57 | FileOld string `json:"fileOld"` |
| 58 | FileType string `json:"type"` |
| 59 | Insertions int `json:"insertions"` |
| 60 | Deletions int `json:"deletions"` |
| 61 | } |
| 62 | |
| 63 | type PatchsetComment struct { |
| 64 | File string `json:"file"` |
| 65 | Line int `json:"line"` |
| 66 | Reviewer Account `json:"reviewer"` |
| 67 | Message string `json:"message"` |
| 68 | } |
| 69 | |
| 70 | type PatchSet struct { |
| 71 | Number int `json:"number"` |
| 72 | Revision string `json:"revision"` |
| 73 | Parents []string `json:"parents"` |
| 74 | Ref string `json:"ref"` |
| 75 | Uploader Account `json:"uploader"` |
| 76 | Author Account `json:"author"` |
| 77 | CreatedOn int64 `json:"createdOn"` |
| 78 | Kind string `json:"kind"` |
| 79 | Approvals []Approval `json:"approvals"` |
| 80 | Comments []PatchsetComment `json:"comments"` |
| 81 | Files []File `json:"files"` |
| 82 | SizeInsertions int `json:"sizeInsertions"` |
| 83 | SizeDeletions int `json:"sizeDeletions"` |
| 84 | } |
| 85 | |
| 86 | type Dependency struct { |
| 87 | Id string `json:"id"` |
| 88 | Number int `json:"number"` |
| 89 | Revision string `json:"revision"` |
| 90 | Ref string `json:"ref"` |
| 91 | IsCurrentPatchSet bool `json:"isCurrentPatchSet"` |
| 92 | } |
| 93 | |
| 94 | type Label struct { |
| 95 | Label string `json:"label"` |
| 96 | Status string `json:"status"` |
| 97 | By Account `json:"by"` |
| 98 | } |
| 99 | |
| 100 | type Requirement struct { |
| 101 | FallbackText string `json:"fallbackText"` |
| 102 | RequirementType string `json:"type"` |
| 103 | Data map[string]interface{} `json:"data"` |
| 104 | } |
| 105 | |
| 106 | type SubmitRecord struct { |
| 107 | Status string `json:"status"` |
| 108 | Labels []Label `json:"labels"` |
| 109 | Requirements []Requirement `json:"requirements"` |
| 110 | } |
| 111 | |
| 112 | type Change struct { |
| 113 | Project string `json:"project"` |
| 114 | Branch string `json:"branch"` |
| 115 | Id string `json:"id"` |
| 116 | Number int `json:"number"` |
| 117 | Subject string `json:"subject"` |
| 118 | Owner Account `json:"owner"` |
| 119 | Url string `json:"url"` |
| 120 | CommitMessage string `json:"commitMessage"` |
| 121 | Hashtags []string `json:"hashtags"` |
| 122 | CreatedOn int64 `json:"createdOn"` |
| 123 | LastUpdated int64 `json:"lastUpdated"` |
| 124 | Open bool `json:"open"` |
| 125 | Status string `json:"status"` |
| 126 | Private bool `json:"private"` |
| 127 | Wip bool `json:"wip"` |
| 128 | Comments []Message `json:"comments"` |
| 129 | TrackingIds []int `json:"trackingIds"` |
| 130 | CurrentPatchSet PatchSet `json:"currentPatchSet"` |
| 131 | PatchSets []PatchSet `json:"patchSets"` |
| 132 | DependsOn []Dependency `json:"dependsOn"` |
| 133 | NeededBy []Dependency `json:"neededBy"` |
| 134 | SubmitRecords SubmitRecord `json:"submitRecords"` |
| 135 | AllReviewers []Account `json:"allReviewers"` |
| 136 | } |
| 137 | |
| 138 | type ChangeMergedEvent struct { |
| 139 | EventType string `json:"type"` |
| 140 | Change Change `json:"change"` |
| 141 | PatchSet PatchSet `json:"patchSet"` |
| 142 | Submitter Account `json:"submitter"` |
| 143 | NewRev string `json:"newRev"` |
| 144 | EventCreatedOn int64 `json:"eventCreatedOn"` |
| 145 | } |
| 146 | |
| 147 | var ( |
| 148 | bugsRegex = regexp.MustCompile(`(Bug:|BUG=|Fixed:)((?:\s*(?:[a-z]\w[-\w]*:)?\d+,)*\s*(?:[a-z]\w[-\w]*:)?\d+),?`) |
| 149 | ) |
| 150 | |
| 151 | func getBugsList(message string) []Bug { |
| 152 | bugsFooters := bugsRegex.FindAllStringSubmatch(message, -1) |
| 153 | var allBugs []Bug |
| 154 | for _, footerMatches := range bugsFooters { |
| 155 | // This shouldn't happen, but we protect against this case |
| 156 | // TODO(avm99963): This should be replaced by something like what DCHECK() does in Chromium |
| 157 | if len(footerMatches) < 3 { |
| 158 | continue |
| 159 | } |
| 160 | willCloseBug := footerMatches[1] == "Fixed:" |
| 161 | bugs := strings.Split(footerMatches[2], ",") |
| 162 | for i, _ := range bugs { |
| 163 | bug := strings.TrimSpace(bugs[i]) |
| 164 | res := strings.Split(bug, ":") |
| 165 | if len(res) > 2 { |
| 166 | continue |
| 167 | } |
| 168 | if len(res) == 1 { |
| 169 | number, err := strconv.ParseInt(res[0], 10, 64) |
| 170 | if err != nil { |
| 171 | log.Printf("Couldn't parse bug number from %v: %v", bug, err) |
| 172 | } |
| 173 | allBugs = append(allBugs, Bug{ |
| 174 | Project: "misc", |
| 175 | Number: number, |
| 176 | WillClose: willCloseBug, |
| 177 | }) |
| 178 | } else if len(res) == 2 { |
| 179 | number, err := strconv.ParseInt(res[1], 10, 64) |
| 180 | if err != nil { |
| 181 | log.Printf("Couldn't parse bug number from %v: %v", bug, err) |
| 182 | } |
| 183 | allBugs = append(allBugs, Bug{ |
| 184 | Project: res[0], |
| 185 | Number: number, |
| 186 | WillClose: willCloseBug, |
| 187 | }) |
| 188 | } |
| 189 | } |
| 190 | } |
| 191 | return allBugs |
| 192 | } |
| 193 | |
| 194 | func handlePubSubMessage(ctx context.Context, msg *pubsub.Message, monorailSvc *monorail.Service, template *template.Template, mu sync.Mutex, cache ttlcache.SimpleCache) error { |
| 195 | mu.Lock() |
| 196 | defer mu.Unlock() |
| 197 | |
| 198 | var m ChangeMergedEvent |
| 199 | err := json.Unmarshal(msg.Data, &m) |
| 200 | if err != nil { |
| 201 | msg.Ack() |
| 202 | return err |
| 203 | } |
| 204 | |
| 205 | // We're only interested in merged changes |
| 206 | // |
| 207 | // Even if the struct is meant for change-merged events, unknown fields are |
| 208 | // ignored. |
| 209 | if m.EventType != "change-merged" { |
| 210 | log.Printf("Ignoring message with event '%v'.\n", m.EventType) |
| 211 | msg.Ack() |
| 212 | return nil |
| 213 | } |
| 214 | |
| 215 | if _, err := cache.Get(strconv.Itoa(m.Change.Number)); err != ttlcache.ErrNotFound { |
| 216 | log.Printf("Not processing CL:%d since it has been processed before", m.Change.Number) |
| 217 | msg.Ack() |
| 218 | return nil |
| 219 | } |
| 220 | |
| 221 | log.Printf("Processing CL:%d\n", m.Change.Number) |
| 222 | |
| 223 | patchSetTime := time.Unix(m.PatchSet.CreatedOn, 0) |
| 224 | patchSetTimeString := patchSetTime.Format(time.RFC1123Z) |
| 225 | |
| 226 | messageBuf := new(bytes.Buffer) |
| 227 | template.Execute(messageBuf, struct { |
| 228 | Event ChangeMergedEvent |
| 229 | CommitURL string |
| 230 | Date string |
| 231 | }{ |
| 232 | m, |
| 233 | "undefined", |
| 234 | patchSetTimeString, |
| 235 | }) |
| 236 | if err != nil { |
| 237 | msg.Ack() |
| 238 | return err |
| 239 | } |
| 240 | |
| 241 | message := messageBuf.String() |
| 242 | |
| 243 | ctxRequest, cancel := context.WithDeadline(ctx, time.Now().Add(45*time.Second)) |
| 244 | defer cancel() |
| 245 | |
| 246 | bugs := getBugsList(m.Change.CommitMessage) |
| 247 | log.Printf("[CL:%d] Bugs: %v\n", m.Change.Number, bugs) |
| 248 | |
| 249 | for _, b := range bugs { |
| 250 | comment := monorail.ProtoApiPb2V1IssueCommentWrapper{ |
| 251 | Content: message, |
| 252 | Updates: &monorail.ProtoApiPb2V1Update{}, |
| 253 | } |
| 254 | |
| 255 | if b.WillClose { |
| 256 | comment.Updates.Status = "Fixed" |
| 257 | } |
| 258 | |
| 259 | _, err := monorailSvc.Issues.Comments.Insert(b.Project, b.Number, &comment).Context(ctxRequest).Do() |
| 260 | if err != nil { |
| 261 | log.Printf("[CL:%d] Couldn't add comment to bug %v: %v\n", m.Change.Number, b, err) |
| 262 | } |
| 263 | |
| 264 | log.Printf("[CL:%d] Added comment to bug %v\n", m.Change.Number, b) |
| 265 | } |
| 266 | |
| 267 | cache.Set(strconv.Itoa(m.Change.Number), true) |
| 268 | |
| 269 | msg.Ack() |
| 270 | |
| 271 | return nil |
| 272 | } |
| 273 | |
| 274 | func main() { |
| 275 | log.Println("Starting gitwatcher...") |
| 276 | |
| 277 | ctx := context.Background() |
| 278 | |
| 279 | template, err := template.New("MonorailMessage").Parse(`The following change refers to this bug: |
| 280 | {{.Event.Change.Url}} |
| 281 | |
| 282 | commit {{.Event.NewRev}} |
| 283 | Author: {{.Event.PatchSet.Author.Name}} <{{.Event.PatchSet.Author.Email}}> |
| 284 | Date: {{.Date}} |
| 285 | |
| 286 | {{.Event.Change.CommitMessage}}`) |
| 287 | if err != nil { |
| 288 | log.Fatal(err) |
| 289 | } |
| 290 | |
| 291 | cache := ttlcache.NewCache() |
| 292 | cache.SetTTL(time.Duration(10 * time.Minute)) |
| 293 | |
| 294 | pubsubClient, err := pubsub.NewClient(ctx, gcloudProjectId) |
| 295 | if err != nil { |
| 296 | log.Fatal(err) |
| 297 | } |
| 298 | defer pubsubClient.Close() |
| 299 | |
| 300 | jsonFile, ok := os.LookupEnv("GOOGLE_APPLICATION_CREDENTIALS") |
| 301 | if !ok { |
| 302 | log.Println("GOOGLE_APPLICATION_CREDENTIALS environment variable is not set.") |
| 303 | return |
| 304 | } |
| 305 | |
| 306 | monorailSvc, err := monorail.NewService(ctx, option.WithEndpoint(monorailEndpointURL), option.WithCredentialsFile(jsonFile)) |
| 307 | if err != nil { |
| 308 | log.Fatal(err) |
| 309 | } |
| 310 | |
| 311 | var mu sync.Mutex |
| 312 | |
| 313 | sub := pubsubClient.Subscription(pubsubSubscriptionId) |
| 314 | err = sub.Receive(ctx, func(ctx context.Context, msg *pubsub.Message) { |
| 315 | err := handlePubSubMessage(ctx, msg, monorailSvc, template, mu, cache) |
| 316 | if err != nil { |
| 317 | log.Printf("Couldn't handle pubsub message: %v\n", err) |
| 318 | } |
| 319 | }) |
| 320 | } |