cloudflare/pint

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.56.1

Branches

Tags

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

Clone

HTTPS

Download ZIP

internal/reporter/github.go

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