Repository: reviewdog/reviewdog
Pull request #997 (2021-07-16 14:57:32)

Add Bitbucket Server support for 'bitbucket-code-report' reporter
This PR add support for Bitbucket Server using Code Insights API available starting from version 5.15:

- [https://confluence.atlassian.com/bitbucketserver/code-insights-966660485.html](https://confluence.atlassian.com/bitbucketserver/code-insights-966660485.html)
- [https://docs.atlassian.com/bitbucket-server/rest/5.15.0/bitbucket-code-insights-rest.html#idm288218258672](https://docs.atlassian.com/bitbucket-server/rest/5.15.0/bitbucket-code-insights-rest.html#idm288218258672)


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c656be4..97917d0e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### :rocket: Enhancements
 - Added support for Bitbucket Pipes executed within Pipelines.
+- Added support for Bitbucket Server to `bitbucket-code-report` reporter
 
 ---
 
diff --git a/README.md b/README.md
index 7225742c..076e91db 100644
--- a/README.md
+++ b/README.md
@@ -552,6 +552,14 @@ $ export BITBUCKET_PASSWORD="my_password"
 $ reviewdog -reporter=bitbucket-code-report
 ```
 
+To post report to Bitbucket Server use `BITBUCKET_SERVER_URL` variable:
+```shell
+$ export BITBUCKET_USER="my_user"
+$ export BITBUCKET_PASSWORD="my_password"
+$ export BITBUCKET_SERVER_URL="https://bitbucket.my-company.com"
+$ reviewdog -reporter=bitbucket-code-report
+```
+
 ## Supported CI services
 
 ### [GitHub Actions](https://github.com/features/actions)
diff --git a/cmd/reviewdog/main.go b/cmd/reviewdog/main.go
index fe8b6a4a..857ab24b 100644
--- a/cmd/reviewdog/main.go
+++ b/cmd/reviewdog/main.go
@@ -25,7 +25,6 @@ import (
 	"github.com/reviewdog/errorformat/fmts"
 	"github.com/xanzy/go-gitlab"
 
-	"github.com/reviewdog/go-bitbucket"
 	"github.com/reviewdog/reviewdog"
 	"github.com/reviewdog/reviewdog/cienv"
 	"github.com/reviewdog/reviewdog/commands"
@@ -165,8 +164,9 @@ const (
 		(see documentation below for local reporters) and BitBucket credentials:
 		- For Basic Auth you need to set following env variables:
 			  BITBUCKET_USER and BITBUCKET_PASSWORD
-		- For AccessToken Auth you need to set BITBUCKET_ACCESS_TOKEN 
-		Running on Bitbucket Server is not tested/supported yet.
+		- For AccessToken Auth you need to set BITBUCKET_ACCESS_TOKEN
+		
+		To post results to Bitbucket Server specify BITBUCKET_SERVER_URL.
 
 	For GitHub Enterprise and self hosted GitLab, set
 	REVIEWDOG_INSECURE_SKIP_VERIFY to skip verifying SSL (please use this at your own risk)
@@ -610,7 +610,7 @@ func gerritBuildWithClient() (*cienv.BuildInfo, *gerrit.Client, error) {
 	return buildInfo, client, nil
 }
 
-func bitbucketBuildWithClient(ctx context.Context) (*cienv.BuildInfo, *bitbucket.APIClient, context.Context, error) {
+func bitbucketBuildWithClient(ctx context.Context) (*cienv.BuildInfo, bbservice.APIClient, context.Context, error) {
 	build, _, err := cienv.GetBuildInfo()
 	if err != nil {
 		return nil, nil, ctx, err
@@ -619,16 +619,20 @@ func bitbucketBuildWithClient(ctx context.Context) (*cienv.BuildInfo, *bitbucket
 	bbUser := os.Getenv("BITBUCKET_USER")
 	bbPass := os.Getenv("BITBUCKET_PASSWORD")
 	bbAccessToken := os.Getenv("BITBUCKET_ACCESS_TOKEN")
+	bbServerURL := os.Getenv("BITBUCKET_SERVER_URL")
 
-	if bbUser != "" && bbPass != "" {
-		ctx = bbservice.WithBasicAuth(ctx, bbUser, bbPass)
-	}
-
-	if bbAccessToken != "" {
-		ctx = bbservice.WithAccessToken(ctx, bbAccessToken)
+	var client bbservice.APIClient
+	if bbServerURL != "" {
+		ctx, err = bbservice.BuildServerAPIContext(ctx, bbServerURL, bbUser, bbPass, bbAccessToken)
+		if err != nil {
+			return nil, nil, ctx, fmt.Errorf("failed to build context for Bitbucket API calls: %w", err)
+		}
+		client = bbservice.NewServerAPIClient()
+	} else {
+		ctx = bbservice.BuildCloudAPIContext(ctx, bbUser, bbPass, bbAccessToken)
+		client = bbservice.NewCloudAPIClient(cienv.IsInBitbucketPipeline(), cienv.IsInBitbucketPipe())
 	}
 
-	client := bbservice.NewAPIClient(cienv.IsInBitbucketPipeline(), cienv.IsInBitbucketPipe())
 	return build, client, ctx, nil
 }
 
diff --git a/go.mod b/go.mod
index 21f3b6f8..63b59012 100644
--- a/go.mod
+++ b/go.mod
@@ -16,8 +16,10 @@ require (
 	github.com/kylelemons/godebug v1.1.0
 	github.com/mattn/go-shellwords v1.0.12
 	github.com/rakyll/statik v0.1.7
+	github.com/reva2/bitbucket-insights-api v1.0.0
 	github.com/reviewdog/errorformat v0.0.0-20210517100703-fd739bda5dda
 	github.com/reviewdog/go-bitbucket v0.0.0-20201024094602-708c3f6a7de0
+	github.com/stretchr/testify v1.7.0
 	github.com/vvakame/sdlog v0.0.0-20200409072131-7c0d359efddc
 	github.com/xanzy/go-gitlab v0.50.1
 	go.opencensus.io v0.23.0
diff --git a/go.sum b/go.sum
index 98422795..97e0c898 100644
--- a/go.sum
+++ b/go.sum
@@ -213,20 +213,24 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
 github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 github.com/rakyll/statik v0.1.7 h1:OF3QCZUuyPxuGEP7B4ypUa7sB/iHtqOTDYZXGM8KOdQ=
 github.com/rakyll/statik v0.1.7/go.mod h1:AlZONWzMtEnMs7W4e/1LURLiI49pIMmp6V9Unghqrcc=
+github.com/reva2/bitbucket-insights-api v1.0.0 h1:lpQ/Q7OmnG04w/EM77piOwZBxP41PeTlbytXxVrnplA=
+github.com/reva2/bitbucket-insights-api v1.0.0/go.mod h1:pLs+ki3MKUntrPryxaGIvpRLiEtBhwfJ/uvxQIMfqHU=
 github.com/reviewdog/errorformat v0.0.0-20210517100703-fd739bda5dda h1:5Y9XKToBKO/WPJ9qq30nvpu9DUNy+nwvT8vcSwk/n8w=
 github.com/reviewdog/errorformat v0.0.0-20210517100703-fd739bda5dda/go.mod h1:AqhrP0G7F9YRROF10JQwdd4cNO8bdm6bY6KzcOc3Cp8=
 github.com/reviewdog/go-bitbucket v0.0.0-20201024094602-708c3f6a7de0 h1:XZ60Bp2UqwaJ6fDQExoFVrgs4nIzwBCy9ct6GCj9hH8=
 github.com/reviewdog/go-bitbucket v0.0.0-20201024094602-708c3f6a7de0/go.mod h1:5JbWAMFyq9hbISZawRyIe7QTcLaptvCIvmZnYo+1VvA=
 github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
-github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
 github.com/vvakame/sdlog v0.0.0-20200409072131-7c0d359efddc h1:El7LEavRpa49dYFE9ezO8aQxQn5E7u7eQkFsaXsoQAY=
 github.com/vvakame/sdlog v0.0.0-20200409072131-7c0d359efddc/go.mod h1:MmhrKtbECoUJTctfak+MnOFoJ9XQqYZ7chcwV9O7v3I=
diff --git a/service/bitbucket/annotator.go b/service/bitbucket/annotator.go
index 3f830623..47a3b827 100644
--- a/service/bitbucket/annotator.go
+++ b/service/bitbucket/annotator.go
@@ -2,17 +2,11 @@ package bitbucket
 
 import (
 	"context"
-	"crypto/sha256"
 	"fmt"
-	"net/http"
 	"path/filepath"
-	"strings"
 	"sync"
 
-	bbapi "github.com/reviewdog/go-bitbucket"
 	"github.com/reviewdog/reviewdog"
-	"github.com/reviewdog/reviewdog/proto/rdf"
-	"google.golang.org/protobuf/proto"
 )
 
 var _ reviewdog.CommentService = &ReportAnnotator{}
@@ -27,38 +21,36 @@ const (
 
 // ReportAnnotator is a comment service for Bitbucket Code Insights reports.
 //
-// API:
+// Cloud API:
 //  https://developer.atlassian.com/bitbucket/api/2/reference/resource/repositories/%7Bworkspace%7D/%7Brepo_slug%7D/commit/%7Bcommit%7D/reports/%7BreportId%7D/annotations#post
 //  POST /2.0/repositories/{username}/{repo_slug}/commit/{commit}/reports/{reportId}/annotations
+//
+// Server API:
+//  https://docs.atlassian.com/bitbucket-server/rest/5.15.0/bitbucket-code-insights-rest.html#idm288218233536
+//  /rest/insights/1.0/projects/{projectKey}/repos/{repositorySlug}/commits/{commitId}/reports/{key}/annotations
 type ReportAnnotator struct {
-	cli         *bbapi.APIClient
+	cli         APIClient
 	sha         string
 	owner, repo string
 
 	muAnnotations sync.Mutex
 	// store annotations in map per tool name
 	// so we can create report per tool
-	annotations map[string][]bbapi.ReportAnnotation
-	severityMap map[rdf.Severity]string
+	comments map[string][]*reviewdog.Comment
 
 	// wd is working directory relative to root of repository.
 	wd         string
 	duplicates map[string]struct{}
 }
 
-// NewReportAnnotator creates new Bitbucket Report Annotator
-func NewReportAnnotator(cli *bbapi.APIClient, owner, repo, sha string, runners []string) *ReportAnnotator {
+// NewReportAnnotator creates new Bitbucket ReportRequest Annotator
+func NewReportAnnotator(cli APIClient, owner, repo, sha string, runners []string) *ReportAnnotator {
 	r := &ReportAnnotator{
-		cli:         cli,
-		sha:         sha,
-		owner:       owner,
-		repo:        repo,
-		annotations: make(map[string][]bbapi.ReportAnnotation, len(runners)),
-		severityMap: map[rdf.Severity]string{
-			rdf.Severity_INFO:    annotationSeverityLow,
-			rdf.Severity_WARNING: annotationSeverityMedium,
-			rdf.Severity_ERROR:   annotationSeverityHigh,
-		},
+		cli:        cli,
+		sha:        sha,
+		owner:      owner,
+		repo:       repo,
+		comments:   make(map[string][]*reviewdog.Comment, len(runners)),
 		duplicates: map[string]struct{}{},
 	}
 
@@ -68,9 +60,14 @@ func NewReportAnnotator(cli *bbapi.APIClient, owner, repo, sha string, runners [
 		if len(runner) == 0 {
 			continue
 		}
-		r.annotations[runner] = []bbapi.ReportAnnotation{}
+		r.comments[runner] = []*reviewdog.Comment{}
 		// create Pending report for each tool
-		_ = r.createOrUpdateReport(context.Background(), reportID(runner, reporter), reportTitle(runner, reporter), reportResultPending)
+		_ = r.createOrUpdateReport(
+			context.Background(),
+			reportID(runner, reporter),
+			reportTitle(runner, reporter),
+			reportResultPending,
+		)
 	}
 
 	return r
@@ -81,19 +78,19 @@ func NewReportAnnotator(cli *bbapi.APIClient, owner, repo, sha string, runners [
 func (r *ReportAnnotator) Post(_ context.Context, c *reviewdog.Comment) error {
 	c.Result.Diagnostic.GetLocation().Path = filepath.ToSlash(
 		filepath.Join(r.wd, c.Result.Diagnostic.GetLocation().GetPath()))
+
 	r.muAnnotations.Lock()
 	defer r.muAnnotations.Unlock()
 
-	anot := r.annotationFromReviewDogComment(*c)
-
 	// deduplicate event, because some reporters might report
 	// it twice, and bitbucket api will complain on duplicated
 	// external id of annotation
-	if _, ok := r.duplicates[*anot.ExternalId]; !ok {
-		r.annotations[c.ToolName] = append(r.annotations[c.ToolName], anot)
+	commentID := externalIDFromDiagnostic(c.Result.Diagnostic)
+	if _, exist := r.duplicates[commentID]; !exist {
+		r.comments[c.ToolName] = append(r.comments[c.ToolName], c)
+		r.duplicates[commentID] = struct{}{}
 	}
 
-	r.duplicates[*anot.ExternalId] = struct{}{}
 	return nil
 }
 
@@ -103,10 +100,10 @@ func (r *ReportAnnotator) Flush(ctx context.Context) error {
 	defer r.muAnnotations.Unlock()
 
 	// create/update/annotate report per tool
-	for tool, annotations := range r.annotations {
+	for tool, comments := range r.comments {
 		reportID := reportID(tool, reporter)
 		title := reportTitle(tool, reporter)
-		if len(annotations) == 0 {
+		if len(comments) == 0 {
 			// if no annotation, create Passed report
 			if err := r.createOrUpdateReport(ctx, reportID, title, reportResultPassed); err != nil {
 				return err
@@ -120,21 +117,25 @@ func (r *ReportAnnotator) Flush(ctx context.Context) error {
 			return err
 		}
 
-		// send annotations in batches, because of the api max payload size limit
-		for start, annCount := 0, len(annotations); start < annCount; start += annotationsBatchSize {
+		// send comments in batches, because of the api max payload size limit
+		for start, annCount := 0, len(comments); start < annCount; start += annotationsBatchSize {
 			end := start + annotationsBatchSize
 
 			if end > annCount {
 				end = annCount
 			}
 
-			// add annotations to the report
-			_, resp, err := r.cli.ReportsApi.BulkCreateOrUpdateAnnotations(
-				ctx, r.owner, r.repo, r.sha, reportID,
-			).Body(annotations[start:end]).Execute()
+			req := &AnnotationsRequest{
+				Owner:      r.owner,
+				Repository: r.repo,
+				Commit:     r.sha,
+				ReportID:   reportID,
+				Comments:   comments[start:end],
+			}
 
-			if err := checkAPIError(err, resp, http.StatusOK); err != nil {
-				return fmt.Errorf("bitbucket.BulkCreateOrUpdateAnnotations: %s", err)
+			err := r.cli.CreateOrUpdateAnnotations(ctx, req)
+			if err != nil {
+				return fmt.Errorf("failed to post annotations: %w", err)
 			}
 		}
 	}
@@ -142,74 +143,27 @@ func (r *ReportAnnotator) Flush(ctx context.Context) error {
 	return nil
 }
 
-func (r *ReportAnnotator) annotationFromReviewDogComment(c reviewdog.Comment) bbapi.ReportAnnotation {
-	a := bbapi.NewReportAnnotation()
-
-	// TODO: allow providing different annotation types in future
-	a.SetAnnotationType(annotationTypeCodeSmell)
-	// hash the output of linter and use it as external id
-	a.SetExternalId(externalIDFromDiagnostic(c.Result.Diagnostic))
-	a.SetSummary(c.Result.Diagnostic.GetMessage())
-	a.SetDetails(fmt.Sprintf(`[%s] %s`, c.ToolName, c.Result.Diagnostic.GetMessage()))
-	a.SetLine(c.Result.Diagnostic.GetLocation().GetRange().GetStart().GetLine())
-	a.SetPath(c.Result.Diagnostic.GetLocation().GetPath())
-	if v, ok := r.severityMap[c.Result.Diagnostic.GetSeverity()]; ok {
-		a.SetSeverity(v)
-	}
-	if link := c.Result.Diagnostic.GetCode().GetUrl(); link != "" {
-		a.SetLink(link)
+func (r *ReportAnnotator) createOrUpdateReport(ctx context.Context, id, title, reportStatus string) error {
+	req := &ReportRequest{
+		ReportID:   id,
+		Owner:      r.owner,
+		Repository: r.repo,
+		Commit:     r.sha,
+		Type:       reportTypeBug,
+		Title:      title,
+		Reporter:   reporter,
+		Result:     reportStatus,
+		LogoURL:    logoURL,
 	}
 
-	return *a
-}
-
-func (r *ReportAnnotator) createOrUpdateReport(ctx context.Context, id, title, reportStatus string) error {
-	var report = bbapi.NewReport()
-	report.SetTitle(title)
-	// TODO: different report types?
-	report.SetReportType(reportTypeBug)
-	report.SetReporter(reporter)
-	report.SetLogoUrl(logoURL)
-	report.SetResult(reportStatus)
 	switch reportStatus {
 	case reportResultPassed:
-		report.SetDetails("Great news! Reviewdog couldn't spot any issues!")
+		req.Details = "Great news! Reviewdog couldn't spot any issues!"
 	case reportResultPending:
-		report.SetDetails("Please wait for Reviewdog to finish checking your code for issues.")
+		req.Details = "Please wait for Reviewdog to finish checking your code for issues."
 	default:
-		report.SetDetails("Woof-Woof! This report generated for you by reviewdog.")
-	}
-
-	_, resp, err := r.cli.ReportsApi.CreateOrUpdateReport(ctx, r.owner, r.repo, r.sha, id).Body(*report).Execute()
-
-	if err := checkAPIError(err, resp, http.StatusOK); err != nil {
-		return fmt.Errorf("bitbucket.CreateOrUpdateReport: %s", err)
+		req.Details = "Woof-Woof! This report generated for you by reviewdog."
 	}
 
-	return nil
-}
-
-func hash(b []byte) string {
-	h := sha256.New()
-	_, _ = h.Write(b)
-	return fmt.Sprintf("%x", h.Sum(nil))
-}
-
-// Note that it might be good to create external ID from the diagnostic
-// content along with *original line* (by using git blame for example) to
-// generate unique ID, but it hashes the Diagnostic message for simplicity.
-func externalIDFromDiagnostic(d *rdf.Diagnostic) string {
-	b, err := proto.Marshal(d)
-	if err != nil {
-		b = []byte(d.OriginalOutput)
-	}
-	return hash(b)
-}
-
-func reportID(ids ...string) string {
-	return strings.ReplaceAll(strings.ToLower(strings.Join(ids, "-")), " ", "_")
-}
-
-func reportTitle(tool, reporter string) string {
-	return fmt.Sprintf("[%s] %s report", tool, reporter)
+	return r.cli.CreateOrUpdateReport(ctx, req)
 }
diff --git a/service/bitbucket/api_client.go b/service/bitbucket/api_client.go
new file mode 100644
index 00000000..f117e04b
--- /dev/null
+++ b/service/bitbucket/api_client.go
@@ -0,0 +1,57 @@
+package bitbucket
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/reviewdog/reviewdog"
+)
+
+// ReportRequest is an object that represent parameters used to create/update report
+type ReportRequest struct {
+	Owner      string
+	Repository string
+	Commit     string
+	ReportID   string
+	Type       string
+	Title      string
+	Reporter   string
+	Result     string
+	Details    string
+	LogoURL    string
+}
+
+// AnnotationsRequest is an object that represent parameters used to create/update annotations
+type AnnotationsRequest struct {
+	Owner      string
+	Repository string
+	Commit     string
+	ReportID   string
+	Comments   []*reviewdog.Comment
+}
+
+// APIClient is client for Bitbucket Code Insights API
+type APIClient interface {
+
+	// CreateOrUpdateReport creates or updates specified report
+	CreateOrUpdateReport(ctx context.Context, req *ReportRequest) error
+
+	// CreateOrUpdateAnnotations creates or updates annotations
+	CreateOrUpdateAnnotations(ctx context.Context, req *AnnotationsRequest) error
+}
+
+// UnexpectedResponseError is triggered when we have unexpected response from Code Insights API
+type UnexpectedResponseError struct {
+	Code int
+	Body []byte
+}
+
+func (e UnexpectedResponseError) Error() string {
+	msg := fmt.Sprintf("received unexpected %d code from Bitbucket API", e.Code)
+
+	if len(e.Body) > 0 {
+		msg += " with message:\n" + string(e.Body)
+	}
+
+	return msg
+}
diff --git a/service/bitbucket/bitbucket.go b/service/bitbucket/bitbucket.go
deleted file mode 100644
index 974045ac..00000000
--- a/service/bitbucket/bitbucket.go
+++ /dev/null
@@ -1,101 +0,0 @@
-package bitbucket
-
-import (
-	"context"
-	"net/http"
-	"net/url"
-	"time"
-
-	bbapi "github.com/reviewdog/go-bitbucket"
-	"golang.org/x/oauth2"
-)
-
-const (
-	// PipelineProxyURL available while using Bitbucket Pipelines and
-	// allows you to use the Reports-API without extra authentication.
-	// For that you need to send your request through a proxy server that runs alongside with
-	// every pipeline on ‘localhost:29418’, and a valid Auth-Header will automatically be added to your request.
-	// https://support.atlassian.com/bitbucket-cloud/docs/code-insights/#Authentication
-	// However, if using proxy HTTP API endpoint need to be used
-	pipelineProxyURL = "http://localhost:29418"
-
-	// PipeProxyURL is to be used when reviewdog is running within a Bitbucket Pipe
-	// Pipes run in docker containers and as a result will need to connect to the proxy via this Docker DNS.
-	pipeProxyURL = "http://host.docker.internal:29418"
-	httpTimeout  = time.Second * 10
-)
-
-// NewAPIClient creates Bitbucket API client
-func NewAPIClient(isInPipeline bool, isInPipe bool) *bbapi.APIClient {
-	httpClient := &http.Client{
-		Timeout: httpTimeout,
-	}
-	server := httpsServer()
-
-	if isInPipeline {
-		var proxyURL *url.URL
-		if isInPipe {
-			// if we are executing a pipe within a pipeline, use docker endpoint
-			// and proxy
-			proxyURL, _ = url.Parse(pipeProxyURL)
-		} else {
-			// if we are on the Bitbucket Pipeline, use HTTP endpoint
-			// and proxy
-			proxyURL, _ = url.Parse(pipelineProxyURL)
-		}
-		server = httpServer()
-		httpClient.Transport = &http.Transport{
-			Proxy: http.ProxyURL(proxyURL),
-		}
-	}
-	return NewAPIClientWithConfigurations(httpClient, server)
-}
-
-// NewAPIClientWithConfigurations allows to create new Bitbucket API client with
-// custom http client or server configurations
-func NewAPIClientWithConfigurations(client *http.Client, server bbapi.ServerConfiguration) *bbapi.APIClient {
-	config := bbapi.NewConfiguration()
-	if client != nil {
-		config.HTTPClient = client
-	} else {
-		config.HTTPClient = &http.Client{
-			Timeout: httpTimeout,
-		}
-	}
-	config.Servers = bbapi.ServerConfigurations{server}
-	return bbapi.NewAPIClient(config)
-
-}
-
-// WithBasicAuth adds basic auth credentials to context
-func WithBasicAuth(ctx context.Context, username, password string) context.Context {
-	return context.WithValue(ctx, bbapi.ContextBasicAuth,
-		bbapi.BasicAuth{
-			UserName: username,
-			Password: password,
-		})
-}
-
-// WithAccessToken adds basic auth credentials to context
-func WithAccessToken(ctx context.Context, accessToken string) context.Context {
-	return context.WithValue(ctx, bbapi.ContextAccessToken, accessToken)
-}
-
-// WithOAuth2 adds basic auth credentials to context
-func WithOAuth2(ctx context.Context, tokenSource oauth2.TokenSource) context.Context {
-	return context.WithValue(ctx, bbapi.ContextOAuth2, tokenSource)
-}
-
-func httpServer() bbapi.ServerConfiguration {
-	return bbapi.ServerConfiguration{
-		URL: "http://api.bitbucket.org/2.0",
-		Description: `If if called from Bitbucket Pipelines,
-using HTTP API endpoint and AuthProxy`,
-	}
-}
-func httpsServer() bbapi.ServerConfiguration {
-	return bbapi.ServerConfiguration{
-		URL:         "https://api.bitbucket.org/2.0",
-		Description: `HTTPS API endpoint`,
-	}
-}
diff --git a/service/bitbucket/cloud_api_client.go b/service/bitbucket/cloud_api_client.go
new file mode 100644
index 00000000..06a449cd
--- /dev/null
+++ b/service/bitbucket/cloud_api_client.go
@@ -0,0 +1,130 @@
+package bitbucket
+
+import (
+	"context"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+
+	bbapi "github.com/reviewdog/go-bitbucket"
+)
+
+const (
+	// PipelineProxyURL available while using Bitbucket Pipelines and
+	// allows you to use the Reports-API without extra authentication.
+	// For that you need to send your request through a proxy server that runs alongside with
+	// every pipeline on ‘localhost:29418’, and a valid Auth-Header will automatically be added to your request.
+	// https://support.atlassian.com/bitbucket-cloud/docs/code-insights/#Authentication
+	// However, if using proxy HTTP API endpoint need to be used
+	pipelineProxyURL = "http://localhost:29418"
+
+	// PipeProxyURL is to be used when reviewdog is running within a Bitbucket Pipe
+	// Pipes run in docker containers and as a result will need to connect to the proxy via this Docker DNS.
+	pipeProxyURL = "http://host.docker.internal:29418"
+)
+
+// CloudAPIClient is wrapper for Bitbucket Cloud Code Insights API client
+type CloudAPIClient struct {
+	cli    *bbapi.APIClient
+	helper *CloudAPIHelper
+}
+
+// NewCloudAPIClient creates client for Bitbucket Cloud Insights API
+func NewCloudAPIClient(isInPipeline bool, isInPipe bool) APIClient {
+	httpClient := &http.Client{
+		Timeout: httpTimeout,
+	}
+
+	server := bbapi.ServerConfiguration{
+		URL:         "https://api.bitbucket.org/2.0",
+		Description: `HTTPS API endpoint`,
+	}
+
+	if isInPipeline {
+		var proxyURL *url.URL
+		if isInPipe {
+			// if we are executing a pipe within a pipeline, use docker endpoint
+			// and proxy
+			proxyURL, _ = url.Parse(pipeProxyURL)
+		} else {
+			// if we are on the Bitbucket Pipeline, use HTTP endpoint
+			// and proxy
+			proxyURL, _ = url.Parse(pipelineProxyURL)
+		}
+
+		httpClient.Transport = &http.Transport{
+			Proxy: http.ProxyURL(proxyURL),
+		}
+
+		server = bbapi.ServerConfiguration{
+			URL:         "http://api.bitbucket.org/2.0",
+			Description: `If if called from Bitbucket Pipelines, using HTTP API endpoint and AuthProxy`,
+		}
+	}
+
+	return NewCloudAPIClientWithConfigurations(httpClient, server)
+}
+
+// NewCloudAPIClientWithConfigurations creates client for Bitbucket Cloud Insights API with specified configuration
+func NewCloudAPIClientWithConfigurations(client *http.Client, server bbapi.ServerConfiguration) APIClient {
+	config := bbapi.NewConfiguration()
+	if client != nil {
+		config.HTTPClient = client
+	} else {
+		config.HTTPClient = &http.Client{
+			Timeout: httpTimeout,
+		}
+	}
+	config.Servers = bbapi.ServerConfigurations{server}
+
+	return &CloudAPIClient{
+		cli:    bbapi.NewAPIClient(config),
+		helper: &CloudAPIHelper{},
+	}
+}
+
+// CreateOrUpdateReport creates or updates specified report
+func (c *CloudAPIClient) CreateOrUpdateReport(ctx context.Context, req *ReportRequest) error {
+	_, resp, err := c.cli.
+		ReportsApi.CreateOrUpdateReport(ctx, req.Owner, req.Repository, req.Commit, req.ReportID).
+		Body(c.helper.BuildReport(req)).
+		Execute()
+
+	if err := c.checkAPIError(err, resp, http.StatusOK); err != nil {
+		return fmt.Errorf("failed to create code insights report: %w", err)
+	}
+
+	return nil
+}
+
+// CreateOrUpdateAnnotations creates or updates annotations
+func (c *CloudAPIClient) CreateOrUpdateAnnotations(ctx context.Context, req *AnnotationsRequest) error {
+	_, resp, err := c.cli.ReportsApi.
+		BulkCreateOrUpdateAnnotations(ctx, req.Owner, req.Repository, req.Commit, req.ReportID).
+		Body(c.helper.BuildAnnotations(req.Comments)).
+		Execute()
+
+	if err := c.checkAPIError(err, resp, http.StatusOK); err != nil {
+		return fmt.Errorf("failed to create code insighsts annotations: %w", err)
+	}
+
+	return nil
+}
+
+func (c *CloudAPIClient) checkAPIError(err error, resp *http.Response, expectedCode int) error {
+	if err != nil {
+		return fmt.Errorf("bitubucket API error: %w", err)
+	}
+
+	if resp != nil && resp.StatusCode != expectedCode {
+		body, _ := ioutil.ReadAll(resp.Body)
+
+		return UnexpectedResponseError{
+			Code: resp.StatusCode,
+			Body: body,
+		}
+	}
+
+	return nil
+}
diff --git a/service/bitbucket/cloud_api_context.go b/service/bitbucket/cloud_api_context.go
new file mode 100644
index 00000000..eb673a71
--- /dev/null
+++ b/service/bitbucket/cloud_api_context.go
@@ -0,0 +1,34 @@
+package bitbucket
+
+import (
+	"context"
+
+	bbapi "github.com/reviewdog/go-bitbucket"
+)
+
+// BuildCloudAPIContext builds context.Context used to call Bitbucket Cloud Code Insights API
+func BuildCloudAPIContext(ctx context.Context, user, password, token string) context.Context {
+	if user != "" && password != "" {
+		ctx = withBasicAuth(ctx, user, password)
+	}
+
+	if token != "" {
+		ctx = withAccessToken(ctx, token)
+	}
+
+	return ctx
+}
+
+// WithBasicAuth adds basic auth credentials to context
+func withBasicAuth(ctx context.Context, username, password string) context.Context {
+	return context.WithValue(ctx, bbapi.ContextBasicAuth,
+		bbapi.BasicAuth{
+			UserName: username,
+			Password: password,
+		})
+}
+
+// WithAccessToken adds basic auth credentials to context
+func withAccessToken(ctx context.Context, accessToken string) context.Context {
+	return context.WithValue(ctx, bbapi.ContextAccessToken, accessToken)
+}
diff --git a/service/bitbucket/cloud_api_helper.go b/service/bitbucket/cloud_api_helper.go
new file mode 100644
index 00000000..62d366f2
--- /dev/null
+++ b/service/bitbucket/cloud_api_helper.go
@@ -0,0 +1,55 @@
+package bitbucket
+
+import (
+	"fmt"
+
+	bbapi "github.com/reviewdog/go-bitbucket"
+	"github.com/reviewdog/reviewdog"
+)
+
+// CloudAPIHelper is collection of utility functions used to build requests
+// for Bitbucket Cloud Code Insights API
+type CloudAPIHelper struct{}
+
+// BuildReport builds Code Insights API report object
+func (c *CloudAPIHelper) BuildReport(req *ReportRequest) bbapi.Report {
+	data := bbapi.NewReport()
+	data.SetTitle(req.Title)
+	data.SetReportType(req.Type)
+	data.SetReporter(req.Reporter)
+	data.SetLogoUrl(req.LogoURL)
+	data.SetResult(req.Result)
+	data.SetDetails(req.Details)
+
+	return *data
+}
+
+// BuildAnnotations builds list of Code Insights API annotation objects for specified comments
+func (c *CloudAPIHelper) BuildAnnotations(comments []*reviewdog.Comment) []bbapi.ReportAnnotation {
+	annotations := make([]bbapi.ReportAnnotation, len(comments))
+	for idx, comment := range comments {
+		annotations[idx] = c.buildAnnotation(comment)
+	}
+
+	return annotations
+}
+
+func (c *CloudAPIHelper) buildAnnotation(comment *reviewdog.Comment) bbapi.ReportAnnotation {
+	data := bbapi.NewReportAnnotation()
+	data.SetExternalId(externalIDFromDiagnostic(comment.Result.Diagnostic))
+	data.SetAnnotationType(annotationTypeCodeSmell)
+	data.SetSummary(comment.Result.Diagnostic.GetMessage())
+	data.SetDetails(fmt.Sprintf(`[%s] %s`, comment.ToolName, comment.Result.Diagnostic.GetMessage()))
+	data.SetLine(comment.Result.Diagnostic.GetLocation().GetRange().GetStart().GetLine())
+	data.SetPath(comment.Result.Diagnostic.GetLocation().GetPath())
+
+	if severity := convertSeverity(comment.Result.Diagnostic.GetSeverity()); severity != "" {
+		data.SetSeverity(severity)
+	}
+
+	if link := comment.Result.Diagnostic.GetCode().GetUrl(); link != "" {
+		data.SetLink(link)
+	}
+
+	return *data
+}
diff --git a/service/bitbucket/consts.go b/service/bitbucket/consts.go
index d1db7b9c..400add97 100644
--- a/service/bitbucket/consts.go
+++ b/service/bitbucket/consts.go
@@ -1,6 +1,10 @@
 package bitbucket
 
+import "time"
+
 const (
+	httpTimeout = time.Second * 10
+
 	reportTypeBug = "BUG"
 	// reportTypeSecurity = "SECURITY"
 	// reportTypeCoverage = "COVERAGE"
diff --git a/service/bitbucket/errors.go b/service/bitbucket/errors.go
deleted file mode 100644
index df8548b3..00000000
--- a/service/bitbucket/errors.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package bitbucket
-
-import (
-	"errors"
-	"fmt"
-	"io/ioutil"
-	"net/http"
-
-	bbapi "github.com/reviewdog/go-bitbucket"
-)
-
-func checkAPIError(err error, resp *http.Response, expectedCode int) error {
-	if err != nil {
-		e, ok := err.(bbapi.GenericOpenAPIError)
-		if ok {
-			return fmt.Errorf(`bitbucket API error:
-	Response error: %s
-	Response body: %s`,
-				e.Error(), string(e.Body()))
-		}
-	}
-
-	if resp != nil && resp.StatusCode != expectedCode {
-		body, err := ioutil.ReadAll(resp.Body)
-		if err != nil {
-			return err
-		}
-		msg := fmt.Sprintf("received unexpected %d code from Bitbucket API", resp.StatusCode)
-		if len(body) > 0 {
-			msg += " with message:\n" + string(body)
-		}
-		return errors.New(msg)
-	}
-
-	return err
-}
diff --git a/service/bitbucket/helpers.go b/service/bitbucket/helpers.go
new file mode 100644
index 00000000..a48af3ee
--- /dev/null
+++ b/service/bitbucket/helpers.go
@@ -0,0 +1,54 @@
+package bitbucket
+
+import (
+	"crypto/sha256"
+	"fmt"
+	"strings"
+
+	"github.com/reviewdog/reviewdog/proto/rdf"
+	"google.golang.org/protobuf/proto"
+)
+
+func hash(b []byte) string {
+	h := sha256.New()
+	_, _ = h.Write(b)
+	return fmt.Sprintf("%x", h.Sum(nil))
+}
+
+// Note that it might be good to create external ReportID from the diagnostic
+// content along with *original line* (by using git blame for example) to
+// generate unique ReportID, but it hashes the Diagnostic message for simplicity.
+func externalIDFromDiagnostic(d *rdf.Diagnostic) string {
+	b, err := proto.Marshal(d)
+	if err != nil {
+		b = []byte(d.OriginalOutput)
+	}
+	return hash(b)
+}
+
+func reportID(ids ...string) string {
+	return strings.ReplaceAll(strings.ToLower(strings.Join(ids, "-")), " ", "_")
+}
+
+func reportTitle(tool, reporter string) string {
+	return fmt.Sprintf("[%s] %s report", tool, reporter)
+}
+
+func convertSeverity(severity rdf.Severity) string {
+	switch severity {
+	case rdf.Severity_INFO:
+		return annotationSeverityLow
+
+	case rdf.Severity_WARNING:
+		return annotationSeverityMedium
+
+	case rdf.Severity_ERROR:
+		return annotationSeverityHigh
+
+	case rdf.Severity_UNKNOWN_SEVERITY:
+		return ""
+
+	default:
+		return ""
+	}
+}
diff --git a/service/bitbucket/server_api_client.go b/service/bitbucket/server_api_client.go
new file mode 100644
index 00000000..fa03a0bb
--- /dev/null
+++ b/service/bitbucket/server_api_client.go
@@ -0,0 +1,99 @@
+package bitbucket
+
+import (
+	"context"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+
+	insights "github.com/reva2/bitbucket-insights-api"
+)
+
+// ServerAPIClient is wrapper for Bitbucket Server Code Insights API client
+type ServerAPIClient struct {
+	cli    *insights.APIClient
+	helper *ServerAPIHelper
+}
+
+// NewServerAPIClient creates client for Bitbucket Server Code Insights API
+func NewServerAPIClient() APIClient {
+	httpClient := &http.Client{
+		Timeout: httpTimeout,
+	}
+
+	config := insights.NewConfiguration()
+	config.HTTPClient = httpClient
+
+	return &ServerAPIClient{
+		cli:    insights.NewAPIClient(config),
+		helper: &ServerAPIHelper{},
+	}
+}
+
+// CreateOrUpdateReport creates or updates specified report
+func (c *ServerAPIClient) CreateOrUpdateReport(ctx context.Context, req *ReportRequest) error {
+	// Bitbucket Server API doesn't support pending status
+	if req.Result == reportResultPending {
+		return nil
+	}
+
+	// We need to drop report and delete all annotations created previously
+	err := c.deleteReport(ctx, req)
+	if err != nil {
+		return err
+	}
+
+	_, resp, err := c.cli.InsightsApi.
+		UpdateReport(ctx, req.Owner, req.Repository, req.Commit, req.ReportID).
+		Report(c.helper.BuildReport(req)).
+		Execute()
+
+	if err := c.checkAPIError(err, resp, http.StatusOK); err != nil {
+		return fmt.Errorf("failed to create code insights report: %w", err)
+	}
+
+	return nil
+}
+
+// CreateOrUpdateAnnotations creates or updates annotations
+func (c *ServerAPIClient) CreateOrUpdateAnnotations(ctx context.Context, req *AnnotationsRequest) error {
+	resp, err := c.cli.InsightsApi.
+		CreateAnnotations(ctx, req.Owner, req.Repository, req.Commit, req.ReportID).
+		AnnotationsList(c.helper.BuildAnnotations(req.Comments)).
+		Execute()
+
+	if err := c.checkAPIError(err, resp, http.StatusNoContent); err != nil {
+		return fmt.Errorf("failed to create annotations: %w", err)
+	}
+
+	return nil
+}
+
+func (c *ServerAPIClient) deleteReport(ctx context.Context, report *ReportRequest) error {
+	resp, err := c.cli.InsightsApi.
+		DeleteReport(ctx, report.Owner, report.Repository, report.Commit, report.ReportID).
+		Execute()
+
+	if err := c.checkAPIError(err, resp, http.StatusNoContent); err != nil {
+		return fmt.Errorf("failted to delete code insights report: %w", err)
+	}
+
+	return nil
+}
+
+func (c *ServerAPIClient) checkAPIError(err error, resp *http.Response, expectedCode int) error {
+	if err != nil {
+		return fmt.Errorf("bitubucket API error: %w", err)
+	}
+
+	if resp != nil && resp.StatusCode != expectedCode {
+		body, _ := ioutil.ReadAll(resp.Body)
+
+		return UnexpectedResponseError{
+			Code: resp.StatusCode,
+			Body: body,
+		}
+	}
+
+	return nil
+}
diff --git a/service/bitbucket/server_api_context.go b/service/bitbucket/server_api_context.go
new file mode 100644
index 00000000..884db94a
--- /dev/null
+++ b/service/bitbucket/server_api_context.go
@@ -0,0 +1,58 @@
+package bitbucket
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+
+	insights "github.com/reva2/bitbucket-insights-api"
+)
+
+// BuildServerAPIContext builds context.Context used to call Bitbucket Server Code Insights API
+func BuildServerAPIContext(ctx context.Context, bbURL, user, password, token string) (context.Context, error) {
+	var err error
+	ctx, err = withServerVariables(ctx, bbURL)
+	if err != nil {
+		return ctx, err
+	}
+
+	if user != "" && password != "" {
+		ctx = withServerBasicAuth(ctx, user, password)
+	}
+
+	if token != "" {
+		ctx = withServerAccessToken(ctx, token)
+	}
+
+	return ctx, nil
+}
+
+// WithServerBasicAuth adds basic auth credentials to context
+func withServerBasicAuth(ctx context.Context, user, password string) context.Context {
+	return context.WithValue(ctx, insights.ContextBasicAuth, insights.BasicAuth{
+		UserName: user,
+		Password: password,
+	})
+}
+
+// WithServerAccessToken adds basic auth credentials to context
+func withServerAccessToken(ctx context.Context, token string) context.Context {
+	return context.WithValue(ctx, insights.ContextAccessToken, token)
+}
+
+// WithServerVariables adds server variable to context
+func withServerVariables(ctx context.Context, bbURL string) (context.Context, error) {
+	parsed, err := url.Parse(bbURL)
+	if err != nil {
+		return ctx, fmt.Errorf("failed to parse Bitbucket Server URL: %w", err)
+	}
+
+	return context.WithValue(
+		ctx,
+		insights.ContextServerVariables,
+		map[string]string{
+			"protocol":        parsed.Scheme,
+			"bitbucketDomain": parsed.Host,
+		},
+	), nil
+}
diff --git a/service/bitbucket/server_api_helper.go b/service/bitbucket/server_api_helper.go
new file mode 100644
index 00000000..876d69b4
--- /dev/null
+++ b/service/bitbucket/server_api_helper.go
@@ -0,0 +1,67 @@
+package bitbucket
+
+import (
+	"fmt"
+	insights "github.com/reva2/bitbucket-insights-api"
+	"github.com/reviewdog/reviewdog"
+)
+
+// ServerAPIHelper is collection of utility functions used to build requests
+// for Bitbucket Server Code Insights API
+type ServerAPIHelper struct{}
+
+// BuildReport builds Code Insights API report object
+func (h *ServerAPIHelper) BuildReport(req *ReportRequest) insights.Report {
+	data := insights.NewReport(req.Title)
+
+	data.SetReporter(req.Reporter)
+	data.SetLogoUrl(req.LogoURL)
+	data.SetResult(h.convertResult(req.Result))
+	data.SetDetails(req.Details)
+
+	return *data
+}
+
+// BuildAnnotations builds list of Code Insights API annotation objects for specified comments
+func (h *ServerAPIHelper) BuildAnnotations(comments []*reviewdog.Comment) insights.AnnotationsList {
+	annotations := make([]insights.Annotation, len(comments))
+	for idx, comment := range comments {
+		annotations[idx] = h.buildAnnotation(comment)
+	}
+
+	list := insights.NewAnnotationsList(annotations)
+
+	return *list
+}
+
+func (h *ServerAPIHelper) buildAnnotation(comment *reviewdog.Comment) insights.Annotation {
+	severity := convertSeverity(comment.Result.Diagnostic.GetSeverity())
+	if severity == "" {
+		severity = annotationSeverityLow
+	}
+
+	data := insights.NewAnnotation(
+		comment.Result.Diagnostic.GetLocation().GetPath(),
+		comment.Result.Diagnostic.GetLocation().GetRange().GetStart().GetLine()-1,
+		fmt.Sprintf(`[%s] %s`, comment.ToolName, comment.Result.Diagnostic.GetMessage()),
+		severity,
+	)
+	data.SetExternalId(externalIDFromDiagnostic(comment.Result.Diagnostic))
+	data.SetType(annotationTypeCodeSmell)
+
+	if link := comment.Result.Diagnostic.GetCode().GetUrl(); link != "" {
+		data.SetLink(link)
+	}
+
+	return *data
+}
+
+func (h *ServerAPIHelper) convertResult(result string) string {
+	switch result {
+	case reportResultFailed:
+		return "FAIL"
+
+	default:
+		return "PASS"
+	}
+}


Test patch:
diff --git a/service/bitbucket/annotator_test.go b/service/bitbucket/annotator_test.go
index de602d1d..9dff05d5 100644
--- a/service/bitbucket/annotator_test.go
+++ b/service/bitbucket/annotator_test.go
@@ -2,189 +2,238 @@ package bitbucket
 
 import (
 	"context"
-	"encoding/json"
-	"fmt"
-	"io/ioutil"
-	"net/http"
-	"net/http/httptest"
-	"reflect"
-	"strings"
 	"testing"
-	"time"
 
-	bbapi "github.com/reviewdog/go-bitbucket"
-	"github.com/reviewdog/reviewdog"
 	"github.com/reviewdog/reviewdog/filter"
 	"github.com/reviewdog/reviewdog/proto/rdf"
+
+	"github.com/reviewdog/reviewdog"
+
+	"github.com/stretchr/testify/suite"
 )
 
-func TestAnnotator(t *testing.T) {
-	var testcases = []struct {
-		name                string
-		runnersList         []string
-		comments            []*reviewdog.Comment
-		expectedAnnotations map[string]int
-		expectedReportCalls map[string][]string
-	}{
-		{
-			name:                "Empty runners list, no comments",
-			expectedAnnotations: make(map[string]int),
-			expectedReportCalls: make(map[string][]string),
-		},
-		{
-			name:                "Predefined runners list, and no comments",
-			runnersList:         []string{"runner1", "runner2"},
-			expectedAnnotations: make(map[string]int),
-			expectedReportCalls: map[string][]string{
-				// since we know list of runners, annotator will create one report for each
-				// - on start it will create reports in "pending" state
-				// - in the end, it will update report to the "passed" state, since no annotations were found
-				reportID("runner1", reporter): {reportResultPending, reportResultPassed},
-				reportID("runner2", reporter): {reportResultPending, reportResultPassed},
-			},
-		},
-		{
-			name:        "Predefined runners list, and one comment",
-			runnersList: []string{"runner1", "runner2"},
-			comments: []*reviewdog.Comment{
-				newComment("runner2", "main.go", "test", 1),
-			},
-			expectedAnnotations: map[string]int{
-				reportID("runner2", reporter): 1,
-			},
-			expectedReportCalls: map[string][]string{
-				// runner1 has no annotations, so report will be marked as "passed"
-				reportID("runner1", reporter): {reportResultPending, reportResultPassed},
-				// runner2 has some annotations, so report will be marked as "failed"
-				reportID("runner2", reporter): {reportResultPending, reportResultFailed},
-			},
-		},
-		{
-			name:        "Predefined runners list, and duplicated comment",
-			runnersList: []string{"runner1", "runner2"},
-			comments: []*reviewdog.Comment{
-				newComment("runner2", "main.go", "test", 1),
-				newComment("runner2", "main.go", "test", 1),
-			},
-			expectedAnnotations: map[string]int{
-				reportID("runner2", reporter): 1,
-			},
-			expectedReportCalls: map[string][]string{
-				reportID("runner1", reporter): {reportResultPending, reportResultPassed},
-				reportID("runner2", reporter): {reportResultPending, reportResultFailed},
-			},
-		},
-		{
-			name:        "Predefined runners list, and many comments",
-			runnersList: []string{"runner1", "runner2"},
-			comments: func() (comments []*reviewdog.Comment) {
-				for i := 0; i < 333; i++ {
-					comments = append(comments, newComment("runner1", "main.go", "test", int32(i)))
-				}
-				return
-			}(),
-			expectedAnnotations: map[string]int{
-				reportID("runner1", reporter): 333,
-			},
-			expectedReportCalls: map[string][]string{
-				reportID("runner1", reporter): {reportResultPending, reportResultFailed},
-				reportID("runner2", reporter): {reportResultPending, reportResultPassed},
-			},
-		},
+type AnnotatorTestSuite struct {
+	suite.Suite
+	cli   *MockAPIClient
+	owner string
+	repo  string
+	sha   string
+}
+
+func (s *AnnotatorTestSuite) SetupTest() {
+	s.cli = &MockAPIClient{}
+	s.owner = "test-owner"
+	s.repo = "test-repo"
+	s.sha = "test-commit"
+}
+
+// Empty runners list, no comments
+func (s *AnnotatorTestSuite) TestEmptyRunnersList() {
+	ctx, annotator := s.createAnnotator(nil)
+
+	err := annotator.Flush(ctx)
+	s.Require().NoError(err)
+	s.cli.AssertExpectations(s.T())
+}
+
+// Predefined runners list, and no comments
+func (s *AnnotatorTestSuite) TestNoComments() {
+	runners := []string{"runner1", "runner2"}
+	ctx, annotator := s.createAnnotator(runners)
+
+	for _, runner := range runners {
+		s.assumeReportCreated(ctx, runner, reportResultPassed)
 	}
 
-	username := "test_user"
-	repo := "test_repo"
-	commit := "test_123"
-	urlPrefix := fmt.Sprintf("/repositories/%s/%s/commit/%s/reports/", username, repo, commit)
-
-	for _, test := range testcases {
-		test := test
-		t.Run(test.name, func(t *testing.T) {
-			reportCallsSequence := map[string][]string{}
-			annotations := map[string]int{}
-			ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-				if !strings.HasPrefix(r.RequestURI, urlPrefix) {
-					t.Error("Bad request URI", r.RequestURI)
-					w.WriteHeader(http.StatusBadRequest)
-					return
-				}
-
-				splt := strings.Split(r.RequestURI, "/")
-				if l := len(splt); l < 8 {
-					t.Error("Request URI should have 7 parts at least, but got", l)
-					w.WriteHeader(http.StatusBadRequest)
-					return
-				}
-				reportID := splt[7]
-				isPostAnnotationsCall := len(splt) >= 9 && splt[8] == "annotations"
-
-				body, err := ioutil.ReadAll(r.Body)
-				if err != nil {
-					t.Error(err)
-					w.WriteHeader(http.StatusInternalServerError)
-					return
-				}
-
-				if isPostAnnotationsCall {
-					var req []bbapi.ReportAnnotation
-					if err := json.Unmarshal(body, &req); err != nil {
-						t.Error(err)
-						w.WriteHeader(http.StatusInternalServerError)
-					}
-					// count how many annotations we created
-					annotations[reportID] += len(req)
-				} else {
-					var req bbapi.Report
-					if err := json.Unmarshal(body, &req); err != nil {
-						t.Error(err)
-						w.WriteHeader(http.StatusInternalServerError)
-					}
-					reportCallsSequence[reportID] = append(reportCallsSequence[reportID], *req.Result)
-				}
-
-				w.WriteHeader(http.StatusOK)
-			}))
-			defer ts.Close()
-
-			client := NewAPIClientWithConfigurations(&http.Client{Timeout: 1 * time.Second}, bbapi.ServerConfiguration{URL: ts.URL})
-			bb := NewReportAnnotator(client, username, repo, commit, test.runnersList)
-			ctx := context.Background()
-
-			for _, c := range test.comments {
-				if err := bb.Post(ctx, c); err != nil {
-					t.Error(err)
-				}
-			}
-			if err := bb.Flush(ctx); err != nil {
-				t.Error(err)
-			}
+	err := annotator.Flush(ctx)
 
-			// assert resulted calls
-			if !reflect.DeepEqual(test.expectedAnnotations, annotations) {
-				t.Errorf("Expected annotations\n%#v\nbut got\n%#v", test.expectedAnnotations, annotations)
-			}
+	s.Require().NoError(err)
+	s.cli.AssertExpectations(s.T())
+}
+
+// Predefined runners list, and one comment
+func (s *AnnotatorTestSuite) TestOneComment() {
+	runners := []string{"runner1", "runner2"}
+	comments := []*reviewdog.Comment{
+		s.buildComment(runners[1], 1),
+	}
+
+	ctx, annotator := s.createAnnotator(runners)
+	s.setupExpectedAPICalls(ctx, runners, comments)
+
+	for _, comment := range comments {
+		err := annotator.Post(ctx, comment)
+		s.Require().NoError(err)
+	}
+
+	err := annotator.Flush(ctx)
+
+	s.Require().NoError(err)
+	s.cli.AssertExpectations(s.T())
+}
+
+// Predefined runners list, and duplicated comment
+func (s *AnnotatorTestSuite) TestDuplicateComments() {
+	runners := []string{"runner1", "runner2"}
+	comments := []*reviewdog.Comment{
+		s.buildComment(runners[1], 1),
+		s.buildComment(runners[1], 1),
+	}
+
+	ctx, annotator := s.createAnnotator(runners)
+	s.setupExpectedAPICalls(ctx, runners, comments)
+
+	for _, comment := range comments {
+		err := annotator.Post(ctx, comment)
+		s.Require().NoError(err)
+	}
 
-			if !reflect.DeepEqual(test.expectedReportCalls, reportCallsSequence) {
-				t.Errorf("Expected report calls\n%#v\nbut got\n%#v", test.expectedReportCalls, reportCallsSequence)
+	err := annotator.Flush(ctx)
+
+	s.Require().NoError(err)
+	s.cli.AssertExpectations(s.T())
+}
+
+// Predefined runners list, and duplicated comment
+func (s *AnnotatorTestSuite) TestManyComments() {
+	runners := []string{"runner1", "runner2"}
+
+	comments := make([]*reviewdog.Comment, 333)
+	for idx := 0; idx < 333; idx++ {
+		comments[idx] = s.buildComment(runners[1], int32(idx))
+	}
+
+	ctx, annotator := s.createAnnotator(runners)
+	s.setupExpectedAPICalls(ctx, runners, comments)
+
+	for _, comment := range comments {
+		err := annotator.Post(ctx, comment)
+		s.Require().NoError(err)
+	}
+
+	err := annotator.Flush(ctx)
+
+	s.Require().NoError(err)
+	s.cli.AssertExpectations(s.T())
+}
+
+func (s *AnnotatorTestSuite) createAnnotator(runners []string) (context.Context, *ReportAnnotator) {
+	ctx := context.Background()
+
+	for _, runner := range runners {
+		s.assumeReportCreated(ctx, runner, reportResultPending)
+	}
+
+	annotator := NewReportAnnotator(s.cli, s.owner, s.repo, s.sha, runners)
+
+	return ctx, annotator
+}
+
+func (s *AnnotatorTestSuite) setupExpectedAPICalls(
+	ctx context.Context,
+	runners []string,
+	comments []*reviewdog.Comment,
+) {
+	commentsMap := s.splitComments(comments)
+
+	for _, runner := range runners {
+		expResult := reportResultPassed
+		if len(commentsMap[runner]) > 0 {
+			expResult = reportResultFailed
+		}
+		s.assumeReportCreated(ctx, runner, expResult)
+
+		for start, annCount := 0, len(commentsMap[runner]); start < annCount; start += annotationsBatchSize {
+			end := start + annotationsBatchSize
+
+			if end > annCount {
+				end = annCount
 			}
-		})
+
+			s.assumeAnnotationsCreated(ctx, runner, commentsMap[runner][start:end])
+		}
 	}
 }
 
-func newComment(toolName, file, message string, line int32) *reviewdog.Comment {
+func (s *AnnotatorTestSuite) assumeReportCreated(ctx context.Context, runner string, status string) {
+	s.cli.On("CreateOrUpdateReport", ctx, s.buildReportReq(runner, status)).Return(nil).Once()
+}
+
+func (s *AnnotatorTestSuite) assumeAnnotationsCreated(
+	ctx context.Context,
+	runner string,
+	comments []*reviewdog.Comment,
+) {
+	s.cli.On("CreateOrUpdateAnnotations", ctx, s.buildAnnotationsRequest(runner, comments)).Return(nil).Once()
+}
+
+func (s *AnnotatorTestSuite) buildReportReq(runner string, result string) *ReportRequest {
+	report := &ReportRequest{
+		ReportID:   reportID(runner, reporter),
+		Owner:      s.owner,
+		Repository: s.repo,
+		Commit:     s.sha,
+		Type:       reportTypeBug,
+		Title:      reportTitle(runner, reporter),
+		Reporter:   reporter,
+		Result:     result,
+		LogoURL:    logoURL,
+	}
+
+	switch result {
+	case reportResultPassed:
+		report.Details = "Great news! Reviewdog couldn't spot any issues!"
+	case reportResultPending:
+		report.Details = "Please wait for Reviewdog to finish checking your code for issues."
+	default:
+		report.Details = "Woof-Woof! This report generated for you by reviewdog."
+	}
+
+	return report
+}
+
+func (s *AnnotatorTestSuite) buildAnnotationsRequest(runner string, comments []*reviewdog.Comment) *AnnotationsRequest {
+	return &AnnotationsRequest{
+		Owner:      s.owner,
+		Repository: s.repo,
+		Commit:     s.sha,
+		ReportID:   reportID(runner, reporter),
+		Comments:   comments,
+	}
+}
+
+func (s *AnnotatorTestSuite) buildComment(toolName string, line int32) *reviewdog.Comment {
 	return &reviewdog.Comment{
 		ToolName: toolName,
 		Result: &filter.FilteredDiagnostic{
 			Diagnostic: &rdf.Diagnostic{
 				Location: &rdf.Location{
-					Path: file,
+					Path: "main.go",
 					Range: &rdf.Range{Start: &rdf.Position{
 						Line: line,
 					}},
 				},
-				Message: message,
+				Message: "test message",
 			},
 		},
 	}
 }
+
+func (s *AnnotatorTestSuite) splitComments(comments []*reviewdog.Comment) map[string][]*reviewdog.Comment {
+	commentsMap := make(map[string][]*reviewdog.Comment)
+	duplicates := make(map[string]struct{})
+
+	for _, comment := range comments {
+		externalID := externalIDFromDiagnostic(comment.Result.Diagnostic)
+		if _, exist := duplicates[externalID]; !exist {
+			commentsMap[comment.ToolName] = append(commentsMap[comment.ToolName], comment)
+			duplicates[externalID] = struct{}{}
+		}
+	}
+
+	return commentsMap
+}
+
+func TestAnnotatorTestSuite(t *testing.T) {
+	suite.Run(t, &AnnotatorTestSuite{})
+}
diff --git a/service/bitbucket/api_client_test.go b/service/bitbucket/api_client_test.go
new file mode 100644
index 00000000..b7528210
--- /dev/null
+++ b/service/bitbucket/api_client_test.go
@@ -0,0 +1,19 @@
+package bitbucket
+
+import (
+	"context"
+
+	"github.com/stretchr/testify/mock"
+)
+
+type MockAPIClient struct {
+	mock.Mock
+}
+
+func (m *MockAPIClient) CreateOrUpdateReport(ctx context.Context, report *ReportRequest) error {
+	return m.Called(ctx, report).Error(0)
+}
+
+func (m *MockAPIClient) CreateOrUpdateAnnotations(ctx context.Context, annotations *AnnotationsRequest) error {
+	return m.Called(ctx, annotations).Error(0)
+}