blob: eb31a9255ef5ec992b589749e017c436db282441 [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"
17)
18
19type Commit struct {
20 Sha1 string
21 ChangeId string
22 ChangeNumber int
23 Patchset int
24}
25
26type State struct {
27 // This mutex needs to be locked across anything which generates a uuid or accesses Commits.
28 mu sync.Mutex
29 // Mapping from build UUID from buildkite to the Commit information which triggered it.
30 Commits map[string]Commit
31 User string
32 Key string
33 // Webhook token expected to service requests
34 Token string
35}
36
37type Approval struct {
38 Type string `json:"type"`
39 Description string `json:"description"`
40 Value string `json:"value"`
41 OldValue string `json:"oldValue"`
42}
43
44type User struct {
45 Name string `json:"name"`
46 Email string `json:"email,omitempty"`
47 Username string `json:"username,omitempty"`
48}
49
50type PatchSet struct {
51 Number int `json:"number"`
52 Revision string `json:"revision"`
53 Parents []string `json:"parents"`
54 Ref string `json:"ref"`
55 Uploader User `json:"uploader"`
56 CreatedOn int `json:"createdOn"`
57 Author User `json:"author"`
58 Kind string `json:"kind,omitempty"`
59 SizeInsertions int `json:"sizeInsertions,omitempty"`
60 SizeDeletions int `json:"sizeDeletions,omitempty"`
61}
62
63type Change struct {
64 Project string `json:"project"`
65 Branch string `json:"branch"`
66 ID string `json:"id"`
67 Number int `json:"number"`
68 Subject string `json:"subject"`
69 Owner User `json:"owner"`
70 URL string `json:"url"`
71 CommitMessage string `json:"commitMessage"`
72 CreatedOn int `json:"createdOn"`
73 Status string `json:"status"`
74}
75
76type ChangeKey struct {
77 ID string `json:"id"`
78}
79
80type RefUpdate struct {
81 OldRev string `json:"oldRev"`
82 NewRev string `json:"newRev"`
83 RefName string `json:"refName"`
84 Project string `json:"project"`
85}
86
87type EventInfo struct {
88 Author *User `json:"author"`
89 Uploader *User `json:"uploader"`
90 Submitter User `json:"submitter,omitempty"`
91 Approvals []Approval `json:"approvals,omitempty"`
92 Comment string `json:"comment,omitempty"`
93 PatchSet *PatchSet `json:"patchSet"`
94 Change *Change `json:"change"`
95 Project string `json:"project"`
96 RefName string `json:"refName"`
97 ChangeKey ChangeKey `json:"changeKey"`
98 RefUpdate *RefUpdate `json:"refUpdate"`
99 Type string `json:"type"`
100 EventCreatedOn int `json:"eventCreatedOn"`
101}
102
103// Simple application to poll Gerrit for events and trigger builds on buildkite when one happens.
104
105// Handles a gerrit event and triggers buildkite accordingly.
106func (s *State) handleEvent(eventInfo EventInfo, client *buildkite.Client) {
107 // Only work on 971-Robot-Code
108 if eventInfo.Project != "971-Robot-Code" {
109 log.Printf("Ignoring project: '%s'\n", eventInfo.Project)
110 return
111 }
112
113 // Find the change id, change number, patchset revision
114 if eventInfo.Change == nil {
115 log.Println("Failed to find Change")
116 return
117 }
118
119 if eventInfo.PatchSet == nil {
120 log.Println("Failed to find Change")
121 return
122 }
123
124 log.Printf("Got a matching change of %s %s %d,%d\n",
125 eventInfo.Change.ID, eventInfo.PatchSet.Revision, eventInfo.Change.Number, eventInfo.PatchSet.Number)
126
127 // 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.
128 s.mu.Lock()
129
130 var user *User
131 if eventInfo.Author != nil {
132 user = eventInfo.Author
133 } else if eventInfo.Uploader != nil {
134 user = eventInfo.Uploader
135 } else {
136 log.Fatalf("Failed to find Author or Uploader")
137 }
138
139 // Trigger the build.
140 if build, _, err := client.Builds.Create(
141 "spartan-robotics", "971-robot-code", &buildkite.CreateBuild{
142 Commit: eventInfo.PatchSet.Revision,
143 Branch: eventInfo.Change.ID,
144 Author: buildkite.Author{
145 Name: user.Name,
146 Email: user.Email,
147 },
148 Env: map[string]string{
149 "GERRIT_CHANGE_NUMBER": fmt.Sprintf("%d", eventInfo.Change.Number),
150 "GERRIT_PATCH_NUMBER": fmt.Sprintf("%d", eventInfo.PatchSet.Number),
151 },
152 }); err == nil {
153
154 if build.ID != nil {
155 log.Printf("Scheduled build %s\n", *build.ID)
156 s.Commits[*build.ID] = Commit{
157 Sha1: eventInfo.PatchSet.Revision,
158 ChangeId: eventInfo.Change.ID,
159 ChangeNumber: eventInfo.Change.Number,
160 Patchset: eventInfo.PatchSet.Number,
161 }
162 }
163 s.mu.Unlock()
164
165 if data, err := json.MarshalIndent(build, "", "\t"); err != nil {
166 log.Fatalf("json encode failed: %s", err)
167 } else {
168 log.Printf("%s\n", string(data))
169 }
170
171 // Now remove the verified from Gerrit and post the link.
172 cmd := exec.Command("ssh",
173 "-p",
174 "29418",
175 "-i",
176 s.Key,
177 s.User+"@software.frc971.org",
178 "gerrit",
179 "review",
180 "-m",
181 fmt.Sprintf("\"Build Started: %s\"", *build.WebURL),
182 "--verified",
183 "0",
184 fmt.Sprintf("%d,%d", eventInfo.Change.Number, eventInfo.PatchSet.Number))
185
186 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...",
187 s.Key, s.User,
188 *build.WebURL, eventInfo.Change.Number, eventInfo.PatchSet.Number)
189 if err := cmd.Run(); err != nil {
190 log.Printf("Command failed with error: %v", err)
191 }
192 } else {
193 s.mu.Unlock()
194 log.Fatalf("Failed to trigger build: %s", err)
195 }
196
197}
198
199type Build struct {
200 ID string `json:"id,omitempty"`
201 GraphqlId string `json:"graphql_id,omitempty"`
202 URL string `json:"url,omitempty"`
203 WebURL string `json:"web_url,omitempty"`
204 Number int `json:"number,omitempty"`
205 State string `json:"state,omitempty"`
206 Blocked bool `json:"blocked,omitempty"`
207 BlockedState string `json:"blocked_state,omitempty"`
208 Message string `json:"message,omitempty"`
209 Commit string `json:"commit"`
210 Branch string `json:"branch"`
211 Source string `json:"source,omitempty"`
212 CreatedAt string `json:"created_at,omitempty"`
213 ScheduledAt string `json:"scheduled_at,omitempty"`
214 StartedAt string `json:"started_at,omitempty"`
215 FinishedAt string `json:"finished_at,omitempty"`
216}
217
218type BuildkiteWebhook struct {
219 Event string `json:"event"`
220 Build Build `json:"build"`
221}
222
223func (s *State) handle(w http.ResponseWriter, r *http.Request) {
224 if r.URL.Path != "/" {
225 http.Error(w, "404 not found.", http.StatusNotFound)
226 return
227 }
228
229 switch r.Method {
230 case "POST":
231 if r.Header.Get("X-Buildkite-Token") != s.Token {
232 http.Error(w, "Invalid token", http.StatusBadRequest)
233 return
234 }
235
236 var data []byte
237 var err error
238 if data, err = ioutil.ReadAll(r.Body); err != nil {
239 http.Error(w, err.Error(), http.StatusBadRequest)
240 return
241 }
242
243 log.Println(string(data))
244
245 var webhook BuildkiteWebhook
246
247 if err := json.Unmarshal(data, &webhook); err != nil {
248 log.Fatalf("json decode failed: %s", err)
249 http.Error(w, err.Error(), http.StatusBadRequest)
250 return
251 }
252
253 // We've successfully received the webhook. Spawn a goroutine in case the mutex is blocked so we don't block this thread.
254 f := func() {
255 if webhook.Event == "build.finished" {
256 var commit *Commit
257 {
258 s.mu.Lock()
259 if c, ok := s.Commits[webhook.Build.ID]; ok {
260 commit = &c
261 delete(s.Commits, webhook.Build.ID)
262 }
263 s.mu.Unlock()
264 }
265
266 if commit == nil {
267 log.Printf("Unknown commit, ID: %s", webhook.Build.ID)
268 } else {
269 var verify string
270 var status string
271
272 if webhook.Build.State == "passed" {
273 verify = "+1"
274 status = "Succeeded"
275 } else {
276 verify = "-1"
277 status = "Failed"
278 }
279
280 cmd := exec.Command("ssh",
281 "-p",
282 "29418",
283 "-i",
284 s.Key,
285 s.User+"@software.frc971.org",
286 "gerrit",
287 "review",
288 "-m",
289 fmt.Sprintf("\"Build %s: %s\"", status, webhook.Build.WebURL),
290 "--verified",
291 verify,
292 fmt.Sprintf("%d,%d", commit.ChangeNumber, commit.Patchset))
293
294 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...",
295 s.Key, s.User,
296 status, webhook.Build.WebURL, verify, commit.ChangeNumber, commit.Patchset)
297 if err := cmd.Run(); err != nil {
298 log.Printf("Command failed with error: %v", err)
299 }
300
301 }
302 if webhook.Build.State == "passed" {
303 log.Printf("Passed build %s: %s", webhook.Build.ID, webhook.Build.Commit)
304 } else {
305 log.Printf("Failed build %s: %s", webhook.Build.ID, webhook.Build.Commit)
306 }
307 }
308 }
309
310 go f()
311
312 log.Printf("%s: %s %s %s\n", webhook.Event, webhook.Build.ID, webhook.Build.Commit, webhook.Build.Branch)
313
314 fmt.Fprintf(w, "")
315
316 default:
317 internalError := http.StatusInternalServerError
318 http.Error(w, "Invalid method", internalError)
319 log.Printf("Invalid method %s", r.Method)
320 }
321}
322
323func main() {
324 apiToken := flag.String("token", "", "API token")
325 webhookToken := flag.String("webhook_token", "", "Expected webhook token")
326 user := flag.String("user", "buildkite", "User to be in gerrit")
327 key := flag.String("key", "~/.ssh/buildkite", "SSH key to use to connect to gerrit")
328 debug := flag.Bool("debug", false, "Enable debugging")
329
330 flag.Parse()
331
332 state := State{
333 Key: *key,
334 User: *user,
335 Commits: make(map[string]Commit),
336 Token: *webhookToken,
337 }
338
339 f := func() {
340 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
341 state.handle(w, r)
342 })
343 log.Println("Starting webhook server on 10005\n")
344 if err := http.ListenAndServe(":10005", nil); err != nil {
345 log.Fatal(err)
346 }
347 }
348
349 if *apiToken == "nope" {
350 log.Println("Only starting server")
351 f()
352 } else {
353 go f()
354 }
355
356 config, err := buildkite.NewTokenConfig(*apiToken, *debug)
357
358 if err != nil {
359 log.Fatalf("client config failed: %s", err)
360 }
361
362 client := buildkite.NewClient(config.Client())
363
364 for {
365 args := fmt.Sprintf("-o ServerAliveInterval=10 -o ServerAliveCountMax=3 -i %s -p 29418 %s@software.frc971.org gerrit stream-events", state.Key, state.User)
366 cmd := exec.Command("ssh", strings.Split(args, " ")...)
367
368 stdout, _ := cmd.StdoutPipe()
369 cmd.Start()
370
371 scanner := bufio.NewScanner(stdout)
372 scanner.Split(bufio.ScanLines)
373 for scanner.Scan() {
374 m := scanner.Text()
375
376 log.Println(m)
377
378 var eventInfo EventInfo
379 dec := json.NewDecoder(bytes.NewReader([]byte(m)))
380 dec.DisallowUnknownFields()
381
382 if err := dec.Decode(&eventInfo); err != nil {
383 log.Printf("Failed to parse JSON: %e\n", err)
384 continue
385 }
386
387 log.Printf("Got an event of type: '%s'\n", eventInfo.Type)
388 switch eventInfo.Type {
389 case "assignee-changed":
390 case "change-abandoned":
391 case "change-deleted":
392 case "change-merged":
393 // TODO(austin): Trigger a master build?
394 case "change-restored":
395 case "comment-added":
396 if matched, _ := regexp.MatchString(`(?m)^retest$`, eventInfo.Comment); !matched {
397 continue
398 }
399
400 state.handleEvent(eventInfo, client)
401 case "dropped-output":
402 case "hashtags-changed":
403 case "project-created":
404 case "patchset-created":
405 state.handleEvent(eventInfo, client)
406 case "ref-updated":
407 case "reviewer-added":
408 case "reviewer-deleted":
409 case "topic-changed":
410 case "wip-state-changed":
411 case "private-state-changed":
412 case "vote-deleted":
413 default:
414 log.Println("Unknown case")
415 }
416 }
417 cmd.Wait()
418 }
419}