blob: 0dc9b132f47d28bf504140ffa201da7782bc6e4e [file] [log] [blame]
Austin Schuhfd8431e2020-12-27 19:35:04 -08001package main
2
3import (
4 "bufio"
5 "bytes"
6 "encoding/json"
7 "flag"
8 "fmt"
9 "github.com/buildkite/go-buildkite/buildkite"
10 "io/ioutil"
11 "log"
12 "net/http"
13 "os/exec"
14 "regexp"
15 "strings"
16 "sync"
Austin Schuh07450432021-03-21 20:29:07 -070017 "time"
Austin Schuhfd8431e2020-12-27 19:35:04 -080018)
19
20type Commit struct {
21 Sha1 string
22 ChangeId string
23 ChangeNumber int
24 Patchset int
25}
26
27type State struct {
28 // This mutex needs to be locked across anything which generates a uuid or accesses Commits.
29 mu sync.Mutex
30 // Mapping from build UUID from buildkite to the Commit information which triggered it.
31 Commits map[string]Commit
32 User string
33 Key string
34 // Webhook token expected to service requests
35 Token string
36}
37
38type Approval struct {
39 Type string `json:"type"`
40 Description string `json:"description"`
41 Value string `json:"value"`
42 OldValue string `json:"oldValue"`
43}
44
45type User struct {
46 Name string `json:"name"`
47 Email string `json:"email,omitempty"`
48 Username string `json:"username,omitempty"`
49}
50
51type PatchSet struct {
52 Number int `json:"number"`
53 Revision string `json:"revision"`
54 Parents []string `json:"parents"`
55 Ref string `json:"ref"`
56 Uploader User `json:"uploader"`
57 CreatedOn int `json:"createdOn"`
58 Author User `json:"author"`
59 Kind string `json:"kind,omitempty"`
60 SizeInsertions int `json:"sizeInsertions,omitempty"`
61 SizeDeletions int `json:"sizeDeletions,omitempty"`
62}
63
64type Change struct {
65 Project string `json:"project"`
66 Branch string `json:"branch"`
67 ID string `json:"id"`
68 Number int `json:"number"`
69 Subject string `json:"subject"`
70 Owner User `json:"owner"`
71 URL string `json:"url"`
72 CommitMessage string `json:"commitMessage"`
73 CreatedOn int `json:"createdOn"`
74 Status string `json:"status"`
75}
76
77type ChangeKey struct {
78 ID string `json:"id"`
79}
80
81type RefUpdate struct {
82 OldRev string `json:"oldRev"`
83 NewRev string `json:"newRev"`
84 RefName string `json:"refName"`
85 Project string `json:"project"`
86}
87
88type EventInfo struct {
89 Author *User `json:"author"`
90 Uploader *User `json:"uploader"`
Austin Schuh07450432021-03-21 20:29:07 -070091 Reviewer *User `json:"reviewer"`
Austin Schuh68d28912021-03-21 21:59:28 -070092 Adder *User `json:"adder"`
93 Remover *User `json:"remover"`
Austin Schuhfd8431e2020-12-27 19:35:04 -080094 Submitter User `json:"submitter,omitempty"`
Austin Schuh68d28912021-03-21 21:59:28 -070095 NewRev string `json:"newRev,omitempty"`
96 Ref string `json:"ref,omitempty"`
97 TargetNode string `json:"targetNode,omitempty"`
Austin Schuhfd8431e2020-12-27 19:35:04 -080098 Approvals []Approval `json:"approvals,omitempty"`
99 Comment string `json:"comment,omitempty"`
100 PatchSet *PatchSet `json:"patchSet"`
101 Change *Change `json:"change"`
102 Project string `json:"project"`
103 RefName string `json:"refName"`
104 ChangeKey ChangeKey `json:"changeKey"`
105 RefUpdate *RefUpdate `json:"refUpdate"`
106 Type string `json:"type"`
107 EventCreatedOn int `json:"eventCreatedOn"`
108}
109
110// Simple application to poll Gerrit for events and trigger builds on buildkite when one happens.
111
112// Handles a gerrit event and triggers buildkite accordingly.
113func (s *State) handleEvent(eventInfo EventInfo, client *buildkite.Client) {
114 // Only work on 971-Robot-Code
115 if eventInfo.Project != "971-Robot-Code" {
116 log.Printf("Ignoring project: '%s'\n", eventInfo.Project)
117 return
118 }
119
120 // Find the change id, change number, patchset revision
121 if eventInfo.Change == nil {
122 log.Println("Failed to find Change")
123 return
124 }
125
126 if eventInfo.PatchSet == nil {
127 log.Println("Failed to find Change")
128 return
129 }
130
131 log.Printf("Got a matching change of %s %s %d,%d\n",
132 eventInfo.Change.ID, eventInfo.PatchSet.Revision, eventInfo.Change.Number, eventInfo.PatchSet.Number)
133
Austin Schuh07450432021-03-21 20:29:07 -0700134 for {
Austin Schuhfd8431e2020-12-27 19:35:04 -0800135
Austin Schuh07450432021-03-21 20:29:07 -0700136 // Triggering a build creates a UUID, and we can see events back from the webhook before the command returns. Lock across the command so nothing access Commits while the new UUID is being added.
137 s.mu.Lock()
Austin Schuhfd8431e2020-12-27 19:35:04 -0800138
Austin Schuh07450432021-03-21 20:29:07 -0700139 var user *User
140 if eventInfo.Author != nil {
141 user = eventInfo.Author
142 } else if eventInfo.Uploader != nil {
143 user = eventInfo.Uploader
Austin Schuhfd8431e2020-12-27 19:35:04 -0800144 } else {
Austin Schuh07450432021-03-21 20:29:07 -0700145 log.Fatalf("Failed to find Author or Uploader")
Austin Schuhfd8431e2020-12-27 19:35:04 -0800146 }
147
Austin Schuh07450432021-03-21 20:29:07 -0700148 // Trigger the build.
149 if build, _, err := client.Builds.Create(
150 "spartan-robotics", "971-robot-code", &buildkite.CreateBuild{
151 Commit: eventInfo.PatchSet.Revision,
152 Branch: eventInfo.Change.ID,
153 Author: buildkite.Author{
154 Name: user.Name,
155 Email: user.Email,
156 },
157 Env: map[string]string{
158 "GERRIT_CHANGE_NUMBER": fmt.Sprintf("%d", eventInfo.Change.Number),
159 "GERRIT_PATCH_NUMBER": fmt.Sprintf("%d", eventInfo.PatchSet.Number),
160 },
161 }); err == nil {
Austin Schuhfd8431e2020-12-27 19:35:04 -0800162
Austin Schuh07450432021-03-21 20:29:07 -0700163 if build.ID != nil {
164 log.Printf("Scheduled build %s\n", *build.ID)
165 s.Commits[*build.ID] = Commit{
166 Sha1: eventInfo.PatchSet.Revision,
167 ChangeId: eventInfo.Change.ID,
168 ChangeNumber: eventInfo.Change.Number,
169 Patchset: eventInfo.PatchSet.Number,
170 }
171 }
172 s.mu.Unlock()
173
174 if data, err := json.MarshalIndent(build, "", "\t"); err != nil {
175 log.Fatalf("json encode failed: %s", err)
176 } else {
177 log.Printf("%s\n", string(data))
178 }
179
180 // Now remove the verified from Gerrit and post the link.
181 cmd := exec.Command("ssh",
182 "-p",
183 "29418",
184 "-i",
185 s.Key,
186 s.User+"@software.frc971.org",
187 "gerrit",
188 "review",
189 "-m",
190 fmt.Sprintf("\"Build Started: %s\"", *build.WebURL),
Austin Schuh05fe85d2021-03-21 22:17:09 -0700191 // Don't email out the initial link to lower the spam.
192 "-n",
193 "NONE",
Austin Schuh07450432021-03-21 20:29:07 -0700194 "--verified",
195 "0",
196 fmt.Sprintf("%d,%d", eventInfo.Change.Number, eventInfo.PatchSet.Number))
197
Austin Schuh05fe85d2021-03-21 22:17:09 -0700198 log.Printf("Running 'ssh -p 29418 -i %s %s@software.frc971.org gerrit review -m '\"Build Started: %s\"' -n NONE --verified 0 %d,%d' and waiting for it to finish...",
Austin Schuh07450432021-03-21 20:29:07 -0700199 s.Key, s.User,
200 *build.WebURL, eventInfo.Change.Number, eventInfo.PatchSet.Number)
201 if err := cmd.Run(); err != nil {
202 log.Printf("Command failed with error: %v", err)
203 }
204 return
205 } else {
206 s.mu.Unlock()
207 log.Printf("Failed to trigger build: %s", err)
208 log.Printf("Trying again in 30 seconds")
209 time.Sleep(30 * time.Second)
Austin Schuhfd8431e2020-12-27 19:35:04 -0800210 }
Austin Schuhfd8431e2020-12-27 19:35:04 -0800211 }
212
213}
214
Austin Schuh07450432021-03-21 20:29:07 -0700215type BuildkiteChange struct {
216 ID string `json:"id,omitempty"`
217 Number int `json:"number,omitempty"`
218 URL string `json:"url,omitempty"`
219}
220
Austin Schuhfd8431e2020-12-27 19:35:04 -0800221type Build struct {
Austin Schuh07450432021-03-21 20:29:07 -0700222 ID string `json:"id,omitempty"`
223 GraphqlId string `json:"graphql_id,omitempty"`
224 URL string `json:"url,omitempty"`
225 WebURL string `json:"web_url,omitempty"`
226 Number int `json:"number,omitempty"`
227 State string `json:"state,omitempty"`
228 Blocked bool `json:"blocked,omitempty"`
229 BlockedState string `json:"blocked_state,omitempty"`
230 Message string `json:"message,omitempty"`
231 Commit string `json:"commit"`
232 Branch string `json:"branch"`
233 Source string `json:"source,omitempty"`
234 CreatedAt string `json:"created_at,omitempty"`
235 ScheduledAt string `json:"scheduled_at,omitempty"`
236 StartedAt string `json:"started_at,omitempty"`
237 FinishedAt string `json:"finished_at,omitempty"`
238 RebuiltFrom *BuildkiteChange `json:"rebuilt_from,omitempty"`
Austin Schuhfd8431e2020-12-27 19:35:04 -0800239}
240
241type BuildkiteWebhook struct {
242 Event string `json:"event"`
243 Build Build `json:"build"`
244}
245
246func (s *State) handle(w http.ResponseWriter, r *http.Request) {
247 if r.URL.Path != "/" {
248 http.Error(w, "404 not found.", http.StatusNotFound)
249 return
250 }
251
252 switch r.Method {
253 case "POST":
254 if r.Header.Get("X-Buildkite-Token") != s.Token {
255 http.Error(w, "Invalid token", http.StatusBadRequest)
256 return
257 }
258
259 var data []byte
260 var err error
261 if data, err = ioutil.ReadAll(r.Body); err != nil {
262 http.Error(w, err.Error(), http.StatusBadRequest)
263 return
264 }
265
266 log.Println(string(data))
267
268 var webhook BuildkiteWebhook
269
270 if err := json.Unmarshal(data, &webhook); err != nil {
271 log.Fatalf("json decode failed: %s", err)
272 http.Error(w, err.Error(), http.StatusBadRequest)
273 return
274 }
275
276 // We've successfully received the webhook. Spawn a goroutine in case the mutex is blocked so we don't block this thread.
277 f := func() {
Austin Schuh07450432021-03-21 20:29:07 -0700278 if webhook.Event == "build.running" {
279 if webhook.Build.RebuiltFrom != nil {
280 s.mu.Lock()
281 if c, ok := s.Commits[webhook.Build.RebuiltFrom.ID]; ok {
282 log.Printf("Detected a rebuild of %s for build %s", webhook.Build.RebuiltFrom.ID, webhook.Build.ID)
283 s.Commits[webhook.Build.ID] = c
284
285 // And now remove the vote since the rebuild started.
286 cmd := exec.Command("ssh",
287 "-p",
288 "29418",
289 "-i",
290 s.Key,
291 s.User+"@software.frc971.org",
292 "gerrit",
293 "review",
294 "-m",
295 fmt.Sprintf("\"Build Started: %s\"", webhook.Build.WebURL),
Austin Schuh05fe85d2021-03-21 22:17:09 -0700296 // Don't email out the initial link to lower the spam.
297 "-n",
298 "NONE",
Austin Schuh07450432021-03-21 20:29:07 -0700299 "--verified",
300 "0",
301 fmt.Sprintf("%d,%d", c.ChangeNumber, c.Patchset))
302
Austin Schuh05fe85d2021-03-21 22:17:09 -0700303 log.Printf("Running 'ssh -p 29418 -i %s %s@software.frc971.org gerrit review -m '\"Build Started: %s\"' -n NONE --verified 0 %d,%d' and waiting for it to finish...",
Austin Schuh07450432021-03-21 20:29:07 -0700304 s.Key, s.User,
305 webhook.Build.WebURL, c.ChangeNumber, c.Patchset)
306 if err := cmd.Run(); err != nil {
307 log.Printf("Command failed with error: %v", err)
308 }
309 }
310 s.mu.Unlock()
311 }
312 } else if webhook.Event == "build.finished" {
Austin Schuhfd8431e2020-12-27 19:35:04 -0800313 var commit *Commit
314 {
315 s.mu.Lock()
316 if c, ok := s.Commits[webhook.Build.ID]; ok {
317 commit = &c
Austin Schuh07450432021-03-21 20:29:07 -0700318 // While we *should* delete this now from the map, that will prevent rebuilds from being mapped correctly.
319 // Instead, leave it in the map indefinately. For the number of builds we do, it should take quite a while to use enough ram to matter. If that becomes an issue, we can either clean the list when a commit is submitted, or keep a fixed number of builds in the list and expire the oldest ones when it is time.
Austin Schuhfd8431e2020-12-27 19:35:04 -0800320 }
321 s.mu.Unlock()
322 }
323
324 if commit == nil {
325 log.Printf("Unknown commit, ID: %s", webhook.Build.ID)
326 } else {
327 var verify string
328 var status string
329
330 if webhook.Build.State == "passed" {
331 verify = "+1"
332 status = "Succeeded"
333 } else {
334 verify = "-1"
335 status = "Failed"
336 }
337
338 cmd := exec.Command("ssh",
339 "-p",
340 "29418",
341 "-i",
342 s.Key,
343 s.User+"@software.frc971.org",
344 "gerrit",
345 "review",
346 "-m",
347 fmt.Sprintf("\"Build %s: %s\"", status, webhook.Build.WebURL),
348 "--verified",
349 verify,
350 fmt.Sprintf("%d,%d", commit.ChangeNumber, commit.Patchset))
351
352 log.Printf("Running 'ssh -p 29418 -i %s %s@software.frc971.org gerrit review -m '\"Build %s: %s\"' --verified %s %d,%d' and waiting for it to finish...",
353 s.Key, s.User,
354 status, webhook.Build.WebURL, verify, commit.ChangeNumber, commit.Patchset)
355 if err := cmd.Run(); err != nil {
356 log.Printf("Command failed with error: %v", err)
357 }
358
359 }
360 if webhook.Build.State == "passed" {
361 log.Printf("Passed build %s: %s", webhook.Build.ID, webhook.Build.Commit)
362 } else {
363 log.Printf("Failed build %s: %s", webhook.Build.ID, webhook.Build.Commit)
364 }
365 }
366 }
367
368 go f()
369
370 log.Printf("%s: %s %s %s\n", webhook.Event, webhook.Build.ID, webhook.Build.Commit, webhook.Build.Branch)
371
372 fmt.Fprintf(w, "")
373
374 default:
375 internalError := http.StatusInternalServerError
376 http.Error(w, "Invalid method", internalError)
377 log.Printf("Invalid method %s", r.Method)
378 }
379}
380
381func main() {
382 apiToken := flag.String("token", "", "API token")
383 webhookToken := flag.String("webhook_token", "", "Expected webhook token")
384 user := flag.String("user", "buildkite", "User to be in gerrit")
385 key := flag.String("key", "~/.ssh/buildkite", "SSH key to use to connect to gerrit")
386 debug := flag.Bool("debug", false, "Enable debugging")
387
388 flag.Parse()
389
390 state := State{
391 Key: *key,
392 User: *user,
393 Commits: make(map[string]Commit),
394 Token: *webhookToken,
395 }
396
397 f := func() {
398 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
399 state.handle(w, r)
400 })
401 log.Println("Starting webhook server on 10005\n")
402 if err := http.ListenAndServe(":10005", nil); err != nil {
403 log.Fatal(err)
404 }
405 }
406
407 if *apiToken == "nope" {
408 log.Println("Only starting server")
409 f()
410 } else {
411 go f()
412 }
413
414 config, err := buildkite.NewTokenConfig(*apiToken, *debug)
415
416 if err != nil {
417 log.Fatalf("client config failed: %s", err)
418 }
419
420 client := buildkite.NewClient(config.Client())
421
422 for {
423 args := fmt.Sprintf("-o ServerAliveInterval=10 -o ServerAliveCountMax=3 -i %s -p 29418 %s@software.frc971.org gerrit stream-events", state.Key, state.User)
424 cmd := exec.Command("ssh", strings.Split(args, " ")...)
425
426 stdout, _ := cmd.StdoutPipe()
427 cmd.Start()
428
429 scanner := bufio.NewScanner(stdout)
430 scanner.Split(bufio.ScanLines)
431 for scanner.Scan() {
432 m := scanner.Text()
433
434 log.Println(m)
435
436 var eventInfo EventInfo
437 dec := json.NewDecoder(bytes.NewReader([]byte(m)))
438 dec.DisallowUnknownFields()
439
440 if err := dec.Decode(&eventInfo); err != nil {
441 log.Printf("Failed to parse JSON: %e\n", err)
442 continue
443 }
444
445 log.Printf("Got an event of type: '%s'\n", eventInfo.Type)
446 switch eventInfo.Type {
447 case "assignee-changed":
448 case "change-abandoned":
449 case "change-deleted":
450 case "change-merged":
Austin Schuh68d28912021-03-21 21:59:28 -0700451 if eventInfo.RefName == "refs/heads/master" && eventInfo.Change.Status == "MERGED" {
452 if build, _, err := client.Builds.Create(
453 "spartan-robotics", "971-robot-code", &buildkite.CreateBuild{
454 Commit: eventInfo.NewRev,
455 Branch: "master",
456 Author: buildkite.Author{
457 Name: eventInfo.Submitter.Name,
458 Email: eventInfo.Submitter.Email,
459 },
460 }); err == nil {
461 log.Printf("Scheduled master build %s\n", *build.ID)
462 } else {
463 log.Printf("Failed to schedule master build %v", err)
464 // TODO(austin): Notify failure to build. Stephan should be able to pick this up in nagios.
465 }
466 }
Austin Schuhfd8431e2020-12-27 19:35:04 -0800467 case "change-restored":
468 case "comment-added":
469 if matched, _ := regexp.MatchString(`(?m)^retest$`, eventInfo.Comment); !matched {
470 continue
471 }
472
473 state.handleEvent(eventInfo, client)
474 case "dropped-output":
475 case "hashtags-changed":
476 case "project-created":
477 case "patchset-created":
478 state.handleEvent(eventInfo, client)
479 case "ref-updated":
480 case "reviewer-added":
481 case "reviewer-deleted":
482 case "topic-changed":
483 case "wip-state-changed":
484 case "private-state-changed":
485 case "vote-deleted":
486 default:
487 log.Println("Unknown case")
488 }
489 }
490 cmd.Wait()
491 }
492}