cloudflare/pint

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.65.1

Branches

Tags

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

Clone

HTTPS

Download ZIP

internal/reporter/github.go

390lines · modecode

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