diff --git a/.dockerignore b/.dockerignore index 7001b86..cb86c29 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,5 +2,5 @@ !go.mod !go.sum !main.go -!pkg +!internal !cmd \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2ea6410..fac6540 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.15-alpine3.12 AS build +FROM golang:1.16-alpine3.12 AS build WORKDIR /app COPY ["go.mod", "go.sum", "./"] diff --git a/cmd/serve.go b/cmd/serve.go index 81ce494..ce206c5 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -16,10 +16,10 @@ limitations under the License. package cmd import ( - "github.com/labstack/gommon/log" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/uniwise/go-ship-it/pkg" + "github.com/uniwise/go-ship-it/internal/rest" ) // serveCmd represents the serve command @@ -28,32 +28,34 @@ var serveCmd = &cobra.Command{ Short: "Start the server", Long: `Start the go-ship-it server`, Run: func(cmd *cobra.Command, args []string) { - lvl := log.INFO - switch viper.GetString("server.loglevel") { - case "debug": - lvl = log.DEBUG - case "warn": - lvl = log.WARN - case "error": - lvl = log.ERROR - case "off": - lvl = log.OFF - case "info": + logger := logrus.New() + + lvl, err := logrus.ParseLevel(viper.GetString("server.loglevel")) + if err != nil { + logger.Fatal(err) + } + logger.SetLevel(lvl) + + switch viper.GetString("server.logformat") { + case "text": + logger.SetFormatter(&logrus.TextFormatter{}) + case "json": + logger.SetFormatter(&logrus.JSONFormatter{}) default: - lvl = log.INFO + logger.Warnf("Could not understand log format '%s'. Defaulting to text", viper.GetString("server.logformat")) + logger.SetFormatter(&logrus.TextFormatter{}) } - s := &pkg.ServerImpl{ + s := &rest.ServerImpl{ AppID: viper.GetInt64("github.appid"), PrivateKeyFile: viper.GetString("github.keyfile"), GithubSecret: []byte(viper.GetString("github.secret")), Port: viper.GetInt32("server.port"), - LogLevel: lvl, + Logger: logrus.NewEntry(logger), } - err := s.Serve() - if err != nil { - log.Fatal(err) + if err := s.Serve(); err != nil { + logger.Fatal(err) } }, } diff --git a/go.mod b/go.mod index 13d36b2..7cfe1ea 100644 --- a/go.mod +++ b/go.mod @@ -5,15 +5,19 @@ go 1.15 require ( github.com/Masterminds/semver/v3 v3.1.0 github.com/bradleyfalzon/ghinstallation v1.1.1 - github.com/google/go-github/v32 v32.1.0 + github.com/go-playground/universal-translator v0.17.0 // indirect + github.com/google/go-github/v33 v33.0.0 github.com/joho/godotenv v1.3.0 github.com/labstack/echo/v4 v4.1.17 github.com/labstack/gommon v0.3.0 + github.com/leodido/go-urn v1.2.1 // indirect github.com/mitchellh/go-homedir v1.1.0 github.com/pkg/errors v0.9.1 + github.com/sirupsen/logrus v1.2.0 github.com/spf13/cobra v1.1.1 github.com/spf13/viper v1.7.1 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect - gopkg.in/gookit/color.v1 v1.1.6 + gopkg.in/go-playground/assert.v1 v1.2.1 // indirect + gopkg.in/go-playground/validator.v9 v9.31.0 gopkg.in/yaml.v2 v2.2.8 ) diff --git a/go.sum b/go.sum index 8e17f2e..9f83134 100644 --- a/go.sum +++ b/go.sum @@ -42,7 +42,6 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= @@ -51,6 +50,10 @@ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= @@ -70,8 +73,8 @@ github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-github/v29 v29.0.2 h1:opYN6Wc7DOz7Ku3Oh4l7prmkOMwEcQxpFtxdU8N8Pts= github.com/google/go-github/v29 v29.0.2/go.mod h1:CHKiKKPHJ0REzfwc14QMklvtHwCveD0PxlMjLlzAM5E= -github.com/google/go-github/v32 v32.1.0 h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II= -github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI= +github.com/google/go-github/v33 v33.0.0 h1:qAf9yP0qc54ufQxzwv+u9H0tiVOnPJxo0lI/JXqw3ZM= +github.com/google/go-github/v33 v33.0.0/go.mod h1:GMdDnVZY/2TsWgp/lkYnpSAh6TrzhANBBwm6k6TTEXg= github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= @@ -119,6 +122,7 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= @@ -130,6 +134,8 @@ github.com/labstack/echo/v4 v4.1.17 h1:PQIBaRplyRy3OjwILGkPg89JRtH2x5bssi59G2EL3 github.com/labstack/echo/v4 v4.1.17/go.mod h1:Tn2yRQL/UclUalpb5rPdXDevbkJ+lp/2svdyFBg6CHQ= github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0= github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= @@ -182,6 +188,7 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= @@ -207,8 +214,9 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= @@ -266,7 +274,6 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -347,18 +354,21 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/gookit/color.v1 v1.1.6 h1:5fB10p6AUFjhd2ayq9JgmJWr9WlTrguFdw3qlYtKNHk= -gopkg.in/gookit/color.v1 v1.1.6/go.mod h1:IcEkFGaveVShJ+j8ew+jwe9epHyGpJ9IrptHmW3laVY= +gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v9 v9.31.0 h1:bmXmP2RSNtFES+bn4uYuHT7iJFJv7Vj+an+ZQdDaD1M= +gopkg.in/go-playground/validator.v9 v9.31.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/rest/handler.go b/internal/rest/handler.go new file mode 100644 index 0000000..73bf76b --- /dev/null +++ b/internal/rest/handler.go @@ -0,0 +1,82 @@ +package rest + +import ( + "context" + "net/http" + "time" + + "github.com/bradleyfalzon/ghinstallation" + "github.com/google/go-github/v33/github" + "github.com/labstack/echo/v4" + "github.com/sirupsen/logrus" + "github.com/uniwise/go-ship-it/internal/scm" +) + +type WebhookHandler struct { + Secret []byte + AppsTransport *ghinstallation.AppsTransport + Logger *logrus.Entry +} + +func NewHandler(atr *ghinstallation.AppsTransport, secret []byte, logger *logrus.Entry) *WebhookHandler { + return &WebhookHandler{ + AppsTransport: atr, + Secret: secret, + Logger: logger, + } +} + +type HandledGithubEvent interface { + GetInstallation() *github.Installation +} + +func (h *WebhookHandler) initReleaser(c echo.Context, ev HandledGithubEvent, repo scm.Repo, ref string, entry *logrus.Entry) (*scm.Releaser, error) { + k := ghinstallation.NewFromAppsTransport(h.AppsTransport, ev.GetInstallation().GetID()) + client := scm.NewGithubClient(&http.Client{Transport: k, Timeout: time.Minute}, repo) + + return scm.NewReleaser(c.Request().Context(), client, ref, entry.WithField("repo", repo.GetFullName())) +} + +func (h *WebhookHandler) HandleGithub(c echo.Context) error { + id := c.Response().Header().Get(echo.HeaderXRequestID) + entry := h.Logger.WithField("id", id) + + payload, err := github.ValidatePayload(c.Request(), h.Secret) + if err != nil { + return echo.ErrBadRequest.SetInternal(err) + } + + event, err := github.ParseWebHook(github.WebHookType(c.Request()), payload) + if err != nil { + return echo.ErrBadRequest.SetInternal(err) + } + + switch event := event.(type) { + case *github.PushEvent: + r, err := h.initReleaser(c, event, event.GetRepo(), event.GetHead(), entry) + if err != nil { + entry.WithError(err).Error("Could not initialize releaser") + + return err + } + go r.HandlePush(context.Background(), event) + + return c.String(http.StatusAccepted, "Handling push event") + case *github.ReleaseEvent: + r, err := h.initReleaser(c, event, event.GetRepo(), event.GetRelease().GetTagName(), entry) + if err != nil { + entry.WithError(err).Error("Could not initialize releaser") + + return err + } + go r.HandleRelease(context.Background(), event) + + return c.String(http.StatusAccepted, "Handling release event") + case *github.PingEvent: + return c.String(http.StatusOK, "pong") + default: + entry.Warn("Unexpected event") + + return c.String(http.StatusNotAcceptable, "Unexpected event") + } +} diff --git a/pkg/server.go b/internal/rest/server.go similarity index 65% rename from pkg/server.go rename to internal/rest/server.go index cf67e73..605b8af 100644 --- a/pkg/server.go +++ b/internal/rest/server.go @@ -1,17 +1,15 @@ -package pkg +package rest import ( "fmt" "net/http" "github.com/bradleyfalzon/ghinstallation" - "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" - "github.com/labstack/gommon/log" "github.com/labstack/gommon/random" "github.com/pkg/errors" - "gopkg.in/gookit/color.v1" + "github.com/sirupsen/logrus" ) type Server interface { @@ -23,7 +21,7 @@ type ServerImpl struct { PrivateKeyFile string GithubSecret []byte Port int32 - LogLevel log.Lvl + Logger *logrus.Entry } func (s *ServerImpl) Serve() error { @@ -32,9 +30,10 @@ func (s *ServerImpl) Serve() error { return errors.Wrap(err, "Error creating github app client") } - handler := NewHandler(atr, s.GithubSecret) + handler := NewHandler(atr, s.GithubSecret, s.Logger.WithField("subsystem", "handler")) e := echo.New() + e.Use(middleware.Recover()) e.Use(middleware.RequestIDWithConfig(middleware.RequestIDConfig{ Skipper: middleware.DefaultSkipper, @@ -43,20 +42,10 @@ func (s *ServerImpl) Serve() error { }, })) - e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - id := c.Response().Header().Get(echo.HeaderXRequestID) - logger := log.New(id) - logger.SetLevel(s.LogLevel) - logger.SetHeader(fmt.Sprintf("${level}\t%s\t${prefix}\t[${short_file}:${line}]\t", color.HEX(id[0:6]).Sprint(id))) - c.SetLogger(logger) - return next(c) - } - }) - e.POST("/github", handler.HandleGithub) e.GET("/", func(c echo.Context) error { return c.String(http.StatusOK, "Ready to receive") }) + return e.Start(fmt.Sprintf(":%d", s.Port)) } diff --git a/internal/scm/github.go b/internal/scm/github.go new file mode 100644 index 0000000..15dcca2 --- /dev/null +++ b/internal/scm/github.go @@ -0,0 +1,224 @@ +package scm + +import ( + "context" + "fmt" + "io" + "net/http" + + semver "github.com/Masterminds/semver/v3" + "github.com/google/go-github/v33/github" + "github.com/pkg/errors" +) + +type GithubClient interface { + CreateMilestone(ctx context.Context, title string) (*github.Milestone, error) + AddPRtoMilestone(ctx context.Context, pull *github.PullRequest, milestone *github.Milestone) error + GetReleaseByTag(ctx context.Context, tag string) (*github.RepositoryRelease, error) + DeleteRelease(ctx context.Context, r *github.RepositoryRelease) error + DeleteTag(ctx context.Context, tag string) error + EditRelease(ctx context.Context, id int64, release *github.RepositoryRelease) (*github.RepositoryRelease, error) + GetRef(ctx context.Context, r string) (*github.Reference, error) + CreateRef(ctx context.Context, r *github.Reference) error + CreateRelease(ctx context.Context, r *github.RepositoryRelease) error + GetRefs(ctx context.Context, pattern string) ([]*github.Reference, error) + GetCommitRange(ctx context.Context, base, head string) (*github.CommitsComparison, error) + GetPullsInCommitRange(ctx context.Context, commits []*github.RepositoryCommit) ([]*github.PullRequest, error) + GetLatestTag(ctx context.Context) (tag string, ver *semver.Version, err error) + GetFile(ctx context.Context, ref, file string) (io.ReadCloser, error) + GetRepo() Repo +} + +type Repo interface { + GetFullName() string + GetOwner() *github.User + GetName() string + GetDefaultBranch() string +} + +type GithubClientImpl struct { + client *github.Client + repo Repo +} + +func NewGithubClient(tc *http.Client, repo Repo) *GithubClientImpl { + cl := github.NewClient(tc) + + return &GithubClientImpl{ + client: cl, + repo: repo, + } +} + +func (c *GithubClientImpl) CreateMilestone(ctx context.Context, title string) (*github.Milestone, error) { + milestone, _, err := c.client.Issues.CreateMilestone(ctx, c.repo.GetOwner().GetLogin(), c.repo.GetName(), &github.Milestone{ + Title: github.String(title), + State: github.String("closed"), + }) + if err != nil { + return nil, errors.Wrapf(err, "Failed to create milestone '%s'", title) + } + return milestone, nil +} + +func (c *GithubClientImpl) AddPRtoMilestone(ctx context.Context, pull *github.PullRequest, milestone *github.Milestone) error { + _, _, err := c.client.Issues.Edit(ctx, c.repo.GetOwner().GetLogin(), c.repo.GetName(), pull.GetNumber(), &github.IssueRequest{ + Milestone: milestone.Number, + }) + if err != nil { + return errors.Wrapf(err, "Failed to add pull request '%d' to milestone '%d'", pull.GetNumber(), milestone.GetNumber()) + } + return nil +} + +func (c *GithubClientImpl) GetReleaseByTag(ctx context.Context, tag string) (*github.RepositoryRelease, error) { + release, _, err := c.client.Repositories.GetReleaseByTag(ctx, c.repo.GetOwner().GetLogin(), c.repo.GetName(), tag) + if err != nil { + return nil, errors.Wrapf(err, "Failed to find release from tag '%s'", tag) + } + return release, nil +} + +func (c *GithubClientImpl) DeleteRelease(ctx context.Context, release *github.RepositoryRelease) error { + _, err := c.client.Repositories.DeleteRelease(ctx, c.repo.GetOwner().GetLogin(), c.repo.GetName(), release.GetID()) + if err != nil { + return errors.Wrapf(err, "Failed to delete release '%d'", release.GetID()) + } + return nil +} + +func (c *GithubClientImpl) DeleteTag(ctx context.Context, tag string) error { + _, err := c.client.Git.DeleteRef(ctx, c.repo.GetOwner().GetLogin(), c.repo.GetName(), fmt.Sprintf("refs/tags/%s", tag)) + if err != nil { + return errors.Wrapf(err, "Failed to delete tag '%s'", tag) + } + return nil +} + +func (c *GithubClientImpl) EditRelease(ctx context.Context, id int64, release *github.RepositoryRelease) (*github.RepositoryRelease, error) { + r, _, err := c.client.Repositories.EditRelease(ctx, c.repo.GetOwner().GetLogin(), c.repo.GetName(), id, release) + return r, err +} + +func (c *GithubClientImpl) GetRef(ctx context.Context, r string) (*github.Reference, error) { + ref, _, err := c.client.Git.GetRef(ctx, c.repo.GetOwner().GetLogin(), c.repo.GetName(), r) + return ref, err +} + +func (c *GithubClientImpl) CreateRef(ctx context.Context, r *github.Reference) error { + _, _, err := c.client.Git.CreateRef(ctx, c.repo.GetOwner().GetLogin(), c.repo.GetName(), r) + return err +} + +func (c *GithubClientImpl) CreateRelease(ctx context.Context, r *github.RepositoryRelease) error { + _, _, err := c.client.Repositories.CreateRelease(ctx, c.repo.GetOwner().GetLogin(), c.repo.GetName(), r) + return err +} + +func (c *GithubClientImpl) GetRefs(ctx context.Context, pattern string) ([]*github.Reference, error) { + return c.paginateRefs(ctx, &github.ReferenceListOptions{Ref: pattern, ListOptions: github.ListOptions{PerPage: 25}}) +} + +func (c *GithubClientImpl) GetCommitRange(ctx context.Context, base, head string) (*github.CommitsComparison, error) { + comparison, _, err := c.client.Repositories.CompareCommits(ctx, c.repo.GetOwner().GetLogin(), c.repo.GetName(), base, head) + if err != nil { + return nil, err + } + return comparison, nil +} + +func (c *GithubClientImpl) GetPullsInCommitRange(ctx context.Context, commits []*github.RepositoryCommit) ([]*github.PullRequest, error) { + max := 100 + if len(commits) < max { + max = len(commits) + } + unique := map[int]interface{}{} + pulls := []*github.PullRequest{} + for _, commit := range commits[:max] { + prs, err := c.paginatePullsWithCommit(ctx, commit.GetSHA(), &github.PullRequestListOptions{}) + if err != nil { + return nil, errors.Wrap(err, "Failed to paginate pull requests") + } + for _, p := range prs { + if _, ok := unique[p.GetNumber()]; ok { + continue + } + unique[p.GetNumber()] = struct{}{} + pulls = append(pulls, p) + } + } + return pulls, nil +} + +func (c *GithubClientImpl) GetFile(ctx context.Context, ref, file string) (io.ReadCloser, error) { + r, _, err := c.client.Repositories.DownloadContents(ctx, c.repo.GetOwner().GetLogin(), c.repo.GetName(), file, &github.RepositoryContentGetOptions{ + Ref: ref, + }) + return r, err +} + +func (c *GithubClientImpl) GetRepo() Repo { + return c.repo +} + +func (c *GithubClientImpl) GetLatestTag(ctx context.Context) (tag string, ver *semver.Version, err error) { + release, _, err := c.client.Repositories.GetLatestRelease(ctx, c.repo.GetOwner().GetLogin(), c.repo.GetName()) + if err != nil { + return "", nil, errors.Wrap(err, "Failed to get latest release") + } + version, err := semver.NewVersion(release.GetTagName()) + if err != nil { + return "", nil, errors.Wrap(err, "Failed to parse tag as semver") + } + return release.GetTagName(), version, nil +} + +func (c *GithubClientImpl) paginatePullsWithCommit(ctx context.Context, sha string, opts *github.PullRequestListOptions) ([]*github.PullRequest, error) { + page := 0 + pulls := []*github.PullRequest{} + for { + prs, out, err := c.client.PullRequests.ListPullRequestsWithCommit(ctx, c.repo.GetOwner().GetLogin(), c.repo.GetName(), sha, &github.PullRequestListOptions{ + State: opts.State, + Head: opts.Head, + Base: opts.Base, + Sort: opts.Sort, + Direction: opts.Direction, + ListOptions: github.ListOptions{ + Page: page, + PerPage: opts.ListOptions.PerPage, + }, + }) + if err != nil { + return nil, errors.Wrap(err, "Failed to list pull requests") + } + pulls = append(pulls, prs...) + if out.NextPage == 0 { + break + } + page = out.NextPage + } + return pulls, nil +} + +func (c *GithubClientImpl) paginateRefs(ctx context.Context, opts *github.ReferenceListOptions) ([]*github.Reference, error) { + page := 0 + references := []*github.Reference{} + for { + refs, out, err := c.client.Git.ListMatchingRefs(ctx, c.repo.GetOwner().GetLogin(), c.repo.GetName(), &github.ReferenceListOptions{ + Ref: opts.Ref, + ListOptions: github.ListOptions{ + Page: page, + PerPage: opts.PerPage, + }, + }) + if err != nil { + return nil, errors.Wrap(err, "Failed to list references") + } + references = append(references, refs...) + if out.NextPage == 0 { + break + } + page = out.NextPage + } + return references, nil +} diff --git a/internal/scm/releaser.go b/internal/scm/releaser.go new file mode 100644 index 0000000..0c1e0e0 --- /dev/null +++ b/internal/scm/releaser.go @@ -0,0 +1,385 @@ +package scm + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/google/go-github/v33/github" + "github.com/pkg/errors" + "github.com/sirupsen/logrus" + validator "gopkg.in/go-playground/validator.v9" + "gopkg.in/yaml.v2" +) + +type Releaser struct { + client GithubClient + config *Config + log *logrus.Entry +} + +var ( + configValidator = validator.New() + candidateRx = regexp.MustCompile("^rc.(?P[0-9]+)$") + changelogRx = regexp.MustCompile("```release-note([\\s\\S]*?)```") +) + +type LabelsConfig struct { + Major string `yaml:"major,omitempty"` + Minor string `yaml:"minor,omitempty"` +} + +type StrategyConf struct { + Type string `yaml:"type,omitempty" validate:"oneof=pre-release full-release"` +} + +type Config struct { + TargetBranch string `yaml:"targetBranch,omitempty" validate:"required"` + Labels LabelsConfig `yaml:"labels,omitempty"` + Strategy StrategyConf `yaml:"strategy,omitempty"` +} + +func getConfig(ctx context.Context, c GithubClient, ref string) (*Config, error) { + config := &Config{ + TargetBranch: c.GetRepo().GetDefaultBranch(), + Labels: LabelsConfig{ + Major: "major", + Minor: "minor", + }, + Strategy: StrategyConf{ + Type: "pre-release", + }, + } + reader, err := c.GetFile(ctx, ref, ".ship-it") + if err != nil { + return config, nil + } + defer reader.Close() + decoder := yaml.NewDecoder(reader) + if err := decoder.Decode(config); err != nil { + return nil, errors.Wrap(err, "Failed to decode config file") + } + if err := configValidator.Struct(config); err != nil { + return nil, errors.Wrap(err, "Failed to validate configuration") + } + return config, nil +} + +func NewReleaser(ctx context.Context, client GithubClient, ref string, log *logrus.Entry) (*Releaser, error) { + config, err := getConfig(ctx, client, ref) + if err != nil { + return nil, errors.Wrap(err, "Failed to configure releaser") + } + return &Releaser{ + client: client, + config: config, + log: log, + }, nil +} + +func (r *Releaser) HandlePush(ctx context.Context, e *github.PushEvent) { + if !r.Match(e.GetRef()) { + return + } + + r.log.Infof("%s pushed. Releasing...", e.GetRef()) + t, v, err := r.client.GetLatestTag(ctx) + if err != nil { + r.log.WithError(err).Error("Failed to get latest release") + return + } + + r.log.Debugf("Finding commits in range %s..%.7s", t, e.GetAfter()) + comparison, err := r.client.GetCommitRange(ctx, t, e.GetAfter()) + if err != nil { + r.log.WithError(err).Error("Failed to get commit range") + return + } + + r.log.Debugf("Finding PRs in %d commits", len(comparison.Commits)) + pulls, err := r.client.GetPullsInCommitRange(ctx, comparison.Commits) + if err != nil { + r.log.WithError(err).Error("Failed to get pull requests in commit range") + return + } + + r.log.Debugf("Finding next version based on %d PRs", len(pulls)) + next, err := r.Increment(ctx, v, pulls) + if err != nil { + r.log.WithError(err).Error("Failed to increment version") + return + } + tagname, name := fmt.Sprintf("v%s", next.String()), next.String() + + r.log.Debugf("Collecting changelog from %d PRs", len(pulls)) + changelog, err := r.CollectChangelog(pulls) + if err != nil { + r.log.WithError(err).Error("Failed to collect changelog") + return + } + + r.log.Debugf("Creating tag '%s' at '%.7s'", tagname, e.GetAfter()) + err = r.client.CreateRef(ctx, &github.Reference{ + Ref: github.String(fmt.Sprintf("refs/tags/%s", tagname)), + Object: &github.GitObject{ + SHA: github.String(e.GetAfter()), + }, + }) + if err != nil { + r.log.WithError(err).Error("Failed to create reference") + return + } + + r.log.WithFields(logrus.Fields{ + "Name": name, + "TagName": tagname, + "Commitish": strings.TrimPrefix(e.GetRef(), "refs/heads/"), + "Prerelease": r.config.Strategy.Type == "pre-release", + }).Debugf("Creating release") + err = r.client.CreateRelease(ctx, &github.RepositoryRelease{ + TagName: github.String(tagname), + Name: github.String(name), + TargetCommitish: github.String(strings.TrimPrefix(e.GetRef(), "refs/heads/")), + Prerelease: github.Bool(r.config.Strategy.Type == "pre-release"), + Body: github.String(changelog), + }) + if err != nil { + r.log.WithError(err).Error("Failed to create release") + return + } + r.log.Infof("Release %s created", tagname) +} + +func (r *Releaser) HandleRelease(ctx context.Context, e *github.ReleaseEvent) { + if r.config.Strategy.Type == "full-release" { + return + } + version, err := semver.NewVersion(e.GetRelease().GetTagName()) + if err != nil { + r.log.WithError(err).Errorf("Failed to parse tag '%s' as version", e.GetRelease().GetTagName()) + return + } + // Promotion action + if version.Prerelease() != "" && !e.GetRelease().GetPrerelease() { + r.log.Infof("Promoting release '%s'", e.GetRelease().GetTagName()) + n, err := r.Promote(ctx, e.GetRelease()) + if err != nil { + r.log.WithError(err).Errorf("Failed to promote release '%d'", e.GetRelease().GetID()) + return + } + r.log.Infof("Release promoted to '%s'", n.GetTagName()) + + r.log.Info("Adding pull requests to milestone") + current, err := semver.NewVersion(n.GetTagName()) + if err != nil { + r.log.WithError(err).Errorf("Failed to parse tag '%s' as version", n.GetTagName()) + return + } + r.log.Debugf("Finding previous release based on '%s'", current.String()) + previous, err := r.FindPreviousRelease(ctx, current) + if err != nil { + r.log.WithError(err).Errorf("Failed to find previous release based on '%s'", current.String()) + return + } + + r.log.Debugf("Finding commits in range %s..%s", previous.GetTagName(), n.GetTagName()) + comparison, err := r.client.GetCommitRange(ctx, previous.GetTagName(), n.GetTagName()) + if err != nil { + r.log.WithError(err).Error("Failed to get commit range") + return + } + + r.log.Debugf("Finding PRs in %d commits", len(comparison.Commits)) + pulls, err := r.client.GetPullsInCommitRange(ctx, comparison.Commits) + if err != nil { + r.log.WithError(err).Error("Failed to get pull requests in commit range") + return + } + + r.log.Debugf("Creating milestone '%s'", n.GetName()) + milestone, err := r.client.CreateMilestone(ctx, n.GetName()) + if err != nil { + r.log.WithError(err).Errorf("Failed to create milestone '%s'", milestone.GetTitle()) + return + } + + r.log.Debugf("Adding %d pull requests to milestone '%s'", len(pulls), milestone.GetTitle()) + failed := 0 + for _, p := range pulls { + err := r.client.AddPRtoMilestone(ctx, p, milestone) + if err != nil { + r.log.WithError(err).Warnf("Failed to add PR '%d' to milestone '%d'", p.GetNumber(), milestone.GetNumber()) + failed++ + } + } + r.log.Infof("%d pull requests added to milestone '%s'", len(pulls)-failed, milestone.GetTitle()) + return + } + // Cleanup action + if version.Prerelease() == "" && !e.GetRelease().GetPrerelease() { + r.log.Infof("Cleaning up candidates of '%s'", e.GetRelease().GetTagName()) + number, err := r.CleanupCandidates(ctx, e.GetRelease()) + if err != nil { + r.log.WithError(err).Errorf("Failed to clean up candidates for release '%d'", e.GetRelease().GetID()) + return + } + r.log.Infof("Removed %d release candidates", number) + return + } +} + +func (r *Releaser) FindPreviousRelease(ctx context.Context, version *semver.Version) (*github.RepositoryRelease, error) { + constraint, err := semver.NewConstraint(fmt.Sprintf("<%s", version.String())) + if err != nil { + return nil, errors.Wrap(err, "Could not create semver constraint") + } + pattern := fmt.Sprintf("v%d.%d.", version.Major(), version.Minor()) + if version.Patch() == 0 { + pattern = fmt.Sprintf("v%d.", version.Major()) + } + if version.Minor() == 0 { + pattern = "v" + } + + refs, err := r.client.GetRefs(ctx, fmt.Sprintf("tags/%s", pattern)) + if err != nil { + return nil, errors.Wrapf(err, "Failed to list references with pattern 'tags/%s'", pattern) + } + top := semver.MustParse("v0.0.0") + for _, ref := range refs { + tag := strings.TrimPrefix(ref.GetRef(), "refs/tags/") + v, err := semver.NewVersion(tag) + if err != nil { + continue + } + if !constraint.Check(v) { + continue + } + if v.GreaterThan(top) { + top = v + } + } + return r.client.GetReleaseByTag(ctx, top.Original()) +} + +func (r *Releaser) Promote(ctx context.Context, release *github.RepositoryRelease) (*github.RepositoryRelease, error) { + version, err := semver.NewVersion(release.GetTagName()) + if err != nil { + return nil, errors.Wrapf(err, "Failed to parse tag '%s' as semantic version", release.GetTagName()) + } + + full, err := version.SetPrerelease("") + if err != nil { + return nil, errors.Wrapf(err, "Failed to unset prerelease for tag '%s'", release.GetTagName()) + } + + ref, err := r.client.GetRef(ctx, fmt.Sprintf("tags/%s", release.GetTagName())) + if err != nil { + return nil, errors.Wrapf(err, "Failed to get reference to tag '%s'", release.GetTagName()) + } + + err = r.client.CreateRef(ctx, &github.Reference{ + Ref: github.String(fmt.Sprintf("tags/v%s", full.String())), + Object: &github.GitObject{ + SHA: github.String(ref.GetObject().GetSHA()), + }, + }) + if err != nil { + return nil, errors.Wrapf(err, "Failed to create reference '%s'", full.String()) + } + + rel, err := r.client.EditRelease(ctx, release.GetID(), &github.RepositoryRelease{ + TagName: github.String(fmt.Sprintf("v%s", full.String())), + Name: github.String(full.String()), + }) + if err != nil { + return nil, errors.Wrapf(err, "Failed to edit release '%d'", release.GetID()) + } + return rel, nil +} + +func (r *Releaser) CleanupCandidates(ctx context.Context, release *github.RepositoryRelease) (int, error) { + refs, err := r.client.GetRefs(ctx, fmt.Sprintf("tags/%s-rc.", release.GetTagName())) + if err != nil { + return 0, errors.Wrapf(err, "Failed to list refs for tag '%s'", release.GetTagName()) + } + + for _, ref := range refs { + tag := strings.TrimPrefix(ref.GetRef(), "refs/tags/") + if doomed, _ := r.client.GetReleaseByTag(ctx, tag); doomed != nil { + if doomed.GetID() == release.GetID() || !doomed.GetPrerelease() { + continue + } + if err := r.client.DeleteRelease(ctx, doomed); err != nil { + r.log.WithError(err).Warnf("Failed to delete release '%d'. Continuing...", doomed.GetID()) + } + } + if err := r.client.DeleteTag(ctx, tag); err != nil { + r.log.WithError(err).Warnf("Failed to delete tag '%s'. Continuing...", tag) + } + } + + return len(refs), nil +} + +func (r *Releaser) Match(ref string) bool { + return strings.TrimPrefix(ref, "refs/heads/") == r.config.TargetBranch +} + +func (r *Releaser) Increment(ctx context.Context, current *semver.Version, pulls []*github.PullRequest) (*semver.Version, error) { + next := current.IncPatch() +out: + for _, p := range pulls { + for _, l := range p.Labels { + switch l.GetName() { + case r.config.Labels.Minor: + next = current.IncMinor() + case r.config.Labels.Major: + next = current.IncMajor() + + break out + } + } + } + + if r.config.Strategy.Type == "full-release" { + return &next, nil + } + + prereleases, err := r.client.GetRefs(ctx, fmt.Sprintf("tags/v%s-rc.", next.String())) + if err != nil { + return nil, errors.Wrap(err, "Failed to retrieve pre-releases") + } + + rc := 1 + for _, r := range prereleases { + result := candidateRx.FindStringSubmatch(strings.TrimPrefix(r.GetRef(), fmt.Sprintf("refs/tags/v%s-", next))) + nextrc, err := strconv.Atoi(result[1]) + if err != nil { + return nil, errors.Wrap(err, "Failed to read pre-release number") + } + if nextrc >= rc { + rc = nextrc + 1 + } + } + + return semver.NewVersion(fmt.Sprintf("v%s-rc.%d", next, rc)) +} + +func (r *Releaser) CollectChangelog(pulls []*github.PullRequest) (string, error) { + logs := []string{} + for _, p := range pulls { + matches := changelogRx.FindStringSubmatch(p.GetBody()) + if len(matches) < 2 { + continue + } + desc := strings.TrimSpace(matches[1]) + if strings.ToLower(desc) != "none" || desc != "" { + logs = append(logs, fmt.Sprintf("- #%d %s", p.GetNumber(), desc)) + } + } + return fmt.Sprintf("Changes:\n\n%s", strings.Join(logs, "\n")), nil +} diff --git a/pkg/githubclient.go b/pkg/githubclient.go deleted file mode 100644 index aa6eb35..0000000 --- a/pkg/githubclient.go +++ /dev/null @@ -1,383 +0,0 @@ -package pkg - -import ( - "context" - "fmt" - "io" - "net/http" - "regexp" - "strconv" - "strings" - - "github.com/labstack/echo/v4" - "github.com/pkg/errors" - - semver "github.com/Masterminds/semver/v3" - "github.com/google/go-github/v32/github" -) - -var ( - candidateRx, changelogRx, emptyRx *regexp.Regexp -) - -type Client interface { - HandlePushEvent(*github.PushEvent, Config) (interface{}, error) - HandleReleaseEvent(*github.ReleaseEvent, Config) (interface{}, error) - GetFile(repo Repo, ref, file string) (io.ReadCloser, error) -} - -type ClientImpl struct { - client *github.Client - log echo.Logger -} - -func NewClient(tc *http.Client, log echo.Logger) *ClientImpl { - cl := github.NewClient(tc) - - return &ClientImpl{ - client: cl, - log: log, - } -} - -func init() { - emptyRx = regexp.MustCompile("^\\s*((?i)none|\\s*)\\s*$") - changelogRx = regexp.MustCompile("```release-note\\r\\n([\\s\\S]*?)\\r\\n```") - candidateRx = regexp.MustCompile("^rc.(?P[0-9]+)$") -} - -func (c *ClientImpl) HandlePushEvent(ev *github.PushEvent, config Config) (interface{}, error) { - owner := ev.GetRepo().GetOwner().GetLogin() - repo := ev.GetRepo().GetName() - pushed := strings.TrimPrefix(ev.GetRef(), "refs/heads/") - master := ev.GetRepo().GetMasterBranch() - if config.TargetBranch != "" { - master = config.TargetBranch - } - - if pushed != master { - return nil, nil - } - c.log.Infof("%v pushed. Scheduling release", master) - - release, _, err := c.client.Repositories.GetLatestRelease(context.TODO(), owner, repo) - if err != nil { - return nil, errors.Wrap(err, "Failed to get latest release") - } - - return c.ReleaseCandidate(owner, repo, release.GetTagName(), master) -} - -func (c *ClientImpl) HandleReleaseEvent(ev *github.ReleaseEvent, config Config) (interface{}, error) { - owner := ev.GetRepo().GetOwner().GetLogin() - repo := ev.GetRepo().GetName() - release := ev.GetRelease() - if release.GetPrerelease() { - return nil, nil - } - version, err := semver.NewVersion(release.GetTagName()) - if err != nil { - return nil, nil - } - if version.Prerelease() != "" { - c.log.Infof("Promoting release %s", ev.GetRelease().GetName()) - - return c.Promote(ev) - } - - c.log.Infof("Cleaning up release candidates of %s", ev.GetRelease().GetName()) - _, err = c.Cleanup(ev) - if err != nil { - return nil, errors.Wrapf(err, "Error while cleaning up release candidates") - } - - curr := release.GetTagName() - next := ev.GetRepo().GetMasterBranch() - if config.TargetBranch != "" { - next = config.TargetBranch - } - comparison, _, err := c.client.Repositories.CompareCommits(context.TODO(), owner, repo, curr, next) - if comparison.GetTotalCommits() != 0 { - return c.ReleaseCandidate(ev.GetRepo().GetOwner().GetLogin(), ev.GetRepo().GetName(), ev.GetRelease().GetTagName(), next) - } - - return nil, nil -} - -func (c *ClientImpl) Promote(ev *github.ReleaseEvent) (interface{}, error) { - owner := ev.GetRepo().GetOwner().GetLogin() - repo := ev.GetRepo().GetName() - - release := ev.GetRelease() - - version, err := semver.NewVersion(release.GetTagName()) - if err != nil { - return nil, errors.Wrapf(err, "Error while establishing semver from %s", release.GetTagName()) - } - - ref, _, err := c.client.Git.GetRef(context.TODO(), owner, repo, fmt.Sprintf("tags/%s", release.GetTagName())) - if err != nil { - return nil, errors.Wrapf(err, "Could not fetch tags tags/%s", release.GetTagName()) - } - newVersion, _ := version.SetPrerelease("") - c.log.Debugf("Creating tag v%v @ %s", newVersion, ref.GetObject().GetSHA()) - _, _, err = c.client.Git.CreateRef(context.TODO(), owner, repo, &github.Reference{ - Ref: github.String(fmt.Sprintf("refs/tags/v%v", newVersion)), - Object: ref.Object, - }) - if err != nil { - return nil, errors.Wrapf(err, "Error while creating new tag v%v", newVersion) - } - c.log.Debugf("Updating release %v with tag v%v", release.GetID(), newVersion) - _, _, err = c.client.Repositories.EditRelease(context.TODO(), owner, repo, release.GetID(), &github.RepositoryRelease{ - Name: github.String(newVersion.String()), - TagName: github.String(fmt.Sprintf("v%v", newVersion)), - }) - - if err != nil { - return nil, errors.Wrapf(err, "Error while updating release %v with tag v%v", release.GetID(), newVersion) - } - - c.log.Infof("Creating milestone for pull requests belonging to v%s", newVersion) - _, err = c.LabelPRs(owner, repo, &newVersion) - if err != nil { - c.log.Warn("Error while labeling pull requests", err) - } - - return nil, nil -} - -func (c *ClientImpl) LabelPRs(owner, repo string, next *semver.Version) (interface{}, error) { - last, err := c.FindLast(owner, repo, next) - if err != nil { - return nil, errors.Wrapf(err, "Could not find latest release candidate of v%s", next) - } - - pulls, err := c.getPulls(owner, repo, fmt.Sprintf("v%s", last), fmt.Sprintf("v%s", next)) - if err != nil { - return nil, errors.Wrapf(err, "Could not find pull requests associated with v%s", next) - } - - c.log.Debugf("Found %d pull requests belonging to %s", len(pulls), next) - milestone, _, err := c.client.Issues.CreateMilestone(context.TODO(), owner, repo, &github.Milestone{ - Title: github.String(next.String()), - State: github.String("closed"), - }) - if err != nil { - return nil, errors.Wrapf(err, "Could not create milestone %s", next) - } - - for n, _ := range pulls { - c.log.Debugf("Adding #%d to milestone %s", n, next.String()) - _, _, err := c.client.Issues.Edit(context.TODO(), owner, repo, n, &github.IssueRequest{ - Milestone: milestone.Number, - }) - if err != nil { - c.log.Warn("Error adding pull request to milestone ", err) - } - } - return nil, nil -} - -func (c *ClientImpl) FindLast(owner, repo string, next *semver.Version) (*semver.Version, error) { - constraint, err := semver.NewConstraint(fmt.Sprintf("<%v", next.String())) - if err != nil { - return nil, err - } - - refs, err := c.getRefs(owner, repo, "tags/v") - if err != nil { - return nil, err - } - - top := semver.MustParse("v0.0.0") - for _, ref := range refs { - v, err := semver.NewVersion(strings.TrimPrefix(ref.GetRef(), "refs/tags/")) - if err != nil { - continue - } - if constraint.Check(v) && v.GreaterThan(top) { - top = v - } - } - return top, nil -} - -func (c *ClientImpl) Cleanup(ev *github.ReleaseEvent) (interface{}, error) { - owner := ev.GetRepo().GetOwner().GetLogin() - repo := ev.GetRepo().GetName() - - release := ev.GetRelease() - - refs, err := c.getRefs(owner, repo, fmt.Sprintf("tags/%v-rc.", release.GetTagName())) - if err != nil { - return nil, err - } - - for _, r := range refs { - tag := strings.TrimPrefix(r.GetRef(), "refs/tags/") - toDelete, _, _ := c.client.Repositories.GetReleaseByTag(context.TODO(), owner, repo, tag) - if toDelete != nil { - if toDelete.GetID() == release.GetID() || !toDelete.GetPrerelease() { - // Ensure full releases and current release is not inadvertently deleted - continue - } - c.log.Infof("Deleting release %s", toDelete.GetTagName()) - _, err = c.client.Repositories.DeleteRelease(context.TODO(), owner, repo, toDelete.GetID()) - if err != nil { - c.log.Warn("Could not delete release", err) - } - } - _, err = c.client.Git.DeleteRef(context.TODO(), owner, repo, r.GetRef()) - if err != nil { - c.log.Warn("Could not delete ref", err) - } - } - - return nil, nil -} - -func (c *ClientImpl) ReleaseCandidate(owner, repo, latest, target string) (interface{}, error) { - pulls, err := c.getPulls(owner, repo, latest, target) - if err != nil { - c.log.Warn("Error while examining pull requests", err) - } - changelog, err := c.CollectChangelog(pulls) - if err != nil { - c.log.Warn("Error while gathering changelog", err) - } - c.log.Debugf("Gathered changelog from %d pull requests", len(pulls)) - - nextTag, err := c.NextTag(owner, repo, latest, pulls) - if err != nil { - return nil, errors.Wrap(err, "Could not calculate next tag") - } - - _, _, err = c.client.Repositories.CreateRelease(context.TODO(), owner, repo, &github.RepositoryRelease{ - TagName: github.String(nextTag), - Prerelease: github.Bool(true), - Name: github.String(semver.MustParse(nextTag).String()), - TargetCommitish: github.String(target), - Body: github.String(changelog), - }) - if err != nil { - return nil, err - } - c.log.Infof("Release %s created", nextTag) - return nextTag, nil -} - -func (c *ClientImpl) CollectChangelog(pulls map[int]*github.PullRequest) (string, error) { - logentries := []string{} - for _, pull := range pulls { - matches := changelogRx.FindStringSubmatch(pull.GetBody()) - if len(matches) < 2 { - continue - } - if emptyRx.Match([]byte(matches[1])) { - continue - } - logentries = append(logentries, fmt.Sprintf("- #%d %s", pull.GetNumber(), matches[1])) - } - return fmt.Sprintf("Changes:\n\n%s", strings.Join(logentries, "\n")), nil -} - -func (c *ClientImpl) NextTag(owner, repo, latest string, pulls map[int]*github.PullRequest) (string, error) { - v, err := semver.NewVersion(latest) - if err != nil { - return "", err - } - - nextVersion := v.IncPatch() -out: - for _, pr := range pulls { - for _, label := range pr.Labels { - if label.GetName() == "minor" { - nextVersion = v.IncMinor() - } - if label.GetName() == "major" { - nextVersion = v.IncMajor() - break out - } - } - } - - refs, err := c.getRefs(owner, repo, fmt.Sprintf("tags/v%v-rc.", nextVersion)) - if err != nil { - return "", err - } - rc := 1 - for _, r := range refs { - result := candidateRx.FindStringSubmatch(strings.TrimPrefix(r.GetRef(), fmt.Sprintf("refs/tags/v%v-", nextVersion))) - next, err := strconv.Atoi(result[1]) - if err != nil { - return "", err - } - if next >= rc { - rc = next + 1 - } - } - - return fmt.Sprintf("v%v-rc.%d", nextVersion, rc), nil -} - -func (c *ClientImpl) getPulls(owner, repo, latest, current string) (map[int]*github.PullRequest, error) { - comparison, _, err := c.client.Repositories.CompareCommits(context.TODO(), owner, repo, latest, current) - if err != nil { - return nil, err - } - max := 100 - if len(comparison.Commits) < 100 { - max = len(comparison.Commits) - } - pulls := make(map[int]*github.PullRequest) - for _, commit := range comparison.Commits[:max] { - page := 0 - for { - prs, out, err := c.client.PullRequests.ListPullRequestsWithCommit(context.TODO(), owner, repo, commit.GetSHA(), &github.PullRequestListOptions{ListOptions: github.ListOptions{Page: page}}) - if err != nil { - return nil, err - } - for _, pr := range prs { - if pr.GetNumber() != 0 { - pulls[pr.GetNumber()] = pr - } else { - return nil, errors.New("Could not get pull request number") - } - } - if out.NextPage == 0 { - break - } - page = out.NextPage - } - } - return pulls, nil -} - -func (c *ClientImpl) getRefs(owner, repo, prefix string) ([]*github.Reference, error) { - page := 0 - references := []*github.Reference{} - for { - refs, out, err := c.client.Git.ListMatchingRefs(context.TODO(), owner, repo, &github.ReferenceListOptions{ - Ref: prefix, - ListOptions: github.ListOptions{ - Page: page, - }, - }) - if err != nil { - return nil, err - } - references = append(references, refs...) - if out.NextPage == 0 { - break - } - page = out.NextPage - } - return references, nil -} - -func (c *ClientImpl) GetFile(repo Repo, ref, file string) (io.ReadCloser, error) { - return c.client.Repositories.DownloadContents(context.TODO(), repo.GetOwner().GetLogin(), repo.GetName(), file, &github.RepositoryContentGetOptions{ - Ref: ref, - }) -} diff --git a/pkg/githubhandler.go b/pkg/githubhandler.go deleted file mode 100644 index bae6e45..0000000 --- a/pkg/githubhandler.go +++ /dev/null @@ -1,128 +0,0 @@ -package pkg - -import ( - "net/http" - "time" - - "github.com/bradleyfalzon/ghinstallation" - "github.com/google/go-github/v32/github" - "github.com/labstack/echo/v4" - "github.com/pkg/errors" - "gopkg.in/yaml.v2" -) - -type WebhookHandler struct { - Secret []byte - AppsTransport *ghinstallation.AppsTransport -} - -func NewHandler(atr *ghinstallation.AppsTransport, secret []byte) *WebhookHandler { - return &WebhookHandler{ - AppsTransport: atr, - Secret: secret, - } -} - -type HandledEvent interface { - GetInstallation() *github.Installation -} - -type Repo interface { - GetOwner() *github.User - GetName() string -} - -func (h *WebhookHandler) initClient(c echo.Context, ev HandledEvent, prefix string) (echo.Logger, *ClientImpl) { - k := ghinstallation.NewFromAppsTransport(h.AppsTransport, ev.GetInstallation().GetID()) - l := c.Logger() - l.SetPrefix(prefix) - client := NewClient(&http.Client{Transport: k, Timeout: time.Minute}, l) - return l, client -} - -func (h *WebhookHandler) HandleGithub(c echo.Context) error { - payload, err := github.ValidatePayload(c.Request(), h.Secret) - if err != nil { - return err - } - event, err := github.ParseWebHook(github.WebHookType(c.Request()), payload) - if err != nil { - return err - } - - switch event := event.(type) { - case *github.PushEvent: - logger, client := h.initClient(c, event, event.GetRepo().GetFullName()) - go handlePushEvent(logger, client, event) - return c.String(http.StatusAccepted, "Handling push event") - case *github.ReleaseEvent: - logger, client := h.initClient(c, event, event.GetRepo().GetFullName()) - go handleReleaseEvent(logger, client, event) - return c.String(http.StatusAccepted, "Handling release event") - case *github.PingEvent: - return c.String(http.StatusOK, "Got ping event") - default: - return c.String(http.StatusNotAcceptable, "Unexpected event") - } -} - -func handleReleaseEvent(l echo.Logger, client Client, ev *github.ReleaseEvent) { - l.Debug("Handling release event") - config, err := getConfig(l, client, ev.GetRepo(), ev.GetRelease().GetTargetCommitish()) - if err != nil { - l.Error("Could not instantiate repository config ", err) - return - } - if config == nil { - l.Error("Config is nil") - return - } - if _, err := client.HandleReleaseEvent(ev, *config); err != nil { - l.Error("Error handling release event", err) - } -} - -func handlePushEvent(l echo.Logger, client Client, ev *github.PushEvent) { - l.Debug("Handling push event") - config, err := getConfig(l, client, ev.GetRepo(), ev.GetAfter()) - if err != nil { - l.Error("Could not instantiate repository config ", err) - return - } - if config == nil { - l.Error("Config is nil") - return - } - - if _, err := client.HandlePushEvent(ev, *config); err != nil { - l.Error("Error handling push event", err) - } -} - -type Config struct { - TargetBranch string `yaml:"targetBranch,omitempty"` -} - -func getConfig(l echo.Logger, client Client, repo Repo, ref string) (*Config, error) { - config := &Config{ - TargetBranch: "", - } - reader, err := client.GetFile(repo, ref, ".ship-it") - if err != nil { - l.Debug("Error getting config from github, using defaults ", err) - return config, nil - } - defer reader.Close() - decoder := yaml.NewDecoder(reader) - if err := decoder.Decode(config); err != nil { - return nil, errors.Wrap(err, "Error decoding config file") - } - if err := config.validate(); err != nil { - return nil, errors.Wrap(err, "Failed to validate config file") - } - return config, nil -} - -func (c *Config) validate() error { - return nil -}