├── .envrc.example ├── .github └── workflows │ └── build.yml ├── .gitignore ├── DEVELOPMENT.md ├── LICENSE.md ├── Makefile ├── README.md ├── cmd └── go-probot │ └── main.go ├── example ├── README.md └── basic.go ├── go.mod ├── go.sum └── probot ├── app.go ├── context.go ├── handlers.go ├── probot.go ├── types.go └── util.go /.envrc.example: -------------------------------------------------------------------------------- 1 | # Project-specific environment variables, powered by direnv 2 | # For more details, see: https://direnv.net 3 | export GITHUB_BASE_URL= 4 | export GITHUB_APP_ID= 5 | export GITHUB_APP_PRIVATE_KEY_PATH= 6 | export GITHUB_APP_WEBHOOK_SECRET=development 7 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: ^1.14 20 | id: go 21 | 22 | - name: Check out code into the Go module directory 23 | uses: actions/checkout@v2 24 | 25 | - name: Build 26 | run: make 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Build artifacts 18 | bin 19 | 20 | # Project-specific environment variables, powered by direnv 21 | .envrc 22 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # :construction: Development 2 | 3 | Use [`reflex`](https://github.com/cespare/reflex) to auto-reload the webhook server when any file changes are detected: 4 | 5 | ```shell 6 | reflex -s -- go run cmd/go-probot/main.go -p 8888 7 | ``` 8 | 9 | Send an example webhook: 10 | 11 | ```shell 12 | curl -X POST -d '{"hello":"world"}' -H "Content-type: application/json" http://localhost:8888/ 13 | ``` 14 | 15 | Expose the endpoint over the public internet using `ngrok`: 16 | 17 | ```shell 18 | ngrok http 8000 19 | ``` 20 | 21 | Alternatively, setup an [`ngrok` configuration file](https://ngrok.com/docs#config): 22 | 23 | ```yaml 24 | tunnels: 25 | main: 26 | proto: http 27 | addr: 8888 28 | ``` 29 | 30 | And then: 31 | 32 | ```shell 33 | ngrok start main 34 | ``` 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/swinton/go-probot/13d87bbf12798d6825c0d0a31472d8408df36c6d/LICENSE.md -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | go build -o bin/go-probot cmd/go-probot/main.go 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # :robot: `go-probot` ![](https://github.com/swinton/go-probot/workflows/Build/badge.svg) 2 | > GitHub Apps to automate your workflow, in Golang, inspired by [Probot](https://probot.github.io) 3 | 4 | ## Example 5 | Check out [the basic example](example/basic.go). 6 | 7 | ## Credits 8 | - [Probot - GitHub Apps to automate your workflow](https://probot.github.io) 9 | - [HTTP Server - Go Web Examples](https://gowebexamples.com/http-server/) 10 | - [Routing (using gorilla/mux) - Go Web Examples](https://gowebexamples.com/routes-using-gorilla-mux/) 11 | - [Accepting Github Webhooks with Go · groob.io](https://groob.io/tutorial/go-github-webhook/) 12 | -------------------------------------------------------------------------------- /cmd/go-probot/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/swinton/go-probot/probot" 5 | ) 6 | 7 | func main() { 8 | probot.Start() 9 | } 10 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | ## Run the basic example app 2 | 3 | ```shell 4 | go run example/basic.go -p 8888 5 | ``` 6 | -------------------------------------------------------------------------------- /example/basic.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | 7 | "github.com/google/go-github/github" 8 | "github.com/swinton/go-probot/probot" 9 | ) 10 | 11 | func main() { 12 | // Add a handler for "issues" events 13 | probot.HandleEvent("issues", func(ctx *probot.Context) error { 14 | // Because we're listening for "issues" we know the payload is a *github.IssuesEvent 15 | event := ctx.Payload.(*github.IssuesEvent) 16 | log.Printf("🌈 Got issues %+v\n", event) 17 | 18 | // Create a comment back on the issue 19 | // https://github.com/google/go-github/blob/d57a3a84ba041135efb6b7ad3991f827c93c306a/github/issues_comments.go#L101-L117 20 | newComment := &github.IssueComment{Body: github.String("## :wave: :earth_americas:\n\n![fellowshipoftheclaps](https://user-images.githubusercontent.com/27806/91333726-91c46f00-e793-11ea-9724-dc2e18ca28d0.gif)")} 21 | comment, _, err := ctx.GitHub.Issues.CreateComment(context.Background(), *event.Repo.Owner.Login, *event.Repo.Name, int(event.Issue.GetID()), newComment) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | // Log out our new comment 27 | log.Printf("✨ New comment created: %+v\n", comment) 28 | 29 | return nil 30 | }) 31 | 32 | probot.Start() 33 | } 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/swinton/go-probot 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/bradleyfalzon/ghinstallation v1.1.1 7 | github.com/google/go-github v17.0.0+incompatible 8 | github.com/google/go-querystring v1.0.0 // indirect 9 | github.com/gorilla/mux v1.8.0 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bradleyfalzon/ghinstallation v1.1.1 h1:pmBXkxgM1WeF8QYvDLT5kuQiHMcmf+X015GI0KM/E3I= 2 | github.com/bradleyfalzon/ghinstallation v1.1.1/go.mod h1:vyCmHTciHx/uuyN82Zc3rXN3X2KTK8nUTCrTMwAhcug= 3 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= 4 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 5 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 6 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 7 | github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= 8 | github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= 9 | github.com/google/go-github/v29 v29.0.2 h1:opYN6Wc7DOz7Ku3Oh4l7prmkOMwEcQxpFtxdU8N8Pts= 10 | github.com/google/go-github/v29 v29.0.2/go.mod h1:CHKiKKPHJ0REzfwc14QMklvtHwCveD0PxlMjLlzAM5E= 11 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 12 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 13 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 14 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 15 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= 16 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 17 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 18 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 19 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 20 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 21 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 22 | -------------------------------------------------------------------------------- /probot/app.go: -------------------------------------------------------------------------------- 1 | package probot 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | "os" 9 | "strconv" 10 | 11 | "github.com/bradleyfalzon/ghinstallation" 12 | "github.com/google/go-github/github" 13 | ) 14 | 15 | // App encapsulates the fields needed to define a GitHub App 16 | type App struct { 17 | BaseURL string 18 | ID int64 19 | Key []byte 20 | Secret string 21 | } 22 | 23 | // NewApp instantiates a GitHub App from environment variables 24 | func NewApp() *App { 25 | // Read GitHub App credentials from environment 26 | baseURL, exists := os.LookupEnv("GITHUB_BASE_URL") 27 | if !exists { 28 | log.Fatal("Unable to load GitHub Base URL from environment") 29 | } 30 | 31 | privateKey, err := ioutil.ReadFile(os.Getenv("GITHUB_APP_PRIVATE_KEY_PATH")) 32 | if err != nil { 33 | log.Fatal(fmt.Sprintf("Unable to load GitHub App private key from file: %s", os.Getenv("GITHUB_APP_PRIVATE_KEY_PATH"))) 34 | } 35 | 36 | id, err := strconv.ParseInt(os.Getenv("GITHUB_APP_ID"), 10, 64) 37 | if err != nil { 38 | log.Fatal(fmt.Sprintf("Unable to load GitHub App: %s", os.Getenv("GITHUB_APP_ID"))) 39 | } 40 | 41 | secret, exists := os.LookupEnv("GITHUB_APP_WEBHOOK_SECRET") 42 | if !exists { 43 | log.Fatal("Unable to load webhook secret from environment") 44 | } 45 | 46 | // Instantiate GitHub App 47 | app := &App{BaseURL: baseURL, ID: id, Key: privateKey, Secret: secret} 48 | 49 | return app 50 | } 51 | 52 | // NewEnterpriseClient instantiates a new GitHub Client using the App and Installation 53 | func NewEnterpriseClient(app *App, installation *installation) (*github.Client, error) { 54 | // Shared transport to reuse TCP connections. 55 | tr := http.DefaultTransport 56 | itr, err := ghinstallation.New(tr, app.ID, installation.ID, app.Key) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | itr.BaseURL = app.BaseURL 62 | client, err := github.NewEnterpriseClient(app.BaseURL, app.BaseURL, &http.Client{Transport: itr}) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | // Overwrite User-Agent, for logging 68 | // See: https://developer.github.com/v3/#user-agent-required 69 | client.UserAgent = "swinton/go-probot" 70 | 71 | return client, nil 72 | } 73 | -------------------------------------------------------------------------------- /probot/context.go: -------------------------------------------------------------------------------- 1 | package probot 2 | 3 | import "github.com/google/go-github/github" 4 | 5 | // Context encapsulates the fields passed to webhook handlers 6 | type Context struct { 7 | App *App 8 | Payload interface{} 9 | GitHub *github.Client 10 | } 11 | 12 | // NewContext instantiates a new context 13 | func NewContext(app *App) *Context { 14 | return &Context{App: app} 15 | } 16 | -------------------------------------------------------------------------------- /probot/handlers.go: -------------------------------------------------------------------------------- 1 | package probot 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/google/go-github/github" 9 | ) 10 | 11 | var handlers = make(map[string]eventHandler) 12 | 13 | func rootHandler(app *App) func(w http.ResponseWriter, r *http.Request) { 14 | return func(w http.ResponseWriter, r *http.Request) { 15 | context := NewContext(app) 16 | 17 | // Validate the payload 18 | // Per the docs: https://docs.github.com/en/developers/webhooks-and-events/securing-your-webhooks#validating-payloads-from-github 19 | payloadBytes, err := github.ValidatePayload(r, []byte(app.Secret)) 20 | if err != nil { 21 | log.Println(err) 22 | http.Error(w, "Forbidden", http.StatusForbidden) 23 | return 24 | } 25 | 26 | // Log the request headers 27 | log.Printf("Signature validates: %s\n", r.Header.Get("X-Hub-Signature")) 28 | log.Printf("Headers: %v\n", r.Header) 29 | 30 | // Get the installation from the payload 31 | payload := &payloadInstallation{} 32 | json.Unmarshal(payloadBytes, payload) 33 | log.Printf("Installation: %d\n", payload.Installation.GetID()) 34 | log.Printf("Received GitHub App ID %d\n", app.ID) 35 | 36 | // Parse the incoming request into an event 37 | context.Payload, err = github.ParseWebHook(github.WebHookType(r), payloadBytes) 38 | if err != nil { 39 | log.Println(err) 40 | http.Error(w, "Bad Request", http.StatusBadRequest) 41 | return 42 | } 43 | log.Printf("Event type: %T\n", context.Payload) 44 | 45 | // Instantiate client 46 | installation := &installation{ID: payload.Installation.GetID()} 47 | context.GitHub, err = NewEnterpriseClient(app, installation) 48 | if err != nil { 49 | log.Println(err) 50 | http.Error(w, "Server Error", http.StatusInternalServerError) 51 | return 52 | } 53 | log.Printf("client %s instantiated for %s\n", context.GitHub.UserAgent, context.GitHub.BaseURL) 54 | 55 | // Reset the body for subsequent handlers to access 56 | r.Body = reset(r.Body, payloadBytes) 57 | 58 | // Look to see if we have a handler for the incoming webhook type 59 | if handler, ok := handlers[github.WebHookType(r)]; ok { 60 | err = handler(context) 61 | if err != nil { 62 | log.Println(err) 63 | http.Error(w, "Server Error", http.StatusInternalServerError) 64 | return 65 | } 66 | } else { 67 | log.Printf("Unknown event type: %s\n", github.WebHookType(r)) 68 | http.Error(w, "Bad Request", http.StatusBadRequest) 69 | return 70 | } 71 | 72 | // Success! 73 | // Send response as application/json 74 | resp := webhookResponse{ 75 | Received: true, 76 | } 77 | w.Header().Add("Content-Type", "application/json") 78 | json.NewEncoder(w).Encode(resp) 79 | } 80 | } 81 | 82 | // HandleEvent registers an eventHandler for a named event 83 | func HandleEvent(event string, f eventHandler) { 84 | handlers[event] = f 85 | } 86 | -------------------------------------------------------------------------------- /probot/probot.go: -------------------------------------------------------------------------------- 1 | package probot 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | 9 | "github.com/gorilla/mux" 10 | ) 11 | 12 | var portVar int 13 | 14 | var app *App 15 | 16 | // Router creates a new mux.Router and registers our webhook handler 17 | func Router(path string) *mux.Router { 18 | r := mux.NewRouter() 19 | 20 | r.HandleFunc(path, rootHandler(app)).Methods("POST") 21 | 22 | return r 23 | } 24 | 25 | // Start handles initialization and setup of the webhook server 26 | func Start() { 27 | initialize() 28 | 29 | // Webhook router 30 | router := Router("/") 31 | 32 | // Server 33 | log.Printf("Server running at: http://localhost:%d/\n", portVar) 34 | log.Fatal(http.ListenAndServe(fmt.Sprintf("127.0.0.1:%d", portVar), router)) 35 | } 36 | 37 | func initialize() { 38 | // Parse incoming command-line arguments 39 | flag.IntVar(&portVar, "p", 8000, "port to listen on, defaults to 8000") 40 | flag.Parse() 41 | 42 | // Initialize app 43 | app = NewApp() 44 | log.Printf("Loaded GitHub App ID: %d\n", app.ID) 45 | } 46 | -------------------------------------------------------------------------------- /probot/types.go: -------------------------------------------------------------------------------- 1 | package probot 2 | 3 | import "github.com/google/go-github/github" 4 | 5 | type eventHandler func(ctx *Context) error 6 | 7 | type webhookResponse struct { 8 | Received bool `json:"received"` 9 | } 10 | 11 | // payloadInstallation represents the incoming installation part of the payload 12 | type payloadInstallation struct { 13 | Installation *github.Installation `json:"installation"` 14 | } 15 | 16 | // Installation encapsulates the fields needed to define an installation of a GitHub App 17 | type installation struct { 18 | ID int64 19 | } 20 | -------------------------------------------------------------------------------- /probot/util.go: -------------------------------------------------------------------------------- 1 | package probot 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "io/ioutil" 7 | ) 8 | 9 | // Reset will return a new ReadCloser for the body that can be passed to subsequent handlers 10 | func reset(old io.ReadCloser, b []byte) io.ReadCloser { 11 | old.Close() 12 | return ioutil.NopCloser(bytes.NewBuffer(b)) 13 | } 14 | --------------------------------------------------------------------------------