Skip to content

Commit

Permalink
Webhook handler now processes installation_repositories events.
Browse files Browse the repository at this point in the history
Such events are triggered when a new repository is added to or removed
from the list of repositories accessible to the app.
  • Loading branch information
blkt committed May 29, 2024
1 parent a01f93c commit 6da9fd3
Show file tree
Hide file tree
Showing 5 changed files with 395 additions and 14 deletions.
164 changes: 151 additions & 13 deletions internal/controlplane/handlers_githubwebhooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,48 @@ func (i *installationEvent) GetInstallation() *installation {
return i.Installation
}

// installationRepositoriesEvent are events occurring when there is
// activity relating to which repositories a GitHub App installation
// can access.
type installationRepositoriesEvent struct {
Action *string `json:"action,omitempty"`
RepositoriesAdded []*repo `json:"repositories_added,omitempty"`
RepositoriesRemoved []*repo `json:"repositories_removed,omitempty"`
RepositorySelection *string `json:"repository_selection,omitempty"`
Sender *user `json:"sender,omitempty"`
Installation *installation `json:"installation,omitempty"`
}

func (i *installationRepositoriesEvent) GetAction() string {
if i.Action != nil {
return *i.Action
}
return ""
}

func (i *installationRepositoriesEvent) GetRepositoriesAdded() []*repo {
return i.RepositoriesAdded
}

func (i *installationRepositoriesEvent) GetRepositoriesRemoved() []*repo {
return i.RepositoriesRemoved
}

func (i *installationRepositoriesEvent) GetRepositorySelection() string {
if i.RepositorySelection != nil {
return *i.RepositorySelection
}
return ""
}

func (i *installationRepositoriesEvent) GetSender() *user {
return i.Sender
}

func (i *installationRepositoriesEvent) GetInstallation() *installation {
return i.Installation
}

type installation struct {
ID *int64 `json:"id,omitempty"`
}
Expand Down Expand Up @@ -348,7 +390,7 @@ func (s *Server) HandleGitHubAppWebhook() http.HandlerFunc {
Logger()
ctx = l.WithContext(ctx)

var res *processingResult
var results []*processingResult
var processingErr error

switch github.WebHookType(r) {
Expand All @@ -360,7 +402,10 @@ func (s *Server) HandleGitHubAppWebhook() http.HandlerFunc {
s.processPingEvent(ctx, rawWBPayload)
case "installation":
wes.Accepted = true
res, processingErr = s.processInstallationAppEvent(ctx, rawWBPayload)
results, processingErr = s.processInstallationAppEvent(ctx, rawWBPayload)
case "installation_repositories":
wes.Accepted = true
results, processingErr = s.processInstallationRepositoriesAppEvent(ctx, rawWBPayload)
default:
l.Info().Msgf("webhook event %s not handled", wes.Typ)
}
Expand All @@ -376,13 +421,23 @@ func (s *Server) HandleGitHubAppWebhook() http.HandlerFunc {
return
}

if res != nil && res.iiw != nil {
for _, res := range results {
l.Info().Str("message-id", m.UUID).Msg("publishing event for execution")
if err := res.iiw.ToMessage(m); err != nil {
wes.Error = true
log.Printf("Error creating event: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
if res.iiw != nil {
if err := res.iiw.ToMessage(m); err != nil {
wes.Error = true
log.Printf("Error creating event: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
if res.eiw != nil {
if err := res.eiw.ToMessage(m); err != nil {
wes.Error = true
log.Printf("Error creating event: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}

if err := s.evt.Publish(res.topic, m); err != nil {
Expand Down Expand Up @@ -846,7 +901,7 @@ func (s *Server) processPullRequestEvent(
func (_ *Server) processInstallationAppEvent(
_ context.Context,
payload []byte,
) (*processingResult, error) {
) ([]*processingResult, error) {
var event *installationEvent
if err := json.Unmarshal(payload, &event); err != nil {
return nil, err
Expand Down Expand Up @@ -881,12 +936,95 @@ func (_ *Server) processInstallationAppEvent(
WithProviderClass(db.ProviderClassGithubApp).
WithPayload(payloadBytes)

res := &processingResult{
topic: installations.ProviderInstallationTopic,
iiw: iiw,
return []*processingResult{
{
topic: installations.ProviderInstallationTopic,
iiw: iiw,
},
}, nil
}

// processInstallationRepositoriesAppEvent processes events related to
// changes to the list of repositories that the app can access.
func (s *Server) processInstallationRepositoriesAppEvent(
ctx context.Context,
payload []byte,
) ([]*processingResult, error) {
var event *installationRepositoriesEvent
if err := json.Unmarshal(payload, &event); err != nil {
return nil, err
}

return res, nil
// Check fields mandatory for processing the event
if event.GetAction() == "" {
return nil, errors.New("invalid event: action is nil")
}
if event.GetInstallation() == nil {
return nil, errors.New("invalid event: installation is nil")
}
if event.GetInstallation().GetID() == 0 {
return nil, errors.New("invalid installation: id is 0")
}

results := make([]*processingResult, 0, len(event.GetRepositoriesAdded())+len(event.GetRepositoriesRemoved()))
for _, repo := range event.GetRepositoriesAdded() {
// caveat: we're accessing the database once for every
// repository, which might be inefficient at scale.
res, err := s.innerInstallationRepositories(
ctx,
repo,
event.GetAction(),
events.TopicQueueReconcileEntityAdd,
)
if err != nil {
return nil, err
}

results = append(results, res)
}
for _, repo := range event.GetRepositoriesRemoved() {
// caveat: we're accessing the database once for every
// repository, which might be inefficient at scale.
res, err := s.innerInstallationRepositories(
ctx,
repo,
event.GetAction(),
events.TopicQueueReconcileEntityDelete,
)
if err != nil {
return nil, err
}

results = append(results, res)
}

return results, nil
}

func (s *Server) innerInstallationRepositories(
ctx context.Context,
repo *repo,
action string,
topic string,
) (*processingResult, error) {
dbrepo, err := s.fetchRepo(ctx, repo)
if err != nil {
return nil, err
}

// protobufs are our API, so we always execute on these instead of the DB directly.
pbRepo := repositories.PBRepositoryFromDB(*dbrepo)
eiw := entities.NewEntityInfoWrapper().
WithProviderID(dbrepo.ProviderID).
WithRepository(pbRepo).
WithProjectID(dbrepo.ProjectID).
WithRepositoryID(dbrepo.ID).
WithActionEvent(action)

return &processingResult{
topic: topic,
eiw: eiw,
}, nil
}

func (s *Server) fetchRepo(
Expand Down
163 changes: 162 additions & 1 deletion internal/controlplane/handlers_githubwebhooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3240,6 +3240,11 @@ func (s *UnitTestSuite) TestHandleGitHubAppWebHook() {
t := s.T()
t.Parallel()

providerName := "github"
repositoryID := uuid.New()
projectID := uuid.New()
providerID := uuid.New()

tests := []struct {
name string
event string
Expand Down Expand Up @@ -3474,6 +3479,156 @@ func (s *UnitTestSuite) TestHandleGitHubAppWebHook() {
queued: nil,
},

// installation repositories events
{
name: "installation_repositories added",
// https://docs.github.com/en/webhooks/webhook-events-and-payloads#installation
event: "installation_repositories",
// https://pkg.go.dev/github.com/google/go-github/v62@v62.0.0/github#InstallationEvent
payload: &github.InstallationRepositoriesEvent{
Action: github.String("added"),
RepositoriesAdded: []*github.Repository{
newGitHubRepo(
12345,
"minder",
"stacklok/minder",
"https://github.com/stacklok/minder",
),
newGitHubRepo(
67890,
"trusty",
"stacklok/trusty",
"https://github.com/stacklok/trusty",
),
},
Installation: &github.Installation{
ID: github.Int64(12345),
},
Sender: &github.User{
Login: github.String("stacklok"),
HTMLURL: github.String("https://github.com/apps"),
},
},
mockStoreFunc: newMockStore(
withSuccessfulGetRepositoryByRepoID(
db.Repository{
ID: repositoryID,
ProjectID: projectID,
RepoID: 12345,
Provider: providerName,
ProviderID: providerID,
},
),
withSuccessfulGetRepositoryByRepoID(
db.Repository{
ID: repositoryID,
ProjectID: projectID,
RepoID: 12345,
Provider: providerName,
ProviderID: providerID,
},
),
),
topic: events.TopicQueueReconcileEntityAdd,
statusCode: http.StatusOK,
//nolint:thelper
queued: func(t *testing.T, event string, ch <-chan *message.Message) {
timeout := 1 * time.Second

received := withTimeout(ch, timeout)
require.NotNilf(t, received, "no event received after waiting %s", timeout)
require.Equal(t, "12345", received.Metadata["id"])
require.Equal(t, event, received.Metadata["type"])
require.Equal(t, "https://api.github.com/", received.Metadata["source"])
require.Equal(t, providerID.String(), received.Metadata["provider_id"])
require.Equal(t, projectID.String(), received.Metadata[entities.ProjectIDEventKey])
require.Equal(t, repositoryID.String(), received.Metadata["repository_id"])

received = withTimeout(ch, timeout)
require.NotNilf(t, received, "no event received after waiting %s", timeout)
require.Equal(t, "12345", received.Metadata["id"])
require.Equal(t, event, received.Metadata["type"])
require.Equal(t, "https://api.github.com/", received.Metadata["source"])
require.Equal(t, providerID.String(), received.Metadata["provider_id"])
require.Equal(t, projectID.String(), received.Metadata[entities.ProjectIDEventKey])
require.Equal(t, repositoryID.String(), received.Metadata["repository_id"])
},
},
{
name: "installation_repositories removed",
// https://docs.github.com/en/webhooks/webhook-events-and-payloads#installation
event: "installation_repositories",
// https://pkg.go.dev/github.com/google/go-github/v62@v62.0.0/github#InstallationEvent
payload: &github.InstallationRepositoriesEvent{
Action: github.String("removed"),
RepositoriesRemoved: []*github.Repository{
newGitHubRepo(
12345,
"minder",
"stacklok/minder",
"https://github.com/stacklok/minder",
),
newGitHubRepo(
67890,
"trusty",
"stacklok/trusty",
"https://github.com/stacklok/trusty",
),
},
Installation: &github.Installation{
ID: github.Int64(12345),
},
Sender: &github.User{
Login: github.String("stacklok"),
HTMLURL: github.String("https://github.com/apps"),
},
},
mockStoreFunc: newMockStore(
withSuccessfulGetRepositoryByRepoID(
db.Repository{
ID: repositoryID,
ProjectID: projectID,
RepoID: 12345,
Provider: providerName,
ProviderID: providerID,
},
),
withSuccessfulGetRepositoryByRepoID(
db.Repository{
ID: repositoryID,
ProjectID: projectID,
RepoID: 12345,
Provider: providerName,
ProviderID: providerID,
},
),
),
topic: events.TopicQueueReconcileEntityDelete,
statusCode: http.StatusOK,
//nolint:thelper
queued: func(t *testing.T, event string, ch <-chan *message.Message) {
timeout := 1 * time.Second

received := withTimeout(ch, timeout)
require.NotNilf(t, received, "no event received after waiting %s", timeout)
require.Equal(t, "12345", received.Metadata["id"])
require.Equal(t, event, received.Metadata["type"])
require.Equal(t, "https://api.github.com/", received.Metadata["source"])
require.Equal(t, providerID.String(), received.Metadata["provider_id"])
require.Equal(t, projectID.String(), received.Metadata[entities.ProjectIDEventKey])
require.Equal(t, repositoryID.String(), received.Metadata["repository_id"])

received = withTimeout(ch, timeout)
require.NotNilf(t, received, "no event received after waiting %s", timeout)
require.Equal(t, "12345", received.Metadata["id"])
require.Equal(t, event, received.Metadata["type"])
require.Equal(t, "https://api.github.com/", received.Metadata["source"])
require.Equal(t, providerID.String(), received.Metadata["provider_id"])
require.Equal(t, projectID.String(), received.Metadata[entities.ProjectIDEventKey])
require.Equal(t, repositoryID.String(), received.Metadata["repository_id"])
},
},

// garbage
{
name: "garbage",
Expand Down Expand Up @@ -3516,7 +3671,13 @@ func (s *UnitTestSuite) TestHandleGitHubAppWebHook() {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockStore := mockdb.NewMockStore(ctrl)
var mockStore *mockdb.MockStore
if tt.mockStoreFunc != nil {
mockStore = tt.mockStoreFunc(ctrl)
} else {
mockStore = mockdb.NewMockStore(ctrl)
}

srv, evt := newDefaultServer(t, mockStore, nil)
srv.cfg.WebhookConfig.WebhookSecret = "test"

Expand Down
2 changes: 2 additions & 0 deletions internal/events/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,6 @@ const (
TopicQueueReconcileProfileInit = "internal.profile.init.event"
// TopicQueueReconcileEntityDelete is the topic for reconciling when an entity is deleted
TopicQueueReconcileEntityDelete = "internal.entity.delete.event"
// TopicQueueReconcileEntityAdd is the topic for reconciling when an entity is added
TopicQueueReconcileEntityAdd = "internal.entity.add.event"
)
Loading

0 comments on commit 6da9fd3

Please sign in to comment.