about summary refs log tree commit diff
path: root/tools/gerrit-hook
diff options
context:
space:
mode:
authorFranck Cuny <franck@fcuny.net>2022-05-30 13:32:43 -0700
committerFranck Cuny <franck@fcuny.net>2022-06-04 15:25:46 -0700
commit83a38a6da9ef99bc6596f6cfb53395a89f0165c7 (patch)
treec808d9dd826757c440f1632fd758503f563c0403 /tools/gerrit-hook
parentmeta: remove pre-commit checks (diff)
downloadworld-83a38a6da9ef99bc6596f6cfb53395a89f0165c7.tar.gz
feat(gerrit-hook): a small tool to act as a dispatcher for gerrit
When a patchset is created, gerrit will call this tool with a number of
arguments.

This hook triggers a build with buildKite for the given patchset, and
add a comment to gerrit with a link to the build.

We do not wait for the build to be successful to update gerrit. This
will be done by another hook which the buildKite agents will call once
they are done with the build.

Change-Id: Iaa221765f3c52875ec37c5d282ba0557291eb5a4
Reviewed-on: https://cl.fcuny.net/c/world/+/171
Reviewed-by: Franck Cuny <franck@fcuny.net>
Diffstat (limited to 'tools/gerrit-hook')
-rw-r--r--tools/gerrit-hook/buildkite.go81
-rw-r--r--tools/gerrit-hook/default.nix16
-rw-r--r--tools/gerrit-hook/gerrit.go144
-rw-r--r--tools/gerrit-hook/go.mod3
-rw-r--r--tools/gerrit-hook/main.go64
5 files changed, 308 insertions, 0 deletions
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)
+	}
+}