cloudflare/pint

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.62.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

internal/reporter/github.go

352lines · 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/v62/github"
13 "golang.org/x/oauth2"
14
15 "github.com/cloudflare/pint/internal/checks"
16 "github.com/cloudflare/pint/internal/git"
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 gitCmd git.CommandRunner
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
37// NewGithubReporter creates a new GitHub reporter that reports
38// problems via comments on a given pull request number (integer).
39func NewGithubReporter(version, baseURL, uploadURL string, timeout time.Duration, token, owner, repo string, prNum, maxComments int, gitCmd git.CommandRunner) (_ GithubReporter, err error) {
40 slog.Info(
41 "Will report problems to GitHub",
42 slog.String("baseURL", baseURL),
43 slog.String("uploadURL", uploadURL),
44 slog.String("timeout", output.HumanizeDuration(timeout)),
45 slog.String("owner", owner),
46 slog.String("repo", repo),
47 slog.Int("pr", prNum),
48 slog.Int("maxComments", maxComments),
49 )
50 gr := GithubReporter{
51 version: version,
52 baseURL: baseURL,
53 uploadURL: uploadURL,
54 timeout: timeout,
55 authToken: token,
56 owner: owner,
57 repo: repo,
58 prNum: prNum,
59 maxComments: maxComments,
60 gitCmd: gitCmd,
61 }
62
63 ts := oauth2.StaticTokenSource(
64 &oauth2.Token{AccessToken: gr.authToken},
65 )
66 tc := oauth2.NewClient(context.Background(), ts)
67 gr.client = github.NewClient(tc)
68
69 if gr.uploadURL != "" && gr.baseURL != "" {
70 gr.client, err = gr.client.WithEnterpriseURLs(gr.baseURL, gr.uploadURL)
71 if err != nil {
72 return gr, fmt.Errorf("creating new GitHub client: %w", err)
73 }
74 }
75
76 return gr, nil
77}
78
79// Submit submits the summary to GitHub.
80func (gr GithubReporter) Submit(summary Summary) error {
81 headCommit, err := git.HeadCommit(gr.gitCmd)
82 if err != nil {
83 return fmt.Errorf("failed to get HEAD commit: %w", err)
84 }
85 slog.Info("Got HEAD commit from git", slog.String("commit", headCommit))
86
87 review, err := gr.findExistingReview()
88 if err != nil {
89 return fmt.Errorf("failed to list pull request reviews: %w", err)
90 }
91 if review != nil {
92 if err = gr.updateReview(review, summary); err != nil {
93 return fmt.Errorf("failed to update pull request review: %w", err)
94 }
95 } else {
96 if err = gr.createReview(headCommit, summary); err != nil {
97 return fmt.Errorf("failed to create pull request review: %w", err)
98 }
99 }
100
101 return gr.addReviewComments(headCommit, summary)
102}
103
104func (gr GithubReporter) findExistingReview() (*github.PullRequestReview, error) {
105 ctx, cancel := context.WithTimeout(context.Background(), gr.timeout)
106 defer cancel()
107
108 reviews, _, err := gr.client.PullRequests.ListReviews(ctx, gr.owner, gr.repo, gr.prNum, nil)
109 if err != nil {
110 return nil, err
111 }
112
113 for _, review := range reviews {
114 if strings.HasPrefix(review.GetBody(), reviewBody) {
115 return review, nil
116 }
117 }
118
119 return nil, nil
120}
121
122func (gr GithubReporter) updateReview(review *github.PullRequestReview, summary Summary) error {
123 slog.Info("Updating pull request review", slog.String("repo", fmt.Sprintf("%s/%s", gr.owner, gr.repo)))
124
125 ctx, cancel := context.WithTimeout(context.Background(), gr.timeout)
126 defer cancel()
127
128 _, _, err := gr.client.PullRequests.UpdateReview(
129 ctx,
130 gr.owner,
131 gr.repo,
132 gr.prNum,
133 review.GetID(),
134 formatGHReviewBody(gr.version, summary),
135 )
136 return err
137}
138
139func (gr GithubReporter) addReviewComments(headCommit string, summary Summary) error {
140 slog.Info("Creating review comments")
141
142 existingComments, err := gr.getReviewComments()
143 if err != nil {
144 return err
145 }
146
147 var added int
148 for _, rep := range summary.Reports() {
149 comment := reportToGitHubComment(headCommit, rep)
150
151 var found bool
152 for _, ec := range existingComments {
153 if ec.GetBody() == comment.GetBody() &&
154 ec.GetCommitID() == comment.GetCommitID() &&
155 ec.GetLine() == comment.GetLine() {
156 found = true
157 break
158 }
159 }
160 if found {
161 slog.Debug("Comment already exist",
162 slog.String("path", comment.GetPath()),
163 slog.Int("line", comment.GetLine()),
164 slog.String("commit", comment.GetCommitID()),
165 slog.String("body", comment.GetBody()),
166 )
167 continue
168 }
169
170 if err := gr.createComment(comment); err != nil {
171 return err
172 }
173 added++
174
175 if added >= gr.maxComments {
176 return gr.tooManyComments(len(summary.Reports()))
177 }
178 }
179
180 return nil
181}
182
183func (gr GithubReporter) getReviewComments() ([]*github.PullRequestComment, error) {
184 ctx, cancel := context.WithTimeout(context.Background(), gr.timeout)
185 defer cancel()
186
187 comments, _, err := gr.client.PullRequests.ListComments(ctx, gr.owner, gr.repo, gr.prNum, nil)
188 return comments, err
189}
190
191func (gr GithubReporter) createComment(comment *github.PullRequestComment) error {
192 slog.Debug("Creating review comment", slog.String("body", comment.GetBody()), slog.String("commit", comment.GetCommitID()))
193
194 ctx, cancel := context.WithTimeout(context.Background(), gr.timeout)
195 defer cancel()
196
197 _, _, err := gr.client.PullRequests.CreateComment(ctx, gr.owner, gr.repo, gr.prNum, comment)
198 return err
199}
200
201func (gr GithubReporter) createReview(headCommit string, summary Summary) error {
202 slog.Info("Creating pull request review", slog.String("repo", fmt.Sprintf("%s/%s", gr.owner, gr.repo)), slog.String("commit", headCommit))
203
204 ctx, cancel := context.WithTimeout(context.Background(), gr.timeout)
205 defer cancel()
206
207 _, resp, err := gr.client.PullRequests.CreateReview(
208 ctx,
209 gr.owner,
210 gr.repo,
211 gr.prNum,
212 &github.PullRequestReviewRequest{
213 CommitID: github.String(headCommit),
214 Body: github.String(formatGHReviewBody(gr.version, summary)),
215 Event: github.String("COMMENT"),
216 },
217 )
218 if err != nil {
219 return err
220 }
221 slog.Info("Pull request review created", slog.String("status", resp.Status))
222 return nil
223}
224
225func formatGHReviewBody(version string, summary Summary) string {
226 var b strings.Builder
227
228 b.WriteString(reviewBody)
229
230 bySeverity := summary.CountBySeverity()
231 if len(bySeverity) > 0 {
232 b.WriteString(":heavy_exclamation_mark: Problems found.\n")
233 b.WriteString("| Severity | Number of problems |\n")
234 b.WriteString("| --- | --- |\n")
235
236 for _, s := range []checks.Severity{checks.Fatal, checks.Bug, checks.Warning, checks.Information} {
237 if bySeverity[s] > 0 {
238 b.WriteString("| ")
239 b.WriteString(s.String())
240 b.WriteString(" | ")
241 b.WriteString(strconv.Itoa(bySeverity[s]))
242 b.WriteString(" |\n")
243 }
244 }
245 } else {
246 b.WriteString(":heavy_check_mark: No problems found\n")
247 }
248
249 b.WriteString("<details><summary>Stats</summary>\n<p>\n\n")
250 b.WriteString("| Stat | Value |\n")
251 b.WriteString("| --- | --- |\n")
252
253 b.WriteString("| Version | ")
254 b.WriteString(version)
255 b.WriteString(" |\n")
256
257 b.WriteString("| Number of rules parsed | ")
258 b.WriteString(strconv.Itoa(summary.TotalEntries))
259 b.WriteString(" |\n")
260
261 b.WriteString("| Number of rules checked | ")
262 b.WriteString(strconv.FormatInt(summary.CheckedEntries, 10))
263 b.WriteString(" |\n")
264
265 b.WriteString("| Number of problems found | ")
266 b.WriteString(strconv.Itoa(len(summary.Reports())))
267 b.WriteString(" |\n")
268
269 b.WriteString("| Number of offline checks | ")
270 b.WriteString(strconv.FormatInt(summary.OfflineChecks, 10))
271 b.WriteString(" |\n")
272
273 b.WriteString("| Number of online checks | ")
274 b.WriteString(strconv.FormatInt(summary.OnlineChecks, 10))
275 b.WriteString(" |\n")
276
277 b.WriteString("| Checks duration | ")
278 b.WriteString(output.HumanizeDuration(summary.Duration))
279 b.WriteString(" |\n")
280
281 b.WriteString("\n</p>\n</details>\n\n")
282
283 b.WriteString("<details><summary>Problems</summary>\n<p>\n\n")
284 if len(summary.Reports()) > 0 {
285 buf := bytes.NewBuffer(nil)
286 cr := NewConsoleReporter(buf, checks.Information)
287 err := cr.Submit(summary)
288 if err != nil {
289 b.WriteString(fmt.Sprintf("Failed to generate list of problems: %s", err))
290 } else {
291 b.WriteString("```\n")
292 b.WriteString(buf.String())
293 b.WriteString("```\n")
294 }
295 } else {
296 b.WriteString("No problems reported")
297 }
298 b.WriteString("\n</p>\n</details>\n\n")
299
300 return b.String()
301}
302
303func reportToGitHubComment(headCommit string, rep Report) *github.PullRequestComment {
304 var msgPrefix, msgSuffix string
305 reportLine, srcLine := moveReportedLine(rep)
306 if reportLine != srcLine {
307 msgPrefix = fmt.Sprintf("Problem reported on unmodified line %d, comment moved here: ", srcLine)
308 }
309 if rep.Problem.Details != "" {
310 msgSuffix = "\n\n" + rep.Problem.Details
311 }
312
313 var side string
314 if rep.Problem.Anchor == checks.AnchorBefore {
315 side = "LEFT"
316 } else {
317 side = "RIGHT"
318 }
319
320 c := github.PullRequestComment{
321 CommitID: github.String(headCommit),
322 Path: github.String(rep.Path.SymlinkTarget),
323 Body: github.String(fmt.Sprintf(
324 "%s [%s](https://cloudflare.github.io/pint/checks/%s.html): %s%s%s",
325 problemIcon(rep.Problem.Severity),
326 rep.Problem.Reporter,
327 rep.Problem.Reporter,
328 msgPrefix,
329 rep.Problem.Text,
330 msgSuffix,
331 )),
332 Line: github.Int(reportLine),
333 Side: github.String(side),
334 }
335
336 return &c
337}
338
339func (gr GithubReporter) tooManyComments(nrComments int) error {
340 comment := github.IssueComment{
341 Body: github.String(fmt.Sprintf(`This pint run would create %d comment(s), which is more than %d limit configured for pint.
342%d comments were skipped and won't be visibile on this PR.`, nrComments, gr.maxComments, nrComments-gr.maxComments)),
343 }
344
345 slog.Debug("Creating PR comment", slog.String("body", comment.GetBody()))
346
347 ctx, cancel := context.WithTimeout(context.Background(), gr.timeout)
348 defer cancel()
349
350 _, _, err := gr.client.Issues.CreateComment(ctx, gr.owner, gr.repo, gr.prNum, &comment)
351 return err
352}
353