github.com/mongodb/grip@v0.0.0-20240213223901-f906268d82b9/send/github.go (about)

     1  package send
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"log"
     7  	"net/http"
     8  	"os"
     9  	"time"
    10  
    11  	"github.com/evergreen-ci/utility"
    12  	"github.com/google/go-github/v53/github"
    13  	"github.com/mongodb/grip/message"
    14  	"github.com/pkg/errors"
    15  	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
    16  	"go.opentelemetry.io/otel/attribute"
    17  	"go.opentelemetry.io/otel/codes"
    18  	"go.opentelemetry.io/otel/trace"
    19  )
    20  
    21  const (
    22  	numGithubAttempts   = 3
    23  	githubRetryMinDelay = time.Second
    24  )
    25  
    26  const (
    27  	githubEndpointAttribute = "grip.github.endpoint"
    28  	githubOwnerAttribute    = "grip.github.owner"
    29  	githubRepoAttribute     = "grip.github.repo"
    30  	githubRefAttribute      = "grip.github.ref"
    31  	githubRetriesAttribute  = "grip.github.retries"
    32  )
    33  
    34  type githubLogger struct {
    35  	opts *GithubOptions
    36  	gh   githubClient
    37  
    38  	*Base
    39  }
    40  
    41  // GithubOptions contains information about a github account and
    42  // repository, used in the GithubIssuesLogger and the
    43  // GithubCommentLogger Sender implementations.
    44  type GithubOptions struct {
    45  	Account     string
    46  	Repo        string
    47  	Token       string
    48  	MaxAttempts int
    49  	MinDelay    time.Duration
    50  }
    51  
    52  func (o *GithubOptions) populate() {
    53  	if o.MaxAttempts <= 0 {
    54  		o.MaxAttempts = numGithubAttempts
    55  	}
    56  
    57  	if o.MinDelay <= 0 {
    58  		o.MinDelay = githubRetryMinDelay
    59  	}
    60  
    61  	const floor = 100 * time.Millisecond
    62  	if o.MinDelay < floor {
    63  		o.MinDelay = floor
    64  	}
    65  }
    66  
    67  // NewGithubIssuesLogger builds a sender implementation that creates a
    68  // new issue in a Github Project for each log message.
    69  func NewGithubIssuesLogger(name string, opts *GithubOptions) (Sender, error) {
    70  	opts.populate()
    71  	s := &githubLogger{
    72  		Base: NewBase(name),
    73  		opts: opts,
    74  		gh:   &githubClientImpl{},
    75  	}
    76  
    77  	s.gh.Init(opts.Token, opts.MaxAttempts, opts.MinDelay)
    78  
    79  	fallback := log.New(os.Stdout, "", log.LstdFlags)
    80  	if err := s.SetErrorHandler(ErrorHandlerFromLogger(fallback)); err != nil {
    81  		return nil, err
    82  	}
    83  
    84  	if err := s.SetFormatter(MakeDefaultFormatter()); err != nil {
    85  		return nil, err
    86  	}
    87  
    88  	s.reset = func() {
    89  		fallback.SetPrefix(fmt.Sprintf("[%s] [%s/%s] ", s.Name(), opts.Account, opts.Repo))
    90  	}
    91  
    92  	return s, nil
    93  }
    94  
    95  func (s *githubLogger) Send(m message.Composer) {
    96  	if s.Level().ShouldLog(m) {
    97  		text, err := s.formatter(m)
    98  		if err != nil {
    99  			s.ErrorHandler()(err, m)
   100  			return
   101  		}
   102  
   103  		title := fmt.Sprintf("[%s]: %s", s.Name(), m.String())
   104  		issue := &github.IssueRequest{
   105  			Title: &title,
   106  			Body:  &text,
   107  		}
   108  
   109  		ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
   110  		defer cancel()
   111  
   112  		ctx, span := tracer.Start(ctx, "CreateIssue", trace.WithAttributes(
   113  			attribute.String(githubEndpointAttribute, "CreateIssue"),
   114  			attribute.String(githubOwnerAttribute, s.opts.Account),
   115  			attribute.String(githubRepoAttribute, s.opts.Repo),
   116  		))
   117  		defer span.End()
   118  
   119  		if _, resp, err := s.gh.Create(ctx, s.opts.Account, s.opts.Repo, issue); err != nil {
   120  			s.ErrorHandler()(errors.Wrap(err, "sending GitHub create issue request"), m)
   121  
   122  			span.RecordError(err)
   123  			span.SetStatus(codes.Error, "creating issue")
   124  		} else if err = handleHTTPResponseError(resp.Response); err != nil {
   125  			s.ErrorHandler()(errors.Wrap(err, "creating GitHub issue"), m)
   126  
   127  			span.RecordError(err)
   128  			span.SetStatus(codes.Error, "creating issue")
   129  		}
   130  	}
   131  }
   132  
   133  func (s *githubLogger) Flush(_ context.Context) error { return nil }
   134  
   135  //////////////////////////////////////////////////////////////////////////
   136  //
   137  // interface wrapper for the github client so that we can mock things out
   138  //
   139  //////////////////////////////////////////////////////////////////////////
   140  
   141  type githubClient interface {
   142  	Init(token string, maxAttempts int, minDelay time.Duration)
   143  	// Issues
   144  	Create(context.Context, string, string, *github.IssueRequest) (*github.Issue, *github.Response, error)
   145  	CreateComment(context.Context, string, string, int, *github.IssueComment) (*github.IssueComment, *github.Response, error)
   146  
   147  	// Status API
   148  	CreateStatus(ctx context.Context, owner, repo, ref string, status *github.RepoStatus) (*github.RepoStatus, *github.Response, error)
   149  }
   150  
   151  type githubClientImpl struct {
   152  	*github.IssuesService
   153  	repos *github.RepositoriesService
   154  }
   155  
   156  func (c *githubClientImpl) Init(token string, maxAttempts int, minDelay time.Duration) {
   157  	client := utility.GetHTTPClient()
   158  	client.Transport = otelhttp.NewTransport(client.Transport)
   159  
   160  	client = utility.SetupOauth2CustomHTTPRetryableClient(
   161  		token,
   162  		githubShouldRetry(),
   163  		utility.RetryHTTPDelay(utility.RetryOptions{
   164  			MaxAttempts: maxAttempts,
   165  			MinDelay:    minDelay,
   166  		}),
   167  		client)
   168  	githubClient := github.NewClient(client)
   169  	c.IssuesService = githubClient.Issues
   170  	c.repos = githubClient.Repositories
   171  }
   172  
   173  func githubShouldRetry() utility.HTTPRetryFunction {
   174  	return func(index int, req *http.Request, resp *http.Response, err error) bool {
   175  		trace.SpanFromContext(req.Context()).SetAttributes(attribute.Int(githubRetriesAttribute, index))
   176  
   177  		if err != nil {
   178  			return utility.IsTemporaryError(err)
   179  		}
   180  
   181  		if resp == nil {
   182  			return true
   183  		}
   184  
   185  		if resp.StatusCode == http.StatusBadGateway {
   186  			return true
   187  		}
   188  
   189  		return false
   190  	}
   191  }
   192  
   193  func (c *githubClientImpl) CreateStatus(ctx context.Context, owner, repo, ref string, status *github.RepoStatus) (*github.RepoStatus, *github.Response, error) {
   194  	return c.repos.CreateStatus(ctx, owner, repo, ref, status)
   195  }