| // 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 |
| } |