github.com/friedemannf/reviewdog@v0.14.0/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/v37/github" 12 13 "github.com/friedemannf/reviewdog/diff" 14 "github.com/friedemannf/reviewdog/doghouse" 15 "github.com/friedemannf/reviewdog/filter" 16 "github.com/friedemannf/reviewdog/proto/rdf" 17 "github.com/friedemannf/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 maxFilteredFinding = 150 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 lines = append(lines, "reported by [reviewdog](https://github.com/friedemannf/reviewdog) :dog:") 200 201 var findings []*filter.FilteredDiagnostic 202 var filteredFindings []*filter.FilteredDiagnostic 203 for _, c := range checks { 204 if c.ShouldReport { 205 findings = append(findings, c) 206 } else { 207 filteredFindings = append(filteredFindings, c) 208 } 209 } 210 lines = append(lines, ch.summaryFindings("Findings", findings)...) 211 lines = append(lines, ch.summaryFindings("Filtered Findings", filteredFindings)...) 212 213 return strings.Join(lines, "\n") 214 } 215 216 func (ch *Checker) summaryFindings(name string, checks []*filter.FilteredDiagnostic) []string { 217 var lines []string 218 lines = append(lines, "<details>") 219 lines = append(lines, fmt.Sprintf("<summary>%s (%d)</summary>", name, len(checks))) 220 lines = append(lines, "") 221 for i, c := range checks { 222 if i >= maxFilteredFinding { 223 lines = append(lines, "... (Too many findings. Dropped some findings)") 224 break 225 } 226 lines = append(lines, githubutils.LinkedMarkdownDiagnostic( 227 ch.req.Owner, ch.req.Repo, ch.req.SHA, c.Diagnostic)) 228 } 229 lines = append(lines, "</details>") 230 return lines 231 } 232 233 func (ch *Checker) toCheckRunAnnotation(c *filter.FilteredDiagnostic) *github.CheckRunAnnotation { 234 loc := c.Diagnostic.GetLocation() 235 startLine := int(loc.GetRange().GetStart().GetLine()) 236 endLine := int(loc.GetRange().GetEnd().GetLine()) 237 if endLine == 0 { 238 endLine = startLine 239 } 240 a := &github.CheckRunAnnotation{ 241 Path: github.String(loc.GetPath()), 242 StartLine: github.Int(startLine), 243 EndLine: github.Int(endLine), 244 AnnotationLevel: github.String(ch.annotationLevel(c.Diagnostic.Severity)), 245 Message: github.String(c.Diagnostic.GetMessage()), 246 Title: github.String(ch.buildTitle(c)), 247 } 248 // Annotations only support start_column and end_column on the same line. 249 if startLine == endLine { 250 if s, e := loc.GetRange().GetStart().GetColumn(), loc.GetRange().GetEnd().GetColumn(); s != 0 && e != 0 { 251 a.StartColumn = github.Int(int(s)) 252 a.EndColumn = github.Int(int(e)) 253 } 254 } 255 if s := c.Diagnostic.GetOriginalOutput(); s != "" { 256 a.RawDetails = github.String(s) 257 } 258 return a 259 } 260 261 func (ch *Checker) buildTitle(c *filter.FilteredDiagnostic) string { 262 var sb strings.Builder 263 toolName := c.Diagnostic.GetSource().GetName() 264 if toolName == "" { 265 toolName = ch.req.Name 266 } 267 if toolName != "" { 268 sb.WriteString(fmt.Sprintf("[%s] ", toolName)) 269 } 270 loc := c.Diagnostic.GetLocation() 271 sb.WriteString(loc.GetPath()) 272 if startLine := int(loc.GetRange().GetStart().GetLine()); startLine > 0 { 273 sb.WriteString(fmt.Sprintf("#L%d", startLine)) 274 if endLine := int(loc.GetRange().GetEnd().GetLine()); startLine < endLine { 275 sb.WriteString(fmt.Sprintf("-L%d", endLine)) 276 } 277 } 278 if code := c.Diagnostic.GetCode().GetValue(); code != "" { 279 if url := c.Diagnostic.GetCode().GetUrl(); url != "" { 280 sb.WriteString(fmt.Sprintf(" <%s>(%s)", code, url)) 281 } else { 282 sb.WriteString(fmt.Sprintf(" <%s>", code)) 283 } 284 } 285 return sb.String() 286 } 287 288 func (ch *Checker) pullRequestDiff(ctx context.Context, pr int) ([]*diff.FileDiff, error) { 289 d, err := ch.rawPullRequestDiff(ctx, pr) 290 if err != nil { 291 return nil, err 292 } 293 filediffs, err := diff.ParseMultiFile(bytes.NewReader(d)) 294 if err != nil { 295 return nil, fmt.Errorf("fail to parse diff: %w", err) 296 } 297 return filediffs, nil 298 } 299 300 func (ch *Checker) rawPullRequestDiff(ctx context.Context, pr int) ([]byte, error) { 301 d, err := ch.gh.GetPullRequestDiff(ctx, ch.req.Owner, ch.req.Repo, pr) 302 if err != nil { 303 return nil, err 304 } 305 return d, nil 306 } 307 308 func annotationsToDiagnostics(as []*doghouse.Annotation) []*rdf.Diagnostic { 309 ds := make([]*rdf.Diagnostic, 0, len(as)) 310 for _, a := range as { 311 ds = append(ds, annotationToDiagnostic(a)) 312 } 313 return ds 314 } 315 316 func annotationToDiagnostic(a *doghouse.Annotation) *rdf.Diagnostic { 317 if a.Diagnostic != nil { 318 return a.Diagnostic 319 } 320 // Old reviwedog CLI doesn't have the Diagnostic field. 321 return &rdf.Diagnostic{ 322 Location: &rdf.Location{ 323 Path: a.Path, 324 Range: &rdf.Range{ 325 Start: &rdf.Position{ 326 Line: int32(a.Line), 327 }, 328 }, 329 }, 330 Message: a.Message, 331 OriginalOutput: a.RawMessage, 332 } 333 } 334 335 func min(x, y int) int { 336 if x > y { 337 return y 338 } 339 return x 340 }