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