github.com/reviewdog/reviewdog@v0.17.5-0.20240516205324-0cd103a83d58/doghouse/server/doghouse.go (about) 1 package server 2 3 import ( 4 "bytes" 5 "context" 6 "fmt" 7 "net/http" 8 "strings" 9 "time" 10 11 "github.com/google/go-github/v60/github" 12 13 "github.com/reviewdog/reviewdog/diff" 14 "github.com/reviewdog/reviewdog/doghouse" 15 "github.com/reviewdog/reviewdog/filter" 16 "github.com/reviewdog/reviewdog/proto/rdf" 17 "github.com/reviewdog/reviewdog/service/github/githubutils" 18 ) 19 20 // GitHub check runs API cannot handle too large requests. 21 // Set max number of filtered findings to be shown in check-run summary. 22 // ERROR: 23 // 24 // https://api.github.com/repos/easymotion/vim-easymotion/check-runs: 422 25 // Invalid request. 26 // Only 65535 characters are allowed; 250684 were supplied. [] 27 const maxAllowedSize = 65535 28 29 // > The Checks API limits the number of annotations to a maximum of 50 per API 30 // > request. 31 // https://developer.github.com/v3/checks/runs/#output-object 32 const maxAnnotationsPerRequest = 50 33 34 type Checker struct { 35 req *doghouse.CheckRequest 36 gh checkerGitHubClientInterface 37 } 38 39 func NewChecker(req *doghouse.CheckRequest, gh *github.Client) *Checker { 40 return &Checker{req: req, gh: &checkerGitHubClient{Client: gh}} 41 } 42 43 func (ch *Checker) Check(ctx context.Context, makeRequest bool) (*doghouse.CheckResponse, error) { 44 var filediffs []*diff.FileDiff 45 if ch.req.PullRequest != 0 { 46 var err error 47 filediffs, err = ch.pullRequestDiff(ctx, ch.req.PullRequest) 48 if err != nil { 49 return nil, fmt.Errorf("fail to parse diff: %w", err) 50 } 51 } 52 53 results := annotationsToDiagnostics(ch.req.Annotations) 54 filterMode := ch.req.FilterMode 55 //lint:ignore SA1019 Need to support OutsideDiff for backward compatibility. 56 if ch.req.PullRequest == 0 || ch.req.OutsideDiff { 57 // If it's not Pull Request run, do not filter results by diff regardless 58 // of the filter mode. 59 filterMode = filter.ModeNoFilter 60 } 61 filtered := filter.FilterCheck(results, filediffs, 1, "", filterMode) 62 63 // If we're not making a request, we can just return the filtered results. 64 if !makeRequest { 65 return &doghouse.CheckResponse{CheckedResults: filtered}, nil 66 } 67 68 var check *github.CheckRun 69 var err error 70 71 check, err = ch.createCheck(ctx) 72 if err != nil { 73 // If this error is StatusForbidden (403) here, it means reviewdog is 74 // running on GitHub Actions and has only read permission (because it's 75 // running for Pull Requests from forked repository). If the token itself 76 // is invalid, reviewdog should return an error earlier (e.g. when reading 77 // Pull Requests diff), so it should be ok not to return error here and 78 // return results instead. 79 if err, ok := err.(*github.ErrorResponse); ok && err.Response.StatusCode == http.StatusForbidden { 80 return &doghouse.CheckResponse{CheckedResults: filtered}, nil 81 } 82 return nil, fmt.Errorf("failed to create check: %w", err) 83 } 84 85 checkRun, conclusion, err := ch.postCheck(ctx, check.GetID(), filtered) 86 if err != nil { 87 return nil, fmt.Errorf("failed to post result: %w", err) 88 } 89 res := &doghouse.CheckResponse{ 90 ReportURL: checkRun.GetHTMLURL(), 91 Conclusion: conclusion, 92 } 93 return res, nil 94 } 95 96 func (ch *Checker) postCheck(ctx context.Context, checkID int64, checks []*filter.FilteredDiagnostic) (*github.CheckRun, string, error) { 97 var annotations []*github.CheckRunAnnotation 98 for _, c := range checks { 99 if !c.ShouldReport { 100 continue 101 } 102 annotations = append(annotations, ch.toCheckRunAnnotation(c)) 103 } 104 if len(annotations) > 0 { 105 if err := ch.postAnnotations(ctx, checkID, annotations); err != nil { 106 return nil, "", fmt.Errorf("failed to post annotations: %w", err) 107 } 108 } 109 110 conclusion := ch.conclusion(annotations) 111 opt := github.UpdateCheckRunOptions{ 112 Name: ch.checkName(), 113 Status: github.String("completed"), 114 Conclusion: github.String(conclusion), 115 CompletedAt: &github.Timestamp{Time: time.Now()}, 116 Output: &github.CheckRunOutput{ 117 Title: github.String(ch.checkTitle()), 118 Summary: github.String(ch.summary(checks)), 119 }, 120 } 121 checkRun, err := ch.gh.UpdateCheckRun(ctx, ch.req.Owner, ch.req.Repo, checkID, opt) 122 if err != nil { 123 return nil, "", err 124 } 125 return checkRun, conclusion, nil 126 } 127 128 func (ch *Checker) createCheck(ctx context.Context) (*github.CheckRun, error) { 129 opt := github.CreateCheckRunOptions{ 130 Name: ch.checkName(), 131 HeadSHA: ch.req.SHA, 132 Status: github.String("in_progress"), 133 } 134 return ch.gh.CreateCheckRun(ctx, ch.req.Owner, ch.req.Repo, opt) 135 } 136 137 func (ch *Checker) postAnnotations(ctx context.Context, checkID int64, annotations []*github.CheckRunAnnotation) error { 138 opt := github.UpdateCheckRunOptions{ 139 Name: ch.checkName(), 140 Output: &github.CheckRunOutput{ 141 Title: github.String(ch.checkTitle()), 142 Summary: github.String(""), // Post summary with the last request. 143 Annotations: annotations[:min(maxAnnotationsPerRequest, len(annotations))], 144 }, 145 } 146 if _, err := ch.gh.UpdateCheckRun(ctx, ch.req.Owner, ch.req.Repo, checkID, opt); err != nil { 147 return err 148 } 149 if len(annotations) > maxAnnotationsPerRequest { 150 return ch.postAnnotations(ctx, checkID, annotations[maxAnnotationsPerRequest:]) 151 } 152 return nil 153 } 154 155 func (ch *Checker) checkName() string { 156 if ch.req.Name != "" { 157 return ch.req.Name 158 } 159 return "reviewdog" 160 } 161 162 func (ch *Checker) checkTitle() string { 163 if name := ch.checkName(); name != "reviewdog" { 164 return fmt.Sprintf("reviewdog [%s] report", name) 165 } 166 return "reviewdog report" 167 } 168 169 // https://developer.github.com/v3/checks/runs/#parameters-1 170 func (ch *Checker) conclusion(annotations []*github.CheckRunAnnotation) string { 171 checkResult := "success" 172 173 if ch.req.Level != "" { 174 // Level takes precedence when configured (for backwards compatibility) 175 if len(annotations) == 0 { 176 return checkResult 177 } 178 switch strings.ToLower(ch.req.Level) { 179 case "info", "warning": 180 return "neutral" 181 } 182 return "failure" 183 } else { 184 precedence := map[string]int{ 185 "success": 0, 186 "notice": 1, 187 "warning": 2, 188 "failure": 3, 189 } 190 191 highestLevel := "success" 192 for _, a := range annotations { 193 annotationLevel := *a.AnnotationLevel 194 if precedence[annotationLevel] > precedence[highestLevel] { 195 highestLevel = annotationLevel 196 } 197 } 198 checkResult = highestLevel 199 } 200 201 switch checkResult { 202 case "success": 203 return "success" 204 case "notice", "warning": 205 return "neutral" 206 } 207 return "failure" 208 } 209 210 // https://developer.github.com/v3/checks/runs/#annotations-object 211 func (ch *Checker) annotationLevel(s rdf.Severity) string { 212 switch s { 213 case rdf.Severity_ERROR: 214 return "failure" 215 case rdf.Severity_WARNING: 216 return "warning" 217 case rdf.Severity_INFO: 218 return "notice" 219 default: 220 return ch.reqAnnotationLevel() 221 } 222 } 223 224 func (ch *Checker) reqAnnotationLevel() string { 225 switch strings.ToLower(ch.req.Level) { 226 case "info": 227 return "notice" 228 case "warning": 229 return "warning" 230 case "failure": 231 return "failure" 232 } 233 return "failure" 234 } 235 236 func (ch *Checker) summary(checks []*filter.FilteredDiagnostic) string { 237 var lines []string 238 var usedBytes int 239 lines = append(lines, "reported by [reviewdog](https://github.com/reviewdog/reviewdog) :dog:") 240 usedBytes += len(lines[0]) + 1 241 var findings []*filter.FilteredDiagnostic 242 var filteredFindings []*filter.FilteredDiagnostic 243 for _, c := range checks { 244 if c.ShouldReport { 245 findings = append(findings, c) 246 } else { 247 filteredFindings = append(filteredFindings, c) 248 } 249 } 250 251 findingMsgs, usedBytes := ch.summaryFindings("Findings", usedBytes, findings) 252 lines = append(lines, findingMsgs...) 253 filteredFindingsMsgs, _ := ch.summaryFindings("Filtered Findings", usedBytes, filteredFindings) 254 lines = append(lines, filteredFindingsMsgs...) 255 return strings.Join(lines, "\n") 256 } 257 258 func (ch *Checker) summaryFindings(name string, usedBytes int, checks []*filter.FilteredDiagnostic) ([]string, int) { 259 var lines []string 260 lines = append(lines, fmt.Sprintf("<details>\n<summary>%s (%d)</summary>\n", name, len(checks))) 261 if len(lines[0])+1+usedBytes > maxAllowedSize { 262 // bail out if we're already over the limit 263 return nil, usedBytes 264 } 265 usedBytes += len(lines[0]) + 1 266 for _, c := range checks { 267 nextLine := githubutils.LinkedMarkdownDiagnostic(ch.req.Owner, ch.req.Repo, ch.req.SHA, c.Diagnostic) 268 // existing lines + newline + closing details tag must be smaller than the max allowed size 269 if usedBytes+len(nextLine)+1+10 >= maxAllowedSize { 270 cutoffMsg := "... (Too many findings. Dropped some findings)" 271 if usedBytes+len(cutoffMsg)+1+10 <= maxAllowedSize { 272 lines = append(lines, cutoffMsg) 273 usedBytes += len(cutoffMsg) + 1 274 } 275 break 276 } 277 lines = append(lines, nextLine) 278 usedBytes += len(nextLine) + 1 279 } 280 lines = append(lines, "</details>") 281 usedBytes += 10 + 1 282 return lines, usedBytes 283 } 284 285 func (ch *Checker) toCheckRunAnnotation(c *filter.FilteredDiagnostic) *github.CheckRunAnnotation { 286 loc := c.Diagnostic.GetLocation() 287 startLine := int(loc.GetRange().GetStart().GetLine()) 288 endLine := int(loc.GetRange().GetEnd().GetLine()) 289 if endLine == 0 { 290 endLine = startLine 291 } 292 a := &github.CheckRunAnnotation{ 293 Path: github.String(loc.GetPath()), 294 StartLine: github.Int(startLine), 295 EndLine: github.Int(endLine), 296 AnnotationLevel: github.String(ch.annotationLevel(c.Diagnostic.Severity)), 297 Message: github.String(c.Diagnostic.GetMessage()), 298 Title: github.String(ch.buildTitle(c)), 299 } 300 // Annotations only support start_column and end_column on the same line. 301 if startLine == endLine { 302 if s, e := loc.GetRange().GetStart().GetColumn(), loc.GetRange().GetEnd().GetColumn(); s != 0 && e != 0 { 303 a.StartColumn = github.Int(int(s)) 304 a.EndColumn = github.Int(int(e)) 305 } 306 } 307 if s := c.Diagnostic.GetOriginalOutput(); s != "" { 308 a.RawDetails = github.String(s) 309 } 310 return a 311 } 312 313 func (ch *Checker) buildTitle(c *filter.FilteredDiagnostic) string { 314 var sb strings.Builder 315 toolName := c.Diagnostic.GetSource().GetName() 316 if toolName == "" { 317 toolName = ch.req.Name 318 } 319 if toolName != "" { 320 sb.WriteString(fmt.Sprintf("[%s] ", toolName)) 321 } 322 loc := c.Diagnostic.GetLocation() 323 sb.WriteString(loc.GetPath()) 324 if startLine := int(loc.GetRange().GetStart().GetLine()); startLine > 0 { 325 sb.WriteString(fmt.Sprintf("#L%d", startLine)) 326 if endLine := int(loc.GetRange().GetEnd().GetLine()); startLine < endLine { 327 sb.WriteString(fmt.Sprintf("-L%d", endLine)) 328 } 329 } 330 if code := c.Diagnostic.GetCode().GetValue(); code != "" { 331 if url := c.Diagnostic.GetCode().GetUrl(); url != "" { 332 sb.WriteString(fmt.Sprintf(" <%s>(%s)", code, url)) 333 } else { 334 sb.WriteString(fmt.Sprintf(" <%s>", code)) 335 } 336 } 337 return sb.String() 338 } 339 340 func (ch *Checker) pullRequestDiff(ctx context.Context, pr int) ([]*diff.FileDiff, error) { 341 d, err := ch.rawPullRequestDiff(ctx, pr) 342 if err != nil { 343 return nil, err 344 } 345 filediffs, err := diff.ParseMultiFile(bytes.NewReader(d)) 346 if err != nil { 347 return nil, fmt.Errorf("fail to parse diff: %w", err) 348 } 349 return filediffs, nil 350 } 351 352 func (ch *Checker) rawPullRequestDiff(ctx context.Context, pr int) ([]byte, error) { 353 d, err := ch.gh.GetPullRequestDiff(ctx, ch.req.Owner, ch.req.Repo, pr) 354 if err != nil { 355 return nil, err 356 } 357 return d, nil 358 } 359 360 func annotationsToDiagnostics(as []*doghouse.Annotation) []*rdf.Diagnostic { 361 ds := make([]*rdf.Diagnostic, 0, len(as)) 362 for _, a := range as { 363 ds = append(ds, annotationToDiagnostic(a)) 364 } 365 return ds 366 } 367 368 func annotationToDiagnostic(a *doghouse.Annotation) *rdf.Diagnostic { 369 if a.Diagnostic != nil { 370 return a.Diagnostic 371 } 372 // Old reviewdog CLI doesn't have the Diagnostic field. 373 return &rdf.Diagnostic{ 374 Location: &rdf.Location{ 375 //lint:ignore SA1019 use deprecated fields because of backward compatibility. 376 Path: a.Path, 377 Range: &rdf.Range{ 378 Start: &rdf.Position{ 379 //lint:ignore SA1019 use deprecated fields because of backward compatibility. 380 Line: int32(a.Line), 381 }, 382 }, 383 }, 384 //lint:ignore SA1019 use deprecated fields because of backward compatibility. 385 Message: a.Message, 386 //lint:ignore SA1019 use deprecated fields because of backward compatibility. 387 OriginalOutput: a.RawMessage, 388 } 389 }