cloudflare/pint

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v0.63.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

internal/reporter/github.go

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