blob: 7d789f01d943efb1903a46670d0fabd202f255c6 [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 Schuhfd8431e2020-12-27 19:35:04 -080092 Submitter User `json:"submitter,omitempty"`
93 Approvals []Approval `json:"approvals,omitempty"`
94 Comment string `json:"comment,omitempty"`
95 PatchSet *PatchSet `json:"patchSet"`
96 Change *Change `json:"change"`
97 Project string `json:"project"`
98 RefName string `json:"refName"`
99 ChangeKey ChangeKey `json:"changeKey"`
100 RefUpdate *RefUpdate `json:"refUpdate"`
101 Type string `json:"type"`
102 EventCreatedOn int `json:"eventCreatedOn"`
103}
104
105// Simple application to poll Gerrit for events and trigger builds on buildkite when one happens.
106
107// Handles a gerrit event and triggers buildkite accordingly.
108func (s *State) handleEvent(eventInfo EventInfo, client *buildkite.Client) {
109 // Only work on 971-Robot-Code
110 if eventInfo.Project != "971-Robot-Code" {
111 log.Printf("Ignoring project: '%s'\n", eventInfo.Project)
112 return
113 }
114
115 // Find the change id, change number, patchset revision
116 if eventInfo.Change == nil {
117 log.Println("Failed to find Change")
118 return
119 }
120
121 if eventInfo.PatchSet == nil {
122 log.Println("Failed to find Change")
123 return
124 }
125
126 log.Printf("Got a matching change of %s %s %d,%d\n",
127 eventInfo.Change.ID, eventInfo.PatchSet.Revision, eventInfo.Change.Number, eventInfo.PatchSet.Number)
128
Austin Schuh07450432021-03-21 20:29:07 -0700129 for {
Austin Schuhfd8431e2020-12-27 19:35:04 -0800130
Austin Schuh07450432021-03-21 20:29:07 -0700131 // 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.
132 s.mu.Lock()
Austin Schuhfd8431e2020-12-27 19:35:04 -0800133
Austin Schuh07450432021-03-21 20:29:07 -0700134 var user *User
135 if eventInfo.Author != nil {
136 user = eventInfo.Author
137 } else if eventInfo.Uploader != nil {
138 user = eventInfo.Uploader
Austin Schuhfd8431e2020-12-27 19:35:04 -0800139 } else {
Austin Schuh07450432021-03-21 20:29:07 -0700140 log.Fatalf("Failed to find Author or Uploader")
Austin Schuhfd8431e2020-12-27 19:35:04 -0800141 }
142
Austin Schuh07450432021-03-21 20:29:07 -0700143 // Trigger the build.
144 if build, _, err := client.Builds.Create(
145 "spartan-robotics", "971-robot-code", &buildkite.CreateBuild{
146 Commit: eventInfo.PatchSet.Revision,
147 Branch: eventInfo.Change.ID,
148 Author: buildkite.Author{
149 Name: user.Name,
150 Email: user.Email,
151 },
152 Env: map[string]string{
153 "GERRIT_CHANGE_NUMBER": fmt.Sprintf("%d", eventInfo.Change.Number),
154 "GERRIT_PATCH_NUMBER": fmt.Sprintf("%d", eventInfo.PatchSet.Number),
155 },
156 }); err == nil {
Austin Schuhfd8431e2020-12-27 19:35:04 -0800157
Austin Schuh07450432021-03-21 20:29:07 -0700158 if build.ID != nil {
159 log.Printf("Scheduled build %s\n", *build.ID)
160 s.Commits[*build.ID] = Commit{
161 Sha1: eventInfo.PatchSet.Revision,
162 ChangeId: eventInfo.Change.ID,
163 ChangeNumber: eventInfo.Change.Number,
164 Patchset: eventInfo.PatchSet.Number,
165 }
166 }
167 s.mu.Unlock()
168
169 if data, err := json.MarshalIndent(build, "", "\t"); err != nil {
170 log.Fatalf("json encode failed: %s", err)
171 } else {
172 log.Printf("%s\n", string(data))
173 }
174
175 // Now remove the verified from Gerrit and post the link.
176 cmd := exec.Command("ssh",
177 "-p",
178 "29418",
179 "-i",
180 s.Key,
181 s.User+"@software.frc971.org",
182 "gerrit",
183 "review",
184 "-m",
185 fmt.Sprintf("\"Build Started: %s\"", *build.WebURL),
186 "--verified",
187 "0",
188 fmt.Sprintf("%d,%d", eventInfo.Change.Number, eventInfo.PatchSet.Number))
189
190 log.Printf("Running 'ssh -p 29418 -i %s %s@software.frc971.org gerrit review -m '\"Build Started: %s\"' --verified 0 %d,%d' and waiting for it to finish...",
191 s.Key, s.User,
192 *build.WebURL, eventInfo.Change.Number, eventInfo.PatchSet.Number)
193 if err := cmd.Run(); err != nil {
194 log.Printf("Command failed with error: %v", err)
195 }
196 return
197 } else {
198 s.mu.Unlock()
199 log.Printf("Failed to trigger build: %s", err)
200 log.Printf("Trying again in 30 seconds")
201 time.Sleep(30 * time.Second)
Austin Schuhfd8431e2020-12-27 19:35:04 -0800202 }
Austin Schuhfd8431e2020-12-27 19:35:04 -0800203 }
204
205}
206
Austin Schuh07450432021-03-21 20:29:07 -0700207type BuildkiteChange struct {
208 ID string `json:"id,omitempty"`
209 Number int `json:"number,omitempty"`
210 URL string `json:"url,omitempty"`
211}
212
Austin Schuhfd8431e2020-12-27 19:35:04 -0800213type Build struct {
Austin Schuh07450432021-03-21 20:29:07 -0700214 ID string `json:"id,omitempty"`
215 GraphqlId string `json:"graphql_id,omitempty"`
216 URL string `json:"url,omitempty"`
217 WebURL string `json:"web_url,omitempty"`
218 Number int `json:"number,omitempty"`
219 State string `json:"state,omitempty"`
220 Blocked bool `json:"blocked,omitempty"`
221 BlockedState string `json:"blocked_state,omitempty"`
222 Message string `json:"message,omitempty"`
223 Commit string `json:"commit"`
224 Branch string `json:"branch"`
225 Source string `json:"source,omitempty"`
226 CreatedAt string `json:"created_at,omitempty"`
227 ScheduledAt string `json:"scheduled_at,omitempty"`
228 StartedAt string `json:"started_at,omitempty"`
229 FinishedAt string `json:"finished_at,omitempty"`
230 RebuiltFrom *BuildkiteChange `json:"rebuilt_from,omitempty"`
Austin Schuhfd8431e2020-12-27 19:35:04 -0800231}
232
233type BuildkiteWebhook struct {
234 Event string `json:"event"`
235 Build Build `json:"build"`
236}
237
238func (s *State) handle(w http.ResponseWriter, r *http.Request) {
239 if r.URL.Path != "/" {
240 http.Error(w, "404 not found.", http.StatusNotFound)
241 return
242 }
243
244 switch r.Method {
245 case "POST":
246 if r.Header.Get("X-Buildkite-Token") != s.Token {
247 http.Error(w, "Invalid token", http.StatusBadRequest)
248 return
249 }
250
251 var data []byte
252 var err error
253 if data, err = ioutil.ReadAll(r.Body); err != nil {
254 http.Error(w, err.Error(), http.StatusBadRequest)
255 return
256 }
257
258 log.Println(string(data))
259
260 var webhook BuildkiteWebhook
261
262 if err := json.Unmarshal(data, &webhook); err != nil {
263 log.Fatalf("json decode failed: %s", err)
264 http.Error(w, err.Error(), http.StatusBadRequest)
265 return
266 }
267
268 // We've successfully received the webhook. Spawn a goroutine in case the mutex is blocked so we don't block this thread.
269 f := func() {
Austin Schuh07450432021-03-21 20:29:07 -0700270 if webhook.Event == "build.running" {
271 if webhook.Build.RebuiltFrom != nil {
272 s.mu.Lock()
273 if c, ok := s.Commits[webhook.Build.RebuiltFrom.ID]; ok {
274 log.Printf("Detected a rebuild of %s for build %s", webhook.Build.RebuiltFrom.ID, webhook.Build.ID)
275 s.Commits[webhook.Build.ID] = c
276
277 // And now remove the vote since the rebuild started.
278 cmd := exec.Command("ssh",
279 "-p",
280 "29418",
281 "-i",
282 s.Key,
283 s.User+"@software.frc971.org",
284 "gerrit",
285 "review",
286 "-m",
287 fmt.Sprintf("\"Build Started: %s\"", webhook.Build.WebURL),
288 "--verified",
289 "0",
290 fmt.Sprintf("%d,%d", c.ChangeNumber, c.Patchset))
291
292 log.Printf("Running 'ssh -p 29418 -i %s %s@software.frc971.org gerrit review -m '\"Build Started: %s\"' --verified 0 %d,%d' and waiting for it to finish...",
293 s.Key, s.User,
294 webhook.Build.WebURL, c.ChangeNumber, c.Patchset)
295 if err := cmd.Run(); err != nil {
296 log.Printf("Command failed with error: %v", err)
297 }
298 }
299 s.mu.Unlock()
300 }
301 } else if webhook.Event == "build.finished" {
Austin Schuhfd8431e2020-12-27 19:35:04 -0800302 var commit *Commit
303 {
304 s.mu.Lock()
305 if c, ok := s.Commits[webhook.Build.ID]; ok {
306 commit = &c
Austin Schuh07450432021-03-21 20:29:07 -0700307 // While we *should* delete this now from the map, that will prevent rebuilds from being mapped correctly.
308 // 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 -0800309 }
310 s.mu.Unlock()
311 }
312
313 if commit == nil {
314 log.Printf("Unknown commit, ID: %s", webhook.Build.ID)
315 } else {
316 var verify string
317 var status string
318
319 if webhook.Build.State == "passed" {
320 verify = "+1"
321 status = "Succeeded"
322 } else {
323 verify = "-1"
324 status = "Failed"
325 }
326
327 cmd := exec.Command("ssh",
328 "-p",
329 "29418",
330 "-i",
331 s.Key,
332 s.User+"@software.frc971.org",
333 "gerrit",
334 "review",
335 "-m",
336 fmt.Sprintf("\"Build %s: %s\"", status, webhook.Build.WebURL),
337 "--verified",
338 verify,
339 fmt.Sprintf("%d,%d", commit.ChangeNumber, commit.Patchset))
340
341 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...",
342 s.Key, s.User,
343 status, webhook.Build.WebURL, verify, commit.ChangeNumber, commit.Patchset)
344 if err := cmd.Run(); err != nil {
345 log.Printf("Command failed with error: %v", err)
346 }
347
348 }
349 if webhook.Build.State == "passed" {
350 log.Printf("Passed build %s: %s", webhook.Build.ID, webhook.Build.Commit)
351 } else {
352 log.Printf("Failed build %s: %s", webhook.Build.ID, webhook.Build.Commit)
353 }
354 }
355 }
356
357 go f()
358
359 log.Printf("%s: %s %s %s\n", webhook.Event, webhook.Build.ID, webhook.Build.Commit, webhook.Build.Branch)
360
361 fmt.Fprintf(w, "")
362
363 default:
364 internalError := http.StatusInternalServerError
365 http.Error(w, "Invalid method", internalError)
366 log.Printf("Invalid method %s", r.Method)
367 }
368}
369
370func main() {
371 apiToken := flag.String("token", "", "API token")
372 webhookToken := flag.String("webhook_token", "", "Expected webhook token")
373 user := flag.String("user", "buildkite", "User to be in gerrit")
374 key := flag.String("key", "~/.ssh/buildkite", "SSH key to use to connect to gerrit")
375 debug := flag.Bool("debug", false, "Enable debugging")
376
377 flag.Parse()
378
379 state := State{
380 Key: *key,
381 User: *user,
382 Commits: make(map[string]Commit),
383 Token: *webhookToken,
384 }
385
386 f := func() {
387 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
388 state.handle(w, r)
389 })
390 log.Println("Starting webhook server on 10005\n")
391 if err := http.ListenAndServe(":10005", nil); err != nil {
392 log.Fatal(err)
393 }
394 }
395
396 if *apiToken == "nope" {
397 log.Println("Only starting server")
398 f()
399 } else {
400 go f()
401 }
402
403 config, err := buildkite.NewTokenConfig(*apiToken, *debug)
404
405 if err != nil {
406 log.Fatalf("client config failed: %s", err)
407 }
408
409 client := buildkite.NewClient(config.Client())
410
411 for {
412 args := fmt.Sprintf("-o ServerAliveInterval=10 -o ServerAliveCountMax=3 -i %s -p 29418 %s@software.frc971.org gerrit stream-events", state.Key, state.User)
413 cmd := exec.Command("ssh", strings.Split(args, " ")...)
414
415 stdout, _ := cmd.StdoutPipe()
416 cmd.Start()
417
418 scanner := bufio.NewScanner(stdout)
419 scanner.Split(bufio.ScanLines)
420 for scanner.Scan() {
421 m := scanner.Text()
422
423 log.Println(m)
424
425 var eventInfo EventInfo
426 dec := json.NewDecoder(bytes.NewReader([]byte(m)))
427 dec.DisallowUnknownFields()
428
429 if err := dec.Decode(&eventInfo); err != nil {
430 log.Printf("Failed to parse JSON: %e\n", err)
431 continue
432 }
433
434 log.Printf("Got an event of type: '%s'\n", eventInfo.Type)
435 switch eventInfo.Type {
436 case "assignee-changed":
437 case "change-abandoned":
438 case "change-deleted":
439 case "change-merged":
440 // TODO(austin): Trigger a master build?
441 case "change-restored":
442 case "comment-added":
443 if matched, _ := regexp.MatchString(`(?m)^retest$`, eventInfo.Comment); !matched {
444 continue
445 }
446
447 state.handleEvent(eventInfo, client)
448 case "dropped-output":
449 case "hashtags-changed":
450 case "project-created":
451 case "patchset-created":
452 state.handleEvent(eventInfo, client)
453 case "ref-updated":
454 case "reviewer-added":
455 case "reviewer-deleted":
456 case "topic-changed":
457 case "wip-state-changed":
458 case "private-state-changed":
459 case "vote-deleted":
460 default:
461 log.Println("Unknown case")
462 }
463 }
464 cmd.Wait()
465 }
466}