Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implementing GitHub webhook handler #331

Open
wants to merge 6 commits into
base: fred/approval-service-skeleton-1
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion libs/github/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"errors"
"strings"

"github.com/google/go-github/v63/github"
"github.com/google/go-github/v69/github"
)

var (
Expand Down
79 changes: 79 additions & 0 deletions libs/github/deployment_review.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package github

import (
"context"
"fmt"
"log/slog"

"github.com/google/go-github/v69/github"
)

type PendingDeploymentApprovalState string

const (
PendingDeploymentApprovalStateApproved PendingDeploymentApprovalState = "approved"
PendingDeploymentApprovalStateRejected PendingDeploymentApprovalState = "rejected"
)

type Deployment struct {
URL string
ID int64
SHA string
Ref string
Task string
Environment string
Description string
StatusesURL string
RepositoryURL string
NodeID string
}

type PendingDeploymentInfo struct {
Org string
Repo string
RunID int64
EnvIDs []int64
State PendingDeploymentApprovalState
Comment string
}

func (c *Client) UpdatePendingDeployment(ctx context.Context, info PendingDeploymentInfo) ([]Deployment, error) {
objs, _, err := c.client.Actions.PendingDeployments(ctx, info.Org, info.Repo, info.RunID, &github.PendingDeploymentsRequest{
State: string(info.State),
EnvironmentIDs: info.EnvIDs,
Comment: info.Comment,
})

if err != nil {
slog.Default().Error("failed to update pending deployments", "pendingDeployment", info)
return nil, fmt.Errorf("failed to update pending deployments: %w", err)
}

var deploys []Deployment
for _, obj := range objs {
deploys = append(deploys, Deployment{
URL: obj.GetURL(),
ID: obj.GetID(),
SHA: obj.GetSHA(),
Ref: obj.GetRef(),
Task: obj.GetTask(),
Environment: obj.GetEnvironment(),
Description: obj.GetDescription(),
StatusesURL: obj.GetStatusesURL(),
RepositoryURL: obj.GetRepositoryURL(),
NodeID: obj.GetNodeID(),
})
}

return deploys, nil
}

func (i *PendingDeploymentInfo) LogValue() slog.Value {
return slog.GroupValue(
slog.String("org", i.Org),
slog.String("repo", i.Repo),
slog.Int64("run_id", i.RunID),
slog.String("state", string(i.State)),
slog.Any("env_ids", i.EnvIDs),
)
}
2 changes: 1 addition & 1 deletion libs/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
"context"
"time"

go_github "github.com/google/go-github/v63/github"
go_github "github.com/google/go-github/v69/github"
"golang.org/x/oauth2"
)

Expand Down
2 changes: 1 addition & 1 deletion libs/github/pull_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
"fmt"
"time"

"github.com/google/go-github/v63/github"
"github.com/google/go-github/v69/github"
"github.com/gravitational/trace"
)

Expand Down
2 changes: 1 addition & 1 deletion libs/github/pull_request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"path/filepath"
"testing"

go_github "github.com/google/go-github/v63/github"
go_github "github.com/google/go-github/v69/github"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down
152 changes: 152 additions & 0 deletions libs/github/webhook/webhook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package webhook

import (
"log/slog"
"net/http"

"github.com/google/go-github/v63/github"
)

// EventHandlerFunc is a function that handles a webhook event.
// The function should return an error if the event could not be handled.
// If the error is not nil, the webhook will respond with a 500 Internal Server Error.
//
// It is important that this is non-blocking and does not perform any long-running operations.
// GitHub will close the connection if the webhook does not respond within 10 seconds.
//
// Example usage:
//
// func(event interface{}) error {
// switch event := event.(type) {
// case *github.CommitCommentEvent:
// processCommitCommentEvent(event)
// case *github.CreateEvent:
// processCreateEvent(event)
// ...
// }
// return nil
// }
type EventHandlerFunc func(event interface{}) error

// Handler is an implementation of [http.Handler] that handles GitHub webhook events.
type Handler struct {
eventHandler EventHandlerFunc
secretToken []byte
log *slog.Logger
}

var _ http.Handler = &Handler{}

type Opt func(*Handler) error

// WithSecretToken sets the secret token for the webhook.
// The secret token is used to create a hash of the request body, which is sent in the X-Hub-Signature header.
// If not set, the webhook will not verify the signature of the request.
//
// For more information, see: https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
func WithSecretToken(secretToken string) Opt {
return func(p *Handler) error {
p.secretToken = []byte(secretToken)
return nil
}
}

// WithLogger sets the logger for the webhook.
func WithLogger(log *slog.Logger) Opt {
return func(p *Handler) error {
p.log = log
return nil
}
}

var defaultOpts = []Opt{
WithLogger(slog.Default()),
}

// NewHandler creates a new webhook handler.
func NewHandler(eventHandler EventHandlerFunc, opts ...Opt) *Handler {
h := Handler{
eventHandler: eventHandler,
}
for _, opt := range defaultOpts {
opt(&h)
}

for _, opt := range opts {
opt(&h)
}
return &h
}

// Headers is a list of special headers that are sent with a webhook request.
// For more information, see: https://docs.github.com/en/webhooks/webhook-events-and-payloads#delivery-headers
type Headers struct {
// GithubHookID is the unique identifier of the webhook.
GithubHookID string
// GithubEvent is the type of event that triggered the delivery.
GithubEvent string
// GithubDelivery is a globally unique identifier (GUID) to identify the event
GithubDelivery string
// GitHubHookInstallationTargetType is the type of resource where the webhook was created.
GitHubHookInstallationTargetType string
// GitHubHookInstallationTargetID is the unique identifier of the resource where the webhook was created.
GitHubHookInstallationTargetID string

// HubSignature256 is the HMAC hex digest of the response body.
// Is generated with the SHA-256 algorithm with a shared secret used as the HMAC key.
// This header will be sent if the webhook is configured with a secret.
HubSignature256 string
}

// ServeHTTP handles a webhook request.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
// Parse headers for debugging and audit purposes.
var head Headers
head.GithubHookID = r.Header.Get("X-GitHub-Hook-ID")
head.GithubEvent = r.Header.Get("X-GitHub-Event")
head.GithubDelivery = r.Header.Get("X-GitHub-Delivery")
head.GitHubHookInstallationTargetType = r.Header.Get("X-GitHub-Hook-Installation-Target-Type")
head.GitHubHookInstallationTargetID = r.Header.Get("X-GitHub-Hook-Installation-Target-ID")
head.HubSignature256 = r.Header.Get("X-Hub-Signature-256")

if h.secretToken == nil && head.HubSignature256 != "" {
h.log.Warn("received signature but no secret token is set", "github_headers", head)
http.Error(w, "invalid request", http.StatusInternalServerError)
return
}

payload, err := github.ValidatePayload(r, h.secretToken) // If secretToken is empty, the signature will not be verified.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we should error if the secret token is empty and a signature is provided. If this happens, we've probably misconfigured something.

if err != nil {
h.log.Warn("webhook validation failed", "github_headers", head)
http.Error(w, "invalid request", http.StatusBadRequest)
return
}

event, err := github.ParseWebHook(head.GithubEvent, payload)
if err != nil {
h.log.Error("failed to parse webhook event", "error", err)
http.Error(w, "invalid request", http.StatusBadRequest)
return
}

if err := h.eventHandler(event); err != nil {
h.log.Error("failed to handle webhook event", "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}

// Respond to the request.
w.WriteHeader(http.StatusOK)
}

func (h *Headers) LogValue() slog.Value {
return slog.GroupValue(
slog.String("github_hook_id", h.GithubHookID),
slog.String("github_event", h.GithubEvent),
slog.String("github_delivery", h.GithubDelivery),
slog.String("github_hook_installation_target_type", h.GitHubHookInstallationTargetType),
slog.String("github_hook_installation_target_id", h.GitHubHookInstallationTargetID),
slog.String("hub_signature_256", h.HubSignature256),
)
}
Loading
Loading