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