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 }