internal/platform/ext_bitbucket.go (194 lines of code) (raw):

/* * Copyright 2021-2024 JetBrains s.r.o. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package platform import ( "context" "fmt" "io" "net/http" "net/url" "os" "strings" "time" "github.com/JetBrains/qodana-cli/internal/platform/msg" "github.com/JetBrains/qodana-cli/internal/platform/qdenv" "github.com/JetBrains/qodana-cli/internal/sarif" bbapi "github.com/reviewdog/go-bitbucket" // adapted from https://raw.githubusercontent.com/reviewdog/reviewdog/master/LICENSE log "github.com/sirupsen/logrus" ) const ( httpTimeout = time.Second * 10 bitBucketAnnotationLimit = 1000 bitBucketReporter = "JetBrains Qodana" bitBucketAvatar = "https://avatars.githubusercontent.com/u/139879315" bitBucketReportFailed = "FAILED" bitBucketReportPassed = "PASSED" bitBucketReportType = "BUG" bitBucketAnnotationType = "CODE_SMELL" // https://developer.atlassian.com/cloud/bitbucket/rest/api-group-reports/#api-repositories-workspace-repo-slug-commit-commit-reports-reportid-annotations-annotationid-put-request bitBucketHigh = "HIGH" bitBucketMedium = "MEDIUM" bitBucketLow = "LOW" bitBucketInfo = "INFO" pipelineProxyURL = "http://localhost:29418" pipeProxyURL = "http://host.docker.internal:29418" ) // toBitBucketSeverity maps SARIF and Qodana severity levels to BitBucket severity levels, levels are mapped to the closest match https://www.jetbrains.com/help/qodana/qodana-sarif-output.html#SARIF+severity var ( toBitBucketSeverity = map[string]string{ sarifError: bitBucketHigh, sarifWarning: bitBucketMedium, sarifNote: bitBucketLow, qodanaCritical: bitBucketHigh, qodanaHigh: bitBucketHigh, qodanaModerate: bitBucketMedium, qodanaLow: bitBucketLow, qodanaInfo: bitBucketInfo, } ) // sendBitBucketReport sends annotations to BitBucket code Insights func sendBitBucketReport(annotations []bbapi.ReportAnnotation, toolName, cloudUrl, reportId string) error { client, ctx := getBitBucketClient(), getBitBucketContext() repoOwner, repoName, sha := qdenv.GetBitBucketRepoOwner(), qdenv.GetBitBucketRepoName(), qdenv.GetBitBucketCommit() _, resp, err := client. ReportsApi.CreateOrUpdateReport(ctx, repoOwner, repoName, sha, reportId). Body(buildReport(toolName, annotations, cloudUrl)). Execute() if err = checkBitBucketApiError(err, resp, http.StatusOK); err != nil { return fmt.Errorf("failed to create code insights report: %w", err) } totalAnnotations := len(annotations) if totalAnnotations != 0 { if totalAnnotations > bitBucketAnnotationLimit { totalAnnotations = bitBucketAnnotationLimit log.Debugf("Warning: Only first 1000 of %d annotations will be sent", len(annotations)) } for i := 0; i < totalAnnotations; i += 100 { j := i + 100 if j > totalAnnotations { j = totalAnnotations } _, resp, err := client.ReportsApi. BulkCreateOrUpdateAnnotations(ctx, repoOwner, repoName, sha, reportId). Body(annotations[i:j]). Execute() if err = checkBitBucketApiError(err, resp, http.StatusOK); err != nil { return fmt.Errorf("failed to create code insights annotations: %w", err) } } } return nil } // getBitBucketContext returns a context with BitBucket credentials (not required for runs in BitBucket Pipelines) func getBitBucketContext() context.Context { ctx := context.Background() user, password, token := os.Getenv("QD_BITBUCKET_USER"), os.Getenv("QD_BITBUCKET_PASSWORD"), os.Getenv("QD_BITBUCKET_TOKEN") if user != "" && password != "" { ctx = context.WithValue( ctx, bbapi.ContextBasicAuth, bbapi.BasicAuth{ UserName: user, Password: password, }, ) } if token != "" { ctx = context.WithValue(ctx, bbapi.ContextAccessToken, token) } return ctx } // buildReport builds a report to be sent to BitBucket code Insights func buildReport(toolName string, annotations []bbapi.ReportAnnotation, cloudUrl string) bbapi.Report { var result string if len(annotations) == 0 { result = bitBucketReportPassed } else { result = bitBucketReportFailed } data := bbapi.NewReport() data.SetTitle(toolName + " ") data.SetReportType(bitBucketReportType) data.SetReporter(bitBucketReporter) data.SetLogoUrl(bitBucketAvatar) data.SetLink(cloudUrl) data.SetDetails(msg.GetProblemsFoundMessage(len(annotations))) data.SetResult(result) return *data } // buildAnnotation builds an annotation to be sent to BitBucket code Insights func buildAnnotation(r *sarif.Result, ruleDescription string, reportLink string) bbapi.ReportAnnotation { bbSeverity, ok := toBitBucketSeverity[getSeverity(r)] if !ok { log.Debugf("Unknown SARIF severity: %s", getSeverity(r)) bbSeverity = bitBucketLow } data := bbapi.NewReportAnnotation() data.SetExternalId(getFingerprint(r)) data.SetAnnotationType(bitBucketAnnotationType) data.SetSummary(fmt.Sprintf("%s: %s", r.RuleId, r.Message.Text)) data.SetDetails(ruleDescription) data.SetSeverity(bbSeverity) if len(r.Locations) > 0 && r.Locations[0].PhysicalLocation != nil { location := r.Locations[0].PhysicalLocation if location.Region != nil { data.SetLine(int32(location.Region.StartLine)) } if location.ArtifactLocation != nil { data.SetPath(location.ArtifactLocation.Uri) } } data.SetLink(reportLink) return *data } // getBitBucketClient returns a BitBucket API client with proper configuration by bbapi package func getBitBucketClient() *bbapi.APIClient { config := bbapi.NewConfiguration() config.HTTPClient = &http.Client{ Timeout: httpTimeout, } apiURL := os.Getenv("QD_BITBUCKET_URL") if apiURL == "" { if gitOrigin := os.Getenv("BITBUCKET_GIT_HTTP_ORIGIN"); gitOrigin != "" { if parsedURL, err := url.Parse(gitOrigin); err == nil { if !strings.Contains(parsedURL.Host, "bitbucket.org") { // Construct API URL for self-hosted BitBucket Data Center/Server // Reference: https://developer.atlassian.com/server/bitbucket/rest/v1000/intro/ apiURL = fmt.Sprintf("%s://%s/rest/api/1.0", parsedURL.Scheme, parsedURL.Host) } } } if apiURL == "" { apiURL = "https://api.bitbucket.org/2.0" } } server := bbapi.ServerConfiguration{ URL: apiURL, Description: `HTTPS API endpoint`, } if qdenv.IsBitBucket() { var proxyURL *url.URL if qdenv.IsBitBucketPipe() { proxyURL, _ = url.Parse(pipeProxyURL) } else { proxyURL, _ = url.Parse(pipelineProxyURL) } config.HTTPClient.Transport = &http.Transport{ Proxy: http.ProxyURL(proxyURL), } //goland:noinspection HttpUrlsUsage server = bbapi.ServerConfiguration{ URL: "http://api.bitbucket.org/2.0", } } config.Servers = bbapi.ServerConfigurations{server} return bbapi.NewAPIClient(config) } // checkBitBucketApiError checks if the API call was successful func checkBitBucketApiError(err error, resp *http.Response, expectedCode int) error { selfHostedHint := "Note: If you are using BitBucket Data Center/Server (self-hosted), " + "please set the QD_BITBUCKET_URL environment variable to your BitBucket API endpoint " + "(e.g., QD_BITBUCKET_URL=https://bitbucket.example.com/rest/api/1.0). " + "Read more at https://developer.atlassian.com/server/bitbucket/rest/v1000/intro/" if err != nil { return fmt.Errorf("BitBucket API error: %w%s", err, selfHostedHint) } if resp != nil && resp.StatusCode != expectedCode { body, _ := io.ReadAll(resp.Body) log.Debugf("Unexpected response: %s", body) return fmt.Errorf( "BitBucket API returned unexpected status code %d (expected %d)\n\n%s", resp.StatusCode, expectedCode, selfHostedHint, ) } return nil }