diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | tools/gerrit-hook/buildkite.go | 81 | ||||
-rw-r--r-- | tools/gerrit-hook/default.nix | 16 | ||||
-rw-r--r-- | tools/gerrit-hook/gerrit.go | 144 | ||||
-rw-r--r-- | tools/gerrit-hook/go.mod | 3 | ||||
-rw-r--r-- | tools/gerrit-hook/main.go | 64 |
6 files changed, 309 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore index 9eb84ac..cfbff8c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /users/fcuny/blog/result /result /users/fcuny/notes/result +/tools/gerrit-hook/gerrit-hook diff --git a/tools/gerrit-hook/buildkite.go b/tools/gerrit-hook/buildkite.go new file mode 100644 index 0000000..d8723b6 --- /dev/null +++ b/tools/gerrit-hook/buildkite.go @@ -0,0 +1,81 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "log/syslog" + "net/http" + "time" +) + +// https://buildkite.com/docs/apis/rest-api/builds#create-a-Build +type Build struct { + Commit string `json:"commit"` + Branch string `json:"branch"` +} + +type buildResponse struct { + WebUrl string `json:"web_url"` +} + +func triggerBuild(cfg *config, log *syslog.Writer, trigger *buildTrigger) error { + b := Build{ + Commit: trigger.commit, + Branch: trigger.ref, + } + + body, _ := json.Marshal(b) + reader := ioutil.NopCloser(bytes.NewReader(body)) + + bkUrl := fmt.Sprintf("https://api.buildkite.com/v2/organizations/%s/pipelines/%s/builds", cfg.BuildKiteOrganization, trigger.project) + req, err := http.NewRequest("POST", bkUrl, reader) + if err != nil { + return fmt.Errorf("failed to create an HTTP request: %v", err) + } + + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", cfg.BuildKiteToken)) + req.Header.Add("Content-Type", "application/json") + + // Let's budget this to 10 seconds maximum, this should be more + // than enough, as we're only triggering the build, we're not + // waiting on the status of the build + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + resp, err := http.DefaultClient.Do(req.WithContext(ctx)) + if err != nil { + return fmt.Errorf("failed to send buildKite request: %v", err) + } + defer resp.Body.Close() + + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to parse buildKite response: %v", err) + } + + if resp.StatusCode != http.StatusCreated { + return fmt.Errorf("received a non-success response from buildKite: %s (%v)", respBody, resp.Status) + } + + var buildResp buildResponse + err = json.Unmarshal(respBody, &buildResp) + if err != nil { + return fmt.Errorf("failed to unmarshal build response: %v", err) + } + + // Report the status back to the Gerrit CL so that users can click + // through to the running build. + msg := fmt.Sprintf("started build for patchset #%s on: %s", trigger.patchset, buildResp.WebUrl) + review := reviewInput{ + Message: msg, + OmitDuplicateComments: true, + Tag: "autogenerated:buildkite~trigger", + IgnoreDefaultAttentionSetRules: true, + Notify: "NONE", + } + updateGerrit(cfg, review, trigger.changeId, trigger.patchset) + return nil +} diff --git a/tools/gerrit-hook/default.nix b/tools/gerrit-hook/default.nix new file mode 100644 index 0000000..c0667ae --- /dev/null +++ b/tools/gerrit-hook/default.nix @@ -0,0 +1,16 @@ +{ pkgs, ... }: + +pkgs.buildGoModule rec { + name = "gerrit-hook"; + src = ./.; + subPackages = [ "." ]; + vendorSha256 = null; + + meta = with pkgs.lib; { + description = "hooks for gerrit"; + homepage = "https://golang.fcuny.net"; + license = licenses.mit; + platforms = platforms.linux; + maintainers = [ ]; + }; +} diff --git a/tools/gerrit-hook/gerrit.go b/tools/gerrit-hook/gerrit.go new file mode 100644 index 0000000..6a23527 --- /dev/null +++ b/tools/gerrit-hook/gerrit.go @@ -0,0 +1,144 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "log" + "log/syslog" + "net/http" + "os" + "regexp" + "strconv" + "time" +) + +// Regular expression to extract change ID out of a URL +var changeIdRegexp = regexp.MustCompile(`^.*/(\d+)$`) + +func gerritHookMain(cfg *config, log *syslog.Writer, trigger *buildTrigger) { + if trigger == nil { + os.Exit(0) + } + + err := triggerBuild(cfg, log, trigger) + if err != nil { + log.Err(fmt.Sprintf("failed to trigger Buildkite build: %s", err)) + os.Exit(1) + } +} + +type reviewInput struct { + Message string `json:"message"` + Labels map[string]int `json:"labels,omitempty"` + OmitDuplicateComments bool `json:"omit_duplicate_comments"` + IgnoreDefaultAttentionSetRules bool `json:"ignore_default_attention_set_rules"` + Tag string `json:"tag"` + Notify string `json:"notify,omitempty"` +} + +type buildTrigger struct { + project string + change string + kind string + changeUrl string + changeOwner string + changeOwnerUserName string + branch string + topic string + uploader string + uploaderUserName string + commit string + patchset string + changeId string + ref string +} + +// https://gerrit.googlesource.com/plugins/hooks/+/HEAD/src/main/resources/Documentation/hooks.md#patchset_created +func triggerForPatchsetCreated() (*buildTrigger, error) { + var trigger buildTrigger + + flag.StringVar(&trigger.project, "project", "", "Gerrit project") + flag.StringVar(&trigger.change, "change", "", "Gerrit change") + flag.StringVar(&trigger.kind, "kind", "", "Gerrit kind") + flag.StringVar(&trigger.changeUrl, "change-url", "", "Gerrit URL for the change") + flag.StringVar(&trigger.changeOwner, "change-owner", "", "Gerrit owner") + flag.StringVar(&trigger.changeOwnerUserName, "change-owner-username", "", "Gerrit username") + flag.StringVar(&trigger.branch, "branch", "", "name of the branch") + flag.StringVar(&trigger.topic, "topic", "", "name of the topic") + flag.StringVar(&trigger.uploader, "uploader", "", "name ofthe uploader") + flag.StringVar(&trigger.uploaderUserName, "uploader-username", "", "") + flag.StringVar(&trigger.commit, "commit", "", "") + flag.StringVar(&trigger.patchset, "patchset", "", "") + + flag.Parse() + + // for now we only care about the project named `world' and the + // branch named 'main' + if trigger.project != "world" || trigger.branch != "main" { + return nil, nil + } + + // We only care about patchset that are actually modifying the + // code. See + // https://gerrit-review.googlesource.com/Documentation/config-labels.html + if trigger.kind == "NO_CODE_CHANGE" || trigger.kind == "NO_CHANGE" { + return nil, nil + } + + // extract the changeId from the URL + matches := changeIdRegexp.FindStringSubmatch(trigger.changeUrl) + trigger.changeId = matches[1] + + // build the ref + changeId, _ := strconv.Atoi(trigger.changeId) + trigger.ref = fmt.Sprintf( + "refs/changes/%02d/%s/%s", + changeId%100, trigger.changeId, trigger.patchset, + ) + + return &trigger, nil +} + +// after triggering a build with buildKite, we update gerrit to add a +// comment that links to the build. +func updateGerrit(cfg *config, review reviewInput, changeId string, patchSet string) { + body, err := json.Marshal(review) + if err != nil { + log.Fatal(fmt.Sprintf("failed to marshal gerrit update: %v", err)) + os.Exit(1) + } + + reader := ioutil.NopCloser(bytes.NewReader(body)) + url := fmt.Sprintf("%s/a/changes/%s/revisions/%s/review", cfg.GerritUrl, changeId, patchSet) + req, err := http.NewRequest("POST", url, reader) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create an HTTP request: %v", err) + os.Exit(1) + } + + req.SetBasicAuth(cfg.GerritUser, cfg.GerritPassword) + req.Header.Add("Content-Type", "application/json") + + // Let's budget this to 10 seconds maximum, this should be more + // than enough to add a comment to gerrit. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + resp, err := http.DefaultClient.Do(req.WithContext(ctx)) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to send gerrit request: %v", err) + os.Exit(1) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := ioutil.ReadAll(resp.Body) + fmt.Fprintf(os.Stderr, "failed to update gerrit: %s: %s ", respBody, resp.Status) + } else { + fmt.Printf("added link to CI build to %s", patchSet) + } +} diff --git a/tools/gerrit-hook/go.mod b/tools/gerrit-hook/go.mod new file mode 100644 index 0000000..98968da --- /dev/null +++ b/tools/gerrit-hook/go.mod @@ -0,0 +1,3 @@ +module golang.fcuny.net/gerrit-hook + +go 1.16 diff --git a/tools/gerrit-hook/main.go b/tools/gerrit-hook/main.go new file mode 100644 index 0000000..f8ed687 --- /dev/null +++ b/tools/gerrit-hook/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log/syslog" + "os" + "path" +) + +// config represents the configuration for the gerrit hook +type config struct { + GerritUrl string `json:"gerritUrl"` + GerritUser string `json:"gerritUser"` + GerritPassword string `json:"gerritPassword"` + BuildKiteToken string `json:"buildKiteToken"` + BuildKiteOrganization string `json:"buildKiteOrganization"` +} + +func loadConfig() (*config, error) { + configPath := "/var/run/agenix/gerrit/hooks" + + configJson, err := ioutil.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read configuration file %s: %v", configPath, err) + } + + var cfg config + err = json.Unmarshal(configJson, &cfg) + if err != nil { + return nil, fmt.Errorf("failed to unmarshall configuration: %v", err) + } + + return &cfg, nil +} + +func main() { + log, err := syslog.New(syslog.LOG_INFO|syslog.LOG_USER, "gerrit-hook") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to open syslog: %s\n", err) + } + + log.Info(fmt.Sprintf("`gerrit-hook' called with arguments: %v\n", os.Args)) + + cmd := path.Base(os.Args[0]) + + cfg, err := loadConfig() + if err != nil { + os.Exit(1) + } + + if cmd == "patchset-created" { + trigger, err := triggerForPatchsetCreated() + if err != nil { + log.Crit(fmt.Sprintf("failed to create a trigger: %s", err)) + os.Exit(1) + } + gerritHookMain(cfg, log, trigger) + } else { + log.Info(fmt.Sprintf("`%s' is not a supported command", cmd)) + os.Exit(1) + } +} |