blob: 0dc9b132f47d28bf504140ffa201da7782bc6e4e [file] [log] [blame]
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"
"time"
)
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"`
Reviewer *User `json:"reviewer"`
Adder *User `json:"adder"`
Remover *User `json:"remover"`
Submitter User `json:"submitter,omitempty"`
NewRev string `json:"newRev,omitempty"`
Ref string `json:"ref,omitempty"`
TargetNode string `json:"targetNode,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)
for {
// 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),
// Don't email out the initial link to lower the spam.
"-n",
"NONE",
"--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\"' -n NONE --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)
}
return
} else {
s.mu.Unlock()
log.Printf("Failed to trigger build: %s", err)
log.Printf("Trying again in 30 seconds")
time.Sleep(30 * time.Second)
}
}
}
type BuildkiteChange struct {
ID string `json:"id,omitempty"`
Number int `json:"number,omitempty"`
URL string `json:"url,omitempty"`
}
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"`
RebuiltFrom *BuildkiteChange `json:"rebuilt_from,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.running" {
if webhook.Build.RebuiltFrom != nil {
s.mu.Lock()
if c, ok := s.Commits[webhook.Build.RebuiltFrom.ID]; ok {
log.Printf("Detected a rebuild of %s for build %s", webhook.Build.RebuiltFrom.ID, webhook.Build.ID)
s.Commits[webhook.Build.ID] = c
// And now remove the vote since the rebuild started.
cmd := exec.Command("ssh",
"-p",
"29418",
"-i",
s.Key,
s.User+"@software.frc971.org",
"gerrit",
"review",
"-m",
fmt.Sprintf("\"Build Started: %s\"", webhook.Build.WebURL),
// Don't email out the initial link to lower the spam.
"-n",
"NONE",
"--verified",
"0",
fmt.Sprintf("%d,%d", c.ChangeNumber, c.Patchset))
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...",
s.Key, s.User,
webhook.Build.WebURL, c.ChangeNumber, c.Patchset)
if err := cmd.Run(); err != nil {
log.Printf("Command failed with error: %v", err)
}
}
s.mu.Unlock()
}
} else if webhook.Event == "build.finished" {
var commit *Commit
{
s.mu.Lock()
if c, ok := s.Commits[webhook.Build.ID]; ok {
commit = &c
// While we *should* delete this now from the map, that will prevent rebuilds from being mapped correctly.
// 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.
}
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":
if eventInfo.RefName == "refs/heads/master" && eventInfo.Change.Status == "MERGED" {
if build, _, err := client.Builds.Create(
"spartan-robotics", "971-robot-code", &buildkite.CreateBuild{
Commit: eventInfo.NewRev,
Branch: "master",
Author: buildkite.Author{
Name: eventInfo.Submitter.Name,
Email: eventInfo.Submitter.Email,
},
}); err == nil {
log.Printf("Scheduled master build %s\n", *build.ID)
} else {
log.Printf("Failed to schedule master build %v", err)
// TODO(austin): Notify failure to build. Stephan should be able to pick this up in nagios.
}
}
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()
}
}