cloudflare/pint

Public

mirrored from https://github.com/cloudflare/pintAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.66.1

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

internal/reporter/github.go

440lines · modecode

1package reporter
2
3import (
4 "bytes"
5 "context"
6 "fmt"
7 "log/slog"
8 "strconv"
9 "strings"
10 "time"
11
12 "github.com/google/go-github/v63/github"
13 "golang.org/x/oauth2"
14
15 "github.com/cloudflare/pint/internal/checks"
16 "github.com/cloudflare/pint/internal/output"
17)
18
19var reviewBody = "### This pull request was validated by [pint](https://github.com/cloudflare/pint).\n"
20
21type GithubReporter struct {
22 headCommit string
23
24 client *github.Client
25 version string
26 baseURL string
27 uploadURL string
28 authToken string
29 owner string
30 repo string
31 timeout time.Duration
32 prNum int
33 maxComments int
34}
35
36type ghCommentMeta struct {
37 id int64
38}
39
40type ghPR struct {
41 files []*github.CommitFile
42}
43
44func (pr ghPR) String() string {
45 return fmt.Sprintf("%d file(s)", len(pr.files))
46}
47
48func (pr ghPR) getFile(path string) *github.CommitFile {
49 for _, f := range pr.files {
50 if f.GetFilename() == path {
51 return f
52 }
53 }
54 return nil
55}
56
57// NewGithubReporter creates a new GitHub reporter that reports
58// problems via comments on a given pull request number (integer).
59func NewGithubReporter(version, baseURL, uploadURL string, timeout time.Duration, token, owner, repo string, prNum, maxComments int, headCommit string) (_ GithubReporter, err error) {
60 slog.Info(
61 "Will report problems to GitHub",
62 slog.String("baseURL", baseURL),
63 slog.String("uploadURL", uploadURL),
64 slog.String("timeout", output.HumanizeDuration(timeout)),
65 slog.String("owner", owner),
66 slog.String("repo", repo),
67 slog.Int("pr", prNum),
68 slog.Int("maxComments", maxComments),
69 slog.String("headCommit", headCommit),
70 )
71 gr := GithubReporter{
72 version: version,
73 baseURL: baseURL,
74 uploadURL: uploadURL,
75 timeout: timeout,
76 authToken: token,
77 owner: owner,
78 repo: repo,
79 prNum: prNum,
80 maxComments: maxComments,
81 headCommit: headCommit,
82 }
83
84 ts := oauth2.StaticTokenSource(
85 &oauth2.Token{AccessToken: gr.authToken},
86 )
87 tc := oauth2.NewClient(context.Background(), ts)
88 gr.client = github.NewClient(tc)
89
90 if gr.uploadURL != "" && gr.baseURL != "" {
91 gr.client, err = gr.client.WithEnterpriseURLs(gr.baseURL, gr.uploadURL)
92 if err != nil {
93 return gr, fmt.Errorf("creating new GitHub client: %w", err)
94 }
95 }
96
97 return gr, nil
98}
99
100func (gr GithubReporter) Describe() string {
101 return "GitHub"
102}
103
104func (gr GithubReporter) Destinations(ctx context.Context) (_ []any, err error) {
105 pr := ghPR{}
106 pr.files, err = gr.listPRFiles(ctx)
107 return []any{pr}, err
108}
109
110func (gr GithubReporter) Summary(ctx context.Context, _ any, s Summary, errs []error) error {
111 review, err := gr.findExistingReview(ctx)
112 if err != nil {
113 return fmt.Errorf("failed to list pull request reviews: %w", err)
114 }
115 if review != nil {
116 if err = gr.updateReview(ctx, review, s); err != nil {
117 return fmt.Errorf("failed to update pull request review: %w", err)
118 }
119 } else {
120 if err = gr.createReview(ctx, s); err != nil {
121 return fmt.Errorf("failed to create pull request review: %w", err)
122 }
123 }
124
125 if gr.maxComments > 0 && len(s.reports) > gr.maxComments {
126 if err = gr.generalComment(ctx, tooManyCommentsMsg(len(s.reports), gr.maxComments)); err != nil {
127 errs = append(errs, fmt.Errorf("failed to create general comment: %w", err))
128 }
129 }
130 if len(errs) > 0 {
131 if err = gr.generalComment(ctx, errsToComment(errs)); err != nil {
132 return fmt.Errorf("failed to create general comment: %w", err)
133 }
134 }
135
136 return nil
137}
138
139func (gr GithubReporter) List(ctx context.Context, _ any) ([]ExistingComment, error) {
140 reqCtx, cancel := gr.reqContext(ctx)
141 defer cancel()
142
143 slog.Debug("Getting the list of pull request comments", slog.Int("pr", gr.prNum))
144 existing, _, err := gr.client.PullRequests.ListComments(reqCtx, gr.owner, gr.repo, gr.prNum, nil)
145 if err != nil {
146 return nil, fmt.Errorf("failed to list pull request reviews: %w", err)
147 }
148
149 comments := make([]ExistingComment, 0, len(existing))
150 for _, ec := range existing {
151 if ec.GetPath() == "" {
152 slog.Debug("Skipping general comment", slog.Int64("id", ec.GetID()))
153 continue
154 }
155 comments = append(comments, ExistingComment{
156 path: ec.GetPath(),
157 text: ec.GetBody(),
158 line: ec.GetLine(),
159 meta: ghCommentMeta{id: ec.GetID()},
160 })
161 }
162
163 return comments, nil
164}
165
166func (gr GithubReporter) Create(ctx context.Context, dst any, p PendingComment) error {
167 pr := dst.(ghPR)
168
169 file := pr.getFile(p.path)
170 if file == nil {
171 slog.Debug("Skipping report for path with no changes",
172 slog.String("path", p.path),
173 )
174 return nil
175 }
176
177 diffs := parseDiffLines(file.GetPatch())
178 if len(diffs) == 0 {
179 slog.Debug("Skipping report for path with no diff",
180 slog.String("path", p.path),
181 )
182 return nil
183 }
184
185 side, line := gr.fixCommentLine(dst, p)
186
187 comment := &github.PullRequestComment{
188 CommitID: github.String(gr.headCommit),
189 Path: github.String(p.path),
190 Body: github.String(p.text),
191 Line: github.Int(line),
192 Side: github.String(side),
193 }
194
195 slog.Debug("Creating a pr comment",
196 slog.String("commit", comment.GetCommitID()),
197 slog.String("path", comment.GetPath()),
198 slog.Int("line", comment.GetLine()),
199 slog.String("side", comment.GetSide()),
200 slog.String("body", comment.GetBody()),
201 )
202
203 reqCtx, cancel := gr.reqContext(ctx)
204 defer cancel()
205
206 _, _, err := gr.client.PullRequests.CreateComment(reqCtx, gr.owner, gr.repo, gr.prNum, comment)
207 return err
208}
209
210func (gr GithubReporter) Delete(_ context.Context, _ any, _ ExistingComment) error {
211 return nil
212}
213
214func (gr GithubReporter) CanDelete(ExistingComment) bool {
215 return false
216}
217
218func (gr GithubReporter) CanCreate(done int) bool {
219 return done < gr.maxComments
220}
221
222func (gr GithubReporter) IsEqual(dst any, existing ExistingComment, pending PendingComment) bool {
223 if existing.path != pending.path {
224 return false
225 }
226 _, line := gr.fixCommentLine(dst, pending)
227 if existing.line != line {
228 return false
229 }
230 return strings.Trim(existing.text, "\n") == strings.Trim(pending.text, "\n")
231}
232
233func (gr GithubReporter) findExistingReview(ctx context.Context) (*github.PullRequestReview, error) {
234 reqCtx, cancel := gr.reqContext(ctx)
235 defer cancel()
236
237 reviews, _, err := gr.client.PullRequests.ListReviews(reqCtx, gr.owner, gr.repo, gr.prNum, nil)
238 if err != nil {
239 return nil, err
240 }
241
242 for _, review := range reviews {
243 if strings.HasPrefix(review.GetBody(), reviewBody) {
244 return review, nil
245 }
246 }
247
248 return nil, nil
249}
250
251func (gr GithubReporter) updateReview(ctx context.Context, review *github.PullRequestReview, summary Summary) error {
252 slog.Info("Updating pull request review", slog.String("repo", fmt.Sprintf("%s/%s", gr.owner, gr.repo)))
253
254 reqCtx, cancel := gr.reqContext(ctx)
255 defer cancel()
256
257 _, _, err := gr.client.PullRequests.UpdateReview(
258 reqCtx,
259 gr.owner,
260 gr.repo,
261 gr.prNum,
262 review.GetID(),
263 formatGHReviewBody(gr.version, summary),
264 )
265 return err
266}
267
268func (gr GithubReporter) createReview(ctx context.Context, summary Summary) error {
269 slog.Info("Creating pull request review", slog.String("repo", fmt.Sprintf("%s/%s", gr.owner, gr.repo)), slog.String("commit", gr.headCommit))
270
271 reqCtx, cancel := gr.reqContext(ctx)
272 defer cancel()
273
274 review := github.PullRequestReviewRequest{
275 CommitID: github.String(gr.headCommit),
276 Body: github.String(formatGHReviewBody(gr.version, summary)),
277 Event: github.String("COMMENT"),
278 }
279 slog.Debug("Creating a review",
280 slog.String("commit", review.GetCommitID()),
281 slog.String("body", review.GetBody()),
282 )
283 _, resp, err := gr.client.PullRequests.CreateReview(
284 reqCtx,
285 gr.owner,
286 gr.repo,
287 gr.prNum,
288 &review,
289 )
290 if err != nil {
291 return err
292 }
293 slog.Info("Pull request review created", slog.String("status", resp.Status))
294 return nil
295}
296
297func (gr GithubReporter) listPRFiles(ctx context.Context) ([]*github.CommitFile, error) {
298 reqCtx, cancel := gr.reqContext(ctx)
299 defer cancel()
300
301 slog.Debug("Getting the list of modified files", slog.Int("pr", gr.prNum))
302 files, _, err := gr.client.PullRequests.ListFiles(reqCtx, gr.owner, gr.repo, gr.prNum, nil)
303 if err != nil {
304 return nil, fmt.Errorf("failed to list pull request files: %w", err)
305 }
306 return files, nil
307}
308
309func formatGHReviewBody(version string, summary Summary) string {
310 var b strings.Builder
311
312 b.WriteString(reviewBody)
313
314 bySeverity := summary.CountBySeverity()
315 if len(bySeverity) > 0 {
316 b.WriteString(":heavy_exclamation_mark: Problems found.\n")
317 b.WriteString("| Severity | Number of problems |\n")
318 b.WriteString("| --- | --- |\n")
319
320 for _, s := range []checks.Severity{checks.Fatal, checks.Bug, checks.Warning, checks.Information} {
321 if bySeverity[s] > 0 {
322 b.WriteString("| ")
323 b.WriteString(s.String())
324 b.WriteString(" | ")
325 b.WriteString(strconv.Itoa(bySeverity[s]))
326 b.WriteString(" |\n")
327 }
328 }
329 } else {
330 b.WriteString(":heavy_check_mark: No problems found\n")
331 }
332
333 b.WriteString("<details><summary>Stats</summary>\n<p>\n\n")
334 b.WriteString("| Stat | Value |\n")
335 b.WriteString("| --- | --- |\n")
336
337 b.WriteString("| Version | ")
338 b.WriteString(version)
339 b.WriteString(" |\n")
340
341 b.WriteString("| Number of rules parsed | ")
342 b.WriteString(strconv.Itoa(summary.TotalEntries))
343 b.WriteString(" |\n")
344
345 b.WriteString("| Number of rules checked | ")
346 b.WriteString(strconv.FormatInt(summary.CheckedEntries, 10))
347 b.WriteString(" |\n")
348
349 b.WriteString("| Number of problems found | ")
350 b.WriteString(strconv.Itoa(len(summary.Reports())))
351 b.WriteString(" |\n")
352
353 b.WriteString("| Number of offline checks | ")
354 b.WriteString(strconv.FormatInt(summary.OfflineChecks, 10))
355 b.WriteString(" |\n")
356
357 b.WriteString("| Number of online checks | ")
358 b.WriteString(strconv.FormatInt(summary.OnlineChecks, 10))
359 b.WriteString(" |\n")
360
361 b.WriteString("| Checks duration | ")
362 b.WriteString(output.HumanizeDuration(summary.Duration))
363 b.WriteString(" |\n")
364
365 b.WriteString("\n</p>\n</details>\n\n")
366
367 b.WriteString("<details><summary>Problems</summary>\n<p>\n\n")
368 if len(summary.Reports()) > 0 {
369 buf := bytes.NewBuffer(nil)
370 cr := NewConsoleReporter(buf, checks.Information)
371 err := cr.Submit(summary)
372 if err != nil {
373 b.WriteString(fmt.Sprintf("Failed to generate list of problems: %s", err))
374 } else {
375 b.WriteString("```\n")
376 b.WriteString(buf.String())
377 b.WriteString("```\n")
378 }
379 } else {
380 b.WriteString("No problems reported")
381 }
382 b.WriteString("\n</p>\n</details>\n\n")
383
384 return b.String()
385}
386
387func (gr GithubReporter) generalComment(ctx context.Context, body string) error {
388 comment := github.IssueComment{
389 Body: github.String(body),
390 }
391
392 slog.Debug("Creating PR comment", slog.String("body", comment.GetBody()))
393
394 reqCtx, cancel := gr.reqContext(ctx)
395 defer cancel()
396
397 _, _, err := gr.client.Issues.CreateComment(reqCtx, gr.owner, gr.repo, gr.prNum, &comment)
398 return err
399}
400
401func (gr GithubReporter) reqContext(ctx context.Context) (context.Context, context.CancelFunc) {
402 return context.WithTimeout(context.WithValue(ctx, github.SleepUntilPrimaryRateLimitResetWhenRateLimited, true), gr.timeout)
403}
404
405func (gr GithubReporter) fixCommentLine(dst any, p PendingComment) (string, int) {
406 pr := dst.(ghPR)
407 file := pr.getFile(p.path)
408
409 var side string
410 if p.anchor == checks.AnchorBefore {
411 side = "LEFT"
412 } else {
413 side = "RIGHT"
414 }
415
416 line := p.line
417 diffs := parseDiffLines(file.GetPatch())
418 dl, ok := diffLineFor(diffs, p.line)
419 switch {
420 case ok && dl.wasModified && p.anchor == checks.AnchorAfter:
421 // Comment on new or modified line.
422 line = dl.new
423 case ok && dl.wasModified && p.anchor == checks.AnchorBefore:
424 // Comment on new or modified line.
425 line = dl.old
426 default:
427 // Comment on unmodified line.
428 // Find first modified line and put it there.
429 for _, d := range diffs {
430 if !d.wasModified {
431 continue
432 }
433 line = d.new
434 side = "RIGHT"
435 break
436 }
437 }
438
439 return side, line
440}
441