Merge "Add a golang program used to trigger buildkite from gerrit"
diff --git a/tools/ci/buildkite_gerrit_trigger.go b/tools/ci/buildkite_gerrit_trigger.go
new file mode 100644
index 0000000..eb31a92
--- /dev/null
+++ b/tools/ci/buildkite_gerrit_trigger.go
@@ -0,0 +1,419 @@
+package main
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"github.com/buildkite/go-buildkite/buildkite"
+	"io/ioutil"
+	"log"
+	"net/http"
+	"os/exec"
+	"regexp"
+	"strings"
+	"sync"
+)
+
+type Commit struct {
+	Sha1         string
+	ChangeId     string
+	ChangeNumber int
+	Patchset     int
+}
+
+type State struct {
+	// This mutex needs to be locked across anything which generates a uuid or accesses Commits.
+	mu sync.Mutex
+	// Mapping from build UUID from buildkite to the Commit information which triggered it.
+	Commits map[string]Commit
+	User    string
+	Key     string
+	// Webhook token expected to service requests
+	Token string
+}
+
+type Approval struct {
+	Type        string `json:"type"`
+	Description string `json:"description"`
+	Value       string `json:"value"`
+	OldValue    string `json:"oldValue"`
+}
+
+type User struct {
+	Name     string `json:"name"`
+	Email    string `json:"email,omitempty"`
+	Username string `json:"username,omitempty"`
+}
+
+type PatchSet struct {
+	Number         int      `json:"number"`
+	Revision       string   `json:"revision"`
+	Parents        []string `json:"parents"`
+	Ref            string   `json:"ref"`
+	Uploader       User     `json:"uploader"`
+	CreatedOn      int      `json:"createdOn"`
+	Author         User     `json:"author"`
+	Kind           string   `json:"kind,omitempty"`
+	SizeInsertions int      `json:"sizeInsertions,omitempty"`
+	SizeDeletions  int      `json:"sizeDeletions,omitempty"`
+}
+
+type Change struct {
+	Project       string `json:"project"`
+	Branch        string `json:"branch"`
+	ID            string `json:"id"`
+	Number        int    `json:"number"`
+	Subject       string `json:"subject"`
+	Owner         User   `json:"owner"`
+	URL           string `json:"url"`
+	CommitMessage string `json:"commitMessage"`
+	CreatedOn     int    `json:"createdOn"`
+	Status        string `json:"status"`
+}
+
+type ChangeKey struct {
+	ID string `json:"id"`
+}
+
+type RefUpdate struct {
+	OldRev  string `json:"oldRev"`
+	NewRev  string `json:"newRev"`
+	RefName string `json:"refName"`
+	Project string `json:"project"`
+}
+
+type EventInfo struct {
+	Author         *User      `json:"author"`
+	Uploader       *User      `json:"uploader"`
+	Submitter      User       `json:"submitter,omitempty"`
+	Approvals      []Approval `json:"approvals,omitempty"`
+	Comment        string     `json:"comment,omitempty"`
+	PatchSet       *PatchSet  `json:"patchSet"`
+	Change         *Change    `json:"change"`
+	Project        string     `json:"project"`
+	RefName        string     `json:"refName"`
+	ChangeKey      ChangeKey  `json:"changeKey"`
+	RefUpdate      *RefUpdate `json:"refUpdate"`
+	Type           string     `json:"type"`
+	EventCreatedOn int        `json:"eventCreatedOn"`
+}
+
+// Simple application to poll Gerrit for events and trigger builds on buildkite when one happens.
+
+// Handles a gerrit event and triggers buildkite accordingly.
+func (s *State) handleEvent(eventInfo EventInfo, client *buildkite.Client) {
+	// Only work on 971-Robot-Code
+	if eventInfo.Project != "971-Robot-Code" {
+		log.Printf("Ignoring project: '%s'\n", eventInfo.Project)
+		return
+	}
+
+	// Find the change id, change number, patchset revision
+	if eventInfo.Change == nil {
+		log.Println("Failed to find Change")
+		return
+	}
+
+	if eventInfo.PatchSet == nil {
+		log.Println("Failed to find Change")
+		return
+	}
+
+	log.Printf("Got a matching change of %s %s %d,%d\n",
+		eventInfo.Change.ID, eventInfo.PatchSet.Revision, eventInfo.Change.Number, eventInfo.PatchSet.Number)
+
+	// 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.
+	s.mu.Lock()
+
+	var user *User
+	if eventInfo.Author != nil {
+		user = eventInfo.Author
+	} else if eventInfo.Uploader != nil {
+		user = eventInfo.Uploader
+	} else {
+		log.Fatalf("Failed to find Author or Uploader")
+	}
+
+	// Trigger the build.
+	if build, _, err := client.Builds.Create(
+		"spartan-robotics", "971-robot-code", &buildkite.CreateBuild{
+			Commit: eventInfo.PatchSet.Revision,
+			Branch: eventInfo.Change.ID,
+			Author: buildkite.Author{
+				Name:  user.Name,
+				Email: user.Email,
+			},
+			Env: map[string]string{
+				"GERRIT_CHANGE_NUMBER": fmt.Sprintf("%d", eventInfo.Change.Number),
+				"GERRIT_PATCH_NUMBER":  fmt.Sprintf("%d", eventInfo.PatchSet.Number),
+			},
+		}); err == nil {
+
+		if build.ID != nil {
+			log.Printf("Scheduled build %s\n", *build.ID)
+			s.Commits[*build.ID] = Commit{
+				Sha1:         eventInfo.PatchSet.Revision,
+				ChangeId:     eventInfo.Change.ID,
+				ChangeNumber: eventInfo.Change.Number,
+				Patchset:     eventInfo.PatchSet.Number,
+			}
+		}
+		s.mu.Unlock()
+
+		if data, err := json.MarshalIndent(build, "", "\t"); err != nil {
+			log.Fatalf("json encode failed: %s", err)
+		} else {
+			log.Printf("%s\n", string(data))
+		}
+
+		// Now remove the verified from Gerrit and post the link.
+		cmd := exec.Command("ssh",
+			"-p",
+			"29418",
+			"-i",
+			s.Key,
+			s.User+"@software.frc971.org",
+			"gerrit",
+			"review",
+			"-m",
+			fmt.Sprintf("\"Build Started: %s\"", *build.WebURL),
+			"--verified",
+			"0",
+			fmt.Sprintf("%d,%d", eventInfo.Change.Number, eventInfo.PatchSet.Number))
+
+		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...",
+			s.Key, s.User,
+			*build.WebURL, eventInfo.Change.Number, eventInfo.PatchSet.Number)
+		if err := cmd.Run(); err != nil {
+			log.Printf("Command failed with error: %v", err)
+		}
+	} else {
+		s.mu.Unlock()
+		log.Fatalf("Failed to trigger build: %s", err)
+	}
+
+}
+
+type Build struct {
+	ID           string `json:"id,omitempty"`
+	GraphqlId    string `json:"graphql_id,omitempty"`
+	URL          string `json:"url,omitempty"`
+	WebURL       string `json:"web_url,omitempty"`
+	Number       int    `json:"number,omitempty"`
+	State        string `json:"state,omitempty"`
+	Blocked      bool   `json:"blocked,omitempty"`
+	BlockedState string `json:"blocked_state,omitempty"`
+	Message      string `json:"message,omitempty"`
+	Commit       string `json:"commit"`
+	Branch       string `json:"branch"`
+	Source       string `json:"source,omitempty"`
+	CreatedAt    string `json:"created_at,omitempty"`
+	ScheduledAt  string `json:"scheduled_at,omitempty"`
+	StartedAt    string `json:"started_at,omitempty"`
+	FinishedAt   string `json:"finished_at,omitempty"`
+}
+
+type BuildkiteWebhook struct {
+	Event string `json:"event"`
+	Build Build  `json:"build"`
+}
+
+func (s *State) handle(w http.ResponseWriter, r *http.Request) {
+	if r.URL.Path != "/" {
+		http.Error(w, "404 not found.", http.StatusNotFound)
+		return
+	}
+
+	switch r.Method {
+	case "POST":
+		if r.Header.Get("X-Buildkite-Token") != s.Token {
+			http.Error(w, "Invalid token", http.StatusBadRequest)
+			return
+		}
+
+		var data []byte
+		var err error
+		if data, err = ioutil.ReadAll(r.Body); err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		log.Println(string(data))
+
+		var webhook BuildkiteWebhook
+
+		if err := json.Unmarshal(data, &webhook); err != nil {
+			log.Fatalf("json decode failed: %s", err)
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+
+		// We've successfully received the webhook.  Spawn a goroutine in case the mutex is blocked so we don't block this thread.
+		f := func() {
+			if webhook.Event == "build.finished" {
+				var commit *Commit
+				{
+					s.mu.Lock()
+					if c, ok := s.Commits[webhook.Build.ID]; ok {
+						commit = &c
+						delete(s.Commits, webhook.Build.ID)
+					}
+					s.mu.Unlock()
+				}
+
+				if commit == nil {
+					log.Printf("Unknown commit, ID: %s", webhook.Build.ID)
+				} else {
+					var verify string
+					var status string
+
+					if webhook.Build.State == "passed" {
+						verify = "+1"
+						status = "Succeeded"
+					} else {
+						verify = "-1"
+						status = "Failed"
+					}
+
+					cmd := exec.Command("ssh",
+						"-p",
+						"29418",
+						"-i",
+						s.Key,
+						s.User+"@software.frc971.org",
+						"gerrit",
+						"review",
+						"-m",
+						fmt.Sprintf("\"Build %s: %s\"", status, webhook.Build.WebURL),
+						"--verified",
+						verify,
+						fmt.Sprintf("%d,%d", commit.ChangeNumber, commit.Patchset))
+
+					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...",
+						s.Key, s.User,
+						status, webhook.Build.WebURL, verify, commit.ChangeNumber, commit.Patchset)
+					if err := cmd.Run(); err != nil {
+						log.Printf("Command failed with error: %v", err)
+					}
+
+				}
+				if webhook.Build.State == "passed" {
+					log.Printf("Passed build %s: %s", webhook.Build.ID, webhook.Build.Commit)
+				} else {
+					log.Printf("Failed build %s: %s", webhook.Build.ID, webhook.Build.Commit)
+				}
+			}
+		}
+
+		go f()
+
+		log.Printf("%s: %s %s %s\n", webhook.Event, webhook.Build.ID, webhook.Build.Commit, webhook.Build.Branch)
+
+		fmt.Fprintf(w, "")
+
+	default:
+		internalError := http.StatusInternalServerError
+		http.Error(w, "Invalid method", internalError)
+		log.Printf("Invalid method %s", r.Method)
+	}
+}
+
+func main() {
+	apiToken := flag.String("token", "", "API token")
+	webhookToken := flag.String("webhook_token", "", "Expected webhook token")
+	user := flag.String("user", "buildkite", "User to be in gerrit")
+	key := flag.String("key", "~/.ssh/buildkite", "SSH key to use to connect to gerrit")
+	debug := flag.Bool("debug", false, "Enable debugging")
+
+	flag.Parse()
+
+	state := State{
+		Key:     *key,
+		User:    *user,
+		Commits: make(map[string]Commit),
+		Token:   *webhookToken,
+	}
+
+	f := func() {
+		http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+			state.handle(w, r)
+		})
+		log.Println("Starting webhook server on 10005\n")
+		if err := http.ListenAndServe(":10005", nil); err != nil {
+			log.Fatal(err)
+		}
+	}
+
+	if *apiToken == "nope" {
+		log.Println("Only starting server")
+		f()
+	} else {
+		go f()
+	}
+
+	config, err := buildkite.NewTokenConfig(*apiToken, *debug)
+
+	if err != nil {
+		log.Fatalf("client config failed: %s", err)
+	}
+
+	client := buildkite.NewClient(config.Client())
+
+	for {
+		args := fmt.Sprintf("-o ServerAliveInterval=10 -o ServerAliveCountMax=3 -i %s -p 29418 %s@software.frc971.org gerrit stream-events", state.Key, state.User)
+		cmd := exec.Command("ssh", strings.Split(args, " ")...)
+
+		stdout, _ := cmd.StdoutPipe()
+		cmd.Start()
+
+		scanner := bufio.NewScanner(stdout)
+		scanner.Split(bufio.ScanLines)
+		for scanner.Scan() {
+			m := scanner.Text()
+
+			log.Println(m)
+
+			var eventInfo EventInfo
+			dec := json.NewDecoder(bytes.NewReader([]byte(m)))
+			dec.DisallowUnknownFields()
+
+			if err := dec.Decode(&eventInfo); err != nil {
+				log.Printf("Failed to parse JSON: %e\n", err)
+				continue
+			}
+
+			log.Printf("Got an event of type: '%s'\n", eventInfo.Type)
+			switch eventInfo.Type {
+			case "assignee-changed":
+			case "change-abandoned":
+			case "change-deleted":
+			case "change-merged":
+				// TODO(austin): Trigger a master build?
+			case "change-restored":
+			case "comment-added":
+				if matched, _ := regexp.MatchString(`(?m)^retest$`, eventInfo.Comment); !matched {
+					continue
+				}
+
+				state.handleEvent(eventInfo, client)
+			case "dropped-output":
+			case "hashtags-changed":
+			case "project-created":
+			case "patchset-created":
+				state.handleEvent(eventInfo, client)
+			case "ref-updated":
+			case "reviewer-added":
+			case "reviewer-deleted":
+			case "topic-changed":
+			case "wip-state-changed":
+			case "private-state-changed":
+			case "vote-deleted":
+			default:
+				log.Println("Unknown case")
+			}
+		}
+		cmd.Wait()
+	}
+}