blob: 3d76bf1cd321d70d6e3a9a2f5f0428e2313fcde0 [file] [log] [blame]
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"slices"
"cloud.google.com/go/firestore"
"golang.org/x/oscar/internal/actions"
"golang.org/x/oscar/internal/docs"
"golang.org/x/oscar/internal/github"
)
// handleGitHubEvent handles incoming webhook requests from GitHub
// and reports whether the request was handled.
//
// If the incoming request was triggered by supported event, and sync
// is enabled, it syncs its relevant state. If changes are enabled,
// it takes relevant actions in response to the event.
//
// Otherwise, it logs the event and returns (false, nil).
//
// The supported events are:
// - new GitHub issue (see [Gaby.handleGitHubIssueEvent])
// - new GitHub issue comment (see [Gaby.handleGitHubIssueCommentEvent])
//
// handled is true if all appropriate syncs and actions were performed
// in response to the event, and false if the event was skipped or an
// error occurred. (handled is also false if either of [gabyFlags.enablesync]
// or [gabyFlags.enablechanges] is false.)
//
// handleGitHubEvent returns an error if any of the syncs or actions fails,
// or if the webhook request is invalid according to [github.ValidateWebhookRequest].
func (g *Gaby) handleGitHubEvent(r *http.Request, fl *gabyFlags) (handled bool, err error) {
event, err := github.ValidateWebhookRequest(r, g.secret)
if err != nil {
return false, fmt.Errorf("%w: %v", errInvalidWebhookRequest, err)
}
if !slices.Contains(g.githubProjects, event.Project()) {
g.slog.Warn("unexpected webhook request", "webhook_project", event.Project(), "gaby_project", g.githubProjects, "event", event)
return false, nil
}
switch p := event.Payload.(type) {
case *github.WebhookIssueEvent:
return g.handleGitHubIssueEvent(r.Context(), p, fl)
case *github.WebhookIssueCommentEvent:
return g.handleGitHubIssueCommentEvent(r.Context(), p, fl)
default:
g.slog.Info("ignoring GitHub event", "type", event.Type, "event", event)
}
return false, nil
}
var errInvalidWebhookRequest = errors.New("invalid webhook request")
// handleGitHubIssueEvent handles an incoming GitHub "issue" event and
// reports whether the event was handled.
//
// If the event is a new issue, and sync is enabled, the function
// syncs the corresponding GitHub project. If changes are also enabled,
// it posts related issues and fixes the body and comments of the issue.
//
// It returns an error immediately if any of the syncs or actions fails.
//
// Otherwise, it logs the event and returns (false, nil).
func (g *Gaby) handleGitHubIssueEvent(ctx context.Context, event *github.WebhookIssueEvent, fl *gabyFlags) (handled bool, _ error) {
if event.Action != github.WebhookIssueActionOpened {
g.slog.Info("ignoring GitHub issue event (action is not opened)", "event", event, "action", event.Action)
return false, nil
}
g.slog.Info("handling GitHub issue", "event", event)
project := event.Repository.Project
if fl.enablesync {
if err := g.syncGitHubProject(ctx, project); err != nil {
return false, err
}
if err := g.embedAll(ctx); err != nil {
return false, err
}
}
// Do not attempt changes unless sync is enabled and completely succeeded.
if fl.enablechanges && fl.enablesync {
// No need to lock; [related.Poster.Post] and [related.Poster.Run] can
// happen concurrently.
if err := g.relatedPoster.Post(ctx, project, event.Issue.Number); err != nil {
return false, err
}
if err := g.fixGitHubIssue(ctx, project, event.Issue.Number); err != nil {
return false, err
}
if err := g.labeler.LabelIssue(ctx, project, event.Issue.Number); err != nil {
return false, err
}
return true, nil
}
return false, nil
}
// handleGitHubIssueCommentEvent handles an incoming GitHub "issue comment" event
// and reports whether the event was handled.
//
// If the event is a new issue comment, and sync is enabled, the function
// syncs the corresponding GitHub project. If changes are also enabled,
// it fixes the body and comments of the issue to which the comment
// was posted.
//
// It returns an error immediately if any of the syncs or actions fails.
//
// Otherwise, it logs the event and returns (false, nil).
func (g *Gaby) handleGitHubIssueCommentEvent(ctx context.Context, event *github.WebhookIssueCommentEvent, fl *gabyFlags) (handled bool, _ error) {
if event.Action != github.WebhookIssueCommentActionCreated {
g.slog.Info("ignoring GitHub issue comment event (action is not created)", "event", event, "action", event.Action)
return false, nil
}
g.slog.Info("handling GitHub issue comment", "event", event)
project := event.Repository.Project
if fl.enablesync {
if err := g.syncGitHubProject(ctx, project); err != nil {
return false, err
}
// Embeddings are not needed to apply fixes.
}
// Do not attempt changes unless sync is enabled and completely succeeded.
if fl.enablechanges && fl.enablesync {
if err := g.fixGitHubIssue(ctx, project, event.Issue.Number); err != nil {
return false, err
}
if err := g.spawnBisectionTask(ctx, event); err != nil {
return false, err
}
return true, nil
}
return false, nil
}
func (g *Gaby) fixGitHubIssue(ctx context.Context, project string, issue int64) error {
// No need to lock; [commentfix.Fixer.FixGitHubIssue] and
// [commentfix.Fixer.Run] can happen concurrently.
if err := g.commentFixer.LogFixGitHubIssue(ctx, project, issue); err != nil {
return err
}
if err := actions.Run(ctx, g.slog, g.db); err != nil {
return err
}
return nil
}
// syncGitHubProject syncs the document corpus with respect to a single
// GitHub project.
func (g *Gaby) syncGitHubProject(ctx context.Context, project string) error {
g.db.Lock(gabyGitHubSyncLock)
defer g.db.Unlock(gabyGitHubSyncLock)
if err := g.github.SyncProject(ctx, project); err != nil {
return err
}
docs.Sync(g.docs, g.github)
return nil
}
// spawnBisectionTask checks if event is encoding a bisection task and,
// if so, it spawns the corresponding task.
func (g *Gaby) spawnBisectionTask(ctx context.Context, event *github.WebhookIssueCommentEvent) error {
// TODO: access comment through db instead
// of directly through the event?
breq, err := parseBisectTrigger(event)
if err != nil {
g.slog.Error("bisect.Request trigger fail", "body", event.Comment.Body, "err", err)
return err
}
if breq == nil {
g.slog.Info("bisect.Request no trigger", "body", event.Comment.Body)
return nil
}
// Check the user only after we established that
// the comment encodes a bisection request, to
// save time on pinging firestore.
user := event.Comment.User.Login
if ok, err := userAllowedBisection(ctx, user); err != nil {
g.slog.Error("bisect.Request permission check", "err", err)
return err
} else if !ok {
g.slog.Info("bisect.Request permission denied", "user", user)
return nil
}
if err := g.bisect.BisectAsync(ctx, breq); err != nil {
return err
}
g.slog.Info("bisect.Request trigger success", "req", fmt.Sprintf("%+v", breq))
return nil
}
// userAllowedBisection checks if the author of the
// comment event is allowed to spawn a bisection.
func userAllowedBisection(ctx context.Context, user string) (bool, error) {
fc, err := firestore.NewClient(ctx, flags.project)
if err != nil {
return false, err
}
doc := fc.Collection("auth").Doc("bisect-github-users")
sn, err := doc.Get(ctx)
if err != nil {
return false, err
}
var users map[string]bool
if err := sn.DataTo(&users); err != nil {
return false, err
}
return users[user], nil
}