diff --git a/atlasaction/action.go b/atlasaction/action.go index 7f0f0dc9..c2f2f383 100644 --- a/atlasaction/action.go +++ b/atlasaction/action.go @@ -1051,6 +1051,12 @@ func (tc *TriggerContext) SCMClient() (SCMClient, error) { tc.Act.Warningf("GITLAB_TOKEN is not set, the action may not have all the permissions") } return gitlabClient(tc.Act.Getenv("CI_PROJECT_ID"), tc.SCM.APIURL, token), nil + case atlasexec.SCMTypeBitbucket: + return BitbucketClient( + tc.Act.Getenv("BITBUCKET_WORKSPACE"), + tc.Act.Getenv("BITBUCKET_REPO_SLUG"), + tc.Act.Getenv("BITBUCKET_ACCESS_TOKEN"), + ) default: return nil, ErrNoSCM // Not implemented yet. } diff --git a/atlasaction/bitbucket.go b/atlasaction/bitbucket.go index 304bb825..f275f7dd 100644 --- a/atlasaction/bitbucket.go +++ b/atlasaction/bitbucket.go @@ -6,17 +6,22 @@ package atlasaction import ( "context" + "crypto/sha256" + "encoding/base64" "fmt" "io" "net/url" "os" "path/filepath" + "slices" "strconv" "strings" "testing" + "ariga.io/atlas-action/atlasaction/internal/bitbucket" "ariga.io/atlas-go-sdk/atlasexec" "github.com/fatih/color" + "golang.org/x/oauth2" ) type bbPipe struct { @@ -107,4 +112,238 @@ func (a *bbPipe) SetOutput(name, value string) { } } +// MigrateApply implements Reporter. +func (a *bbPipe) MigrateApply(context.Context, *atlasexec.MigrateApply) { +} + +// MigrateLint implements Reporter. +func (a *bbPipe) MigrateLint(ctx context.Context, r *atlasexec.SummaryReport) { + c, err := a.reportClient() + if err != nil { + a.Errorf("failed to create bitbucket client: %v", err) + return + } + commitID := a.getenv("BITBUCKET_COMMIT") + cr, err := LintReport(commitID, r) + if err != nil { + a.Errorf("failed to generate commit report: %v", err) + return + } + if _, err = c.CreateReport(ctx, commitID, cr); err != nil { + a.Errorf("failed to create commit report: %v", err) + return + } + if issues := filterIssues(r.Steps); len(issues) > 0 { + stepSummary := func(s *atlasexec.StepReport) string { + if s.Text == "" { + return s.Name + } + return fmt.Sprintf("%s: %s", s.Name, s.Text) + } + annos := make([]bitbucket.ReportAnnotation, 0, len(issues)) + for _, s := range issues { + severity := bitbucket.SeverityMedium + if stepIsError(s) { + severity = bitbucket.SeverityHigh + } + if s.Result == nil { + anno := bitbucket.ReportAnnotation{ + Result: bitbucket.ResultFailed, + Summary: stepSummary(s), + Details: s.Error, + Severity: severity, + } + anno.ExternalID, err = hash(cr.ExternalID, s.Name, s.Text) + if err != nil { + a.Errorf("failed to generate external ID: %v", err) + return + } + annos = append(annos, anno) + } else { + for _, rr := range s.Result.Reports { + for _, d := range rr.Diagnostics { + anno := bitbucket.ReportAnnotation{ + Result: bitbucket.ResultFailed, + Summary: stepSummary(s), + Details: fmt.Sprintf("%s: %s", rr.Text, d.Text), + Severity: severity, + Path: "", // TODO: add path + Line: 0, // TODO: add line + } + switch { + case d.Code != "": + anno.Details += fmt.Sprintf(" (%s)", d.Code) + anno.AnnotationType = bitbucket.AnnotationTypeBug + anno.Link = fmt.Sprintf("https://atlasgo.io/lint/analyzers#%s", d.Code) + case len(d.SuggestedFixes) != 0: + anno.AnnotationType = bitbucket.AnnotationTypeCodeSmell + // TODO: Add suggested fixes. + default: + anno.AnnotationType = bitbucket.AnnotationTypeVulnerability + } + anno.ExternalID, err = hash(cr.ExternalID, s.Name, s.Text, rr.Text, d.Text) + if err != nil { + a.Errorf("failed to generate external ID: %v", err) + return + } + annos = append(annos, anno) + } + } + } + } + _, err = c.CreateReportAnnotations(ctx, commitID, cr.ExternalID, annos) + if err != nil { + a.Errorf("failed to create commit report annotations: %v", err) + return + } + } +} + +// SchemaApply implements Reporter. +func (a *bbPipe) SchemaApply(context.Context, *atlasexec.SchemaApply) { +} + +// SchemaPlan implements Reporter. +func (a *bbPipe) SchemaPlan(ctx context.Context, r *atlasexec.SchemaPlan) { + if l := r.Lint; l != nil { + a.MigrateLint(ctx, l) + } +} + +// reportClient returns a new Bitbucket client, +// This client only works with the Reports-API. +func (a *bbPipe) reportClient() (*bitbucket.Client, error) { + return bitbucket.NewClient( + a.getenv("BITBUCKET_WORKSPACE"), + a.getenv("BITBUCKET_REPO_SLUG"), + // Proxy the request through the Docker host. + // It allows the pipe submit the report without extra authentication. + // + // https://support.atlassian.com/bitbucket-cloud/docs/code-insights/#Authentication + bitbucket.WithProxy(func() (u *url.URL, err error) { + u = &url.URL{} + if h := a.getenv("DOCKER_HOST"); h != "" { + if u, err = url.Parse(h); err != nil { + return nil, err + } + } + u.Scheme = "http" + u.Host = fmt.Sprintf("%s:29418", u.Hostname()) + return u, nil + }), + ) +} + +// We use our docker image name as the reporter. +// This is used to identify the source of the report. +const bitbucketReporter = "arigaio/atlas-action" + +// LintReport generates a commit report for the given commit ID +func LintReport(commit string, r *atlasexec.SummaryReport) (*bitbucket.CommitReport, error) { + // We need ensure the report is unique on Bitbucket. + // So we hash the commit ID and the current schema. + // This way, we can identify the report for a specific commit, and schema state. + externalID, err := hash(commit, r.Schema.Current) + if err != nil { + return nil, fmt.Errorf("bitbucket: failed to generate external ID: %w", err) + } + cr := &bitbucket.CommitReport{ + ExternalID: externalID, + Reporter: bitbucketReporter, + ReportType: bitbucket.ReportTypeSecurity, + Title: "Atlas Lint", + Link: r.URL, + LogoURL: "https://ariga.io/assets/favicon.ico", + } + if issues := len(filterIssues(r.Steps)); issues > 0 { + cr.Details = fmt.Sprintf("Found %d issues.", issues) + cr.Result = bitbucket.ResultFailed + steps := len(r.Steps) + cr.AddPercentage("Health Score", float64(steps-issues)/float64(steps)*100) + } else { + cr.Details = "No issues found." + cr.Result = bitbucket.ResultPassed + cr.AddPercentage("Health Score", 100) + } + cr.AddNumber("Diagnostics", int64(r.DiagnosticsCount())) + cr.AddNumber("Files", int64(len(r.Files))) + if d := r.Env.Dir; d != "" { + cr.AddText("Working Directory", d) + } + if r.URL != "" { + u, err := url.Parse(r.URL) + if err != nil { + return nil, fmt.Errorf("bitbucket: failed to parse URL: %w", err) + } + u.Fragment = "erd" + cr.AddLink("ERD", "View Visualization", u) + } + return cr, nil +} + +type bbClient struct { + *bitbucket.Client +} + +// BitbucketClient returns a new Bitbucket client that implements SCMClient. +func BitbucketClient(workspace, repoSlug, token string) (*bbClient, error) { + c, err := bitbucket.NewClient( + workspace, repoSlug, + bitbucket.WithToken(&oauth2.Token{AccessToken: token}), + ) + if err != nil { + return nil, err + } + return &bbClient{Client: c}, nil +} + +// CommentLint implements SCMClient. +func (c *bbClient) CommentLint(ctx context.Context, tc *TriggerContext, r *atlasexec.SummaryReport) error { + comment, err := RenderTemplate("migrate-lint/md", r) + if err != nil { + return err + } + return c.upsertComment(ctx, tc.PullRequest.Number, tc.Act.GetInput("dir-name"), comment) +} + +// CommentPlan implements SCMClient. +func (c *bbClient) CommentPlan(ctx context.Context, tc *TriggerContext, p *atlasexec.SchemaPlan) error { + comment, err := RenderTemplate("schema-plan/md", p) + if err != nil { + return err + } + return c.upsertComment(ctx, tc.PullRequest.Number, p.File.Name, comment) +} + +func (c *bbClient) upsertComment(ctx context.Context, prID int, id, comment string) error { + comments, err := c.PullRequestComments(ctx, prID) + if err != nil { + return err + } + marker := commentMarker(id) + comment += "\n\n" + marker + if found := slices.IndexFunc(comments, func(c bitbucket.PullRequestComment) bool { + return strings.Contains(c.Content.Raw, marker) + }); found != -1 { + _, err = c.PullRequestUpdateComment(ctx, prID, comments[found].ID, comment) + } else { + _, err = c.PullRequestCreateComment(ctx, prID, comment) + } + return err +} + +// hash returns the SHA-256 hash of the parts. +// The hash is encoded using base64.RawURLEncoding. +func hash(parts ...string) (string, error) { + h := sha256.New() + for _, p := range parts { + if _, err := h.Write([]byte(p)); err != nil { + return "", err + } + } + return base64.URLEncoding.EncodeToString(h.Sum(nil)), nil +} + var _ Action = (*bbPipe)(nil) +var _ Reporter = (*bbPipe)(nil) +var _ SCMClient = (*bbClient)(nil) diff --git a/atlasaction/internal/bitbucket/bitbucket.go b/atlasaction/internal/bitbucket/bitbucket.go new file mode 100644 index 00000000..06111903 --- /dev/null +++ b/atlasaction/internal/bitbucket/bitbucket.go @@ -0,0 +1,385 @@ +// Copyright 2021-present The Atlas Authors. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package bitbucket + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + "golang.org/x/oauth2" +) + +type ( + // Client is the Bitbucket client. + Client struct { + baseURL string + workspace string + repoSlug string + client *http.Client + } + // CommitReport is a report for a commit. + CommitReport struct { + Title string `json:"title"` + Details string `json:"details"` + ReportType ReportType `json:"report_type"` + ExternalID string `json:"external_id,omitempty"` + Reporter string `json:"reporter,omitempty"` + Link string `json:"link,omitempty"` + RemoteLinkEnabled bool `json:"remote_link_enabled,omitempty"` + LogoURL string `json:"logo_url,omitempty"` + Result Result `json:"result,omitempty"` + // Map of data to be reported. + // Maximum of 10 data points. + Data []ReportData `json:"data,omitempty"` + } + // ReportType: SECURITY, COVERAGE, TEST, BUG + ReportType string + // Result: PASSED, FAILED, PENDING, SKIPPED, IGNORED + Result string + // Severity: LOW, MEDIUM, HIGH, CRITICAL + Severity string + // ReportData is the data to be reported. + ReportData struct { + Title string `json:"title"` + Value any `json:"value"` + Type string `json:"type,omitempty"` + } + // ReportAnnotation is the annotation to be reported. + ReportAnnotation struct { + AnnotationType AnnotationType `json:"annotation_type"` + Summary string `json:"summary"` + Result Result `json:"result,omitempty"` + Severity Severity `json:"severity,omitempty"` + ExternalID string `json:"external_id,omitempty"` + Path string `json:"path,omitempty"` + Line int `json:"line,omitempty"` + Details string `json:"details,omitempty"` + Link string `json:"link,omitempty"` + } + // AnnotationType: VULNERABILITY, CODE_SMELL, BUG + AnnotationType string + // PullRequestComment is a comment. + PullRequestComment struct { + Content Rendered `json:"content"` + ID int `json:"id,omitempty"` + } + Rendered struct { + Raw string `json:"raw"` + Markup string `json:"markup,omitempty"` + Html string `json:"html,omitempty"` + } + // PaginatedResponse is a paginated response. + PaginatedResponse[T any] struct { + Size int `json:"size"` + Page int `json:"page"` + PageLen int `json:"pagelen"` + Next string `json:"next"` + Previous string `json:"previous"` + Values []T `json:"values"` + } + // Error is an API error. + Error struct { + ID string `json:"id"` + Message string `json:"message"` + Detail string `json:"detail"` + Fields map[string][]string `json:"fields"` + Data map[string]string `json:"data"` + } + // ClientOption is the option when creating a new client. + ClientOption func(*Client) error +) + +// ReportType values. +const ( + ReportTypeSecurity ReportType = "SECURITY" + ReportTypeCoverage ReportType = "COVERAGE" + ReportTypeTest ReportType = "TEST" + ReportTypeBug ReportType = "BUG" +) + +// Result values. +const ( + ResultPassed Result = "PASSED" + ResultFailed Result = "FAILED" + ResultPending Result = "PENDING" + ResultSkipped Result = "SKIPPED" + ResultIgnored Result = "IGNORED" +) + +// Severity values. +const ( + SeverityLow Severity = "LOW" + SeverityMedium Severity = "MEDIUM" + SeverityHigh Severity = "HIGH" + SeverityCritical Severity = "CRITICAL" +) + +// AnnotationType values. +const ( + AnnotationTypeVulnerability AnnotationType = "VULNERABILITY" + AnnotationTypeCodeSmell AnnotationType = "CODE_SMELL" + AnnotationTypeBug AnnotationType = "BUG" +) + +// DefaultBaseURL is the default base URL for the Bitbucket API. +const DefaultBaseURL = "https://api.bitbucket.org/2.0" + +// WithToken returns a ClientOption that sets the token for the client. +func WithToken(t *oauth2.Token) ClientOption { + return func(c *Client) error { + base := c.client.Transport + if base == nil { + base = http.DefaultTransport + } + c.client.Transport = &oauth2.Transport{ + Base: base, + Source: oauth2.StaticTokenSource(t), + } + return nil + } +} + +// WithProxy returns a ClientOption that sets the proxy for the client. +func WithProxy(proxyFn func() (*url.URL, error)) ClientOption { + return func(c *Client) error { + proxy, err := proxyFn() + if err != nil { + return err + } + c.client.Transport = &http.Transport{ + Proxy: http.ProxyURL(proxy), + } + u, err := url.Parse(c.baseURL) + if err != nil { + return err + } + // Set the scheme to the proxy scheme. + u.Scheme = proxy.Scheme + c.baseURL = u.String() + return nil + } +} + +// NewClient returns a new Bitbucket client. +func NewClient(workspace, repoSlug string, opts ...ClientOption) (*Client, error) { + c := &Client{ + workspace: workspace, + repoSlug: repoSlug, + baseURL: DefaultBaseURL, + client: &http.Client{Timeout: time.Second * 30}, + } + for _, opt := range opts { + if err := opt(c); err != nil { + return nil, err + } + } + return c, nil +} + +// CreateReport creates a commit report for the given commit. +func (b *Client) CreateReport(ctx context.Context, commit string, r *CommitReport) (*CommitReport, error) { + if len(r.Data) > 10 { + return nil, fmt.Errorf("bitbucket: maximum of 10 data points allowed") + } + u, err := b.repoURL("commit", commit, "reports", r.ExternalID) + if err != nil { + return nil, err + } + res, err := b.json(ctx, http.MethodPut, u, r) + if err != nil { + return nil, err + } + return responseDecode[CommitReport](res, http.StatusOK) +} + +func (b *Client) CreateReportAnnotations(ctx context.Context, commit, reportID string, annotations []ReportAnnotation) ([]ReportAnnotation, error) { + u, err := b.repoURL("commit", commit, "reports", reportID, "annotations") + if err != nil { + return nil, err + } + res, err := b.json(ctx, http.MethodPost, u, annotations) + if err != nil { + return nil, err + } + a, err := responseDecode[[]ReportAnnotation](res, http.StatusOK) + if err != nil { + return nil, err + } + return *a, nil +} + +// PullRequestComments returns the comments of a pull request. +func (b *Client) PullRequestComments(ctx context.Context, prID int) (result []PullRequestComment, err error) { + u, err := b.repoURL("pullrequests", strconv.Itoa(prID), "comments") + if err != nil { + return nil, err + } + for u != "" { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, err + } + res, err := b.client.Do(req) + if err != nil { + return nil, err + } + list, err := responseDecode[PaginatedResponse[PullRequestComment]](res, http.StatusOK) + if err != nil { + return nil, err + } + result = append(result, list.Values...) + u = list.Next // Fetch the next page if available. + } + return result, nil +} + +// PullRequestCreateComment creates a comment on a pull request. +func (b *Client) PullRequestCreateComment(ctx context.Context, prID int, raw string) (*PullRequestComment, error) { + u, err := b.repoURL("pullrequests", strconv.Itoa(prID), "comments") + if err != nil { + return nil, err + } + res, err := b.json(ctx, http.MethodPost, u, map[string]any{ + "content": map[string]string{ + "raw": raw, + }, + }) + if err != nil { + return nil, err + } + return responseDecode[PullRequestComment](res, http.StatusCreated) +} + +// PullRequestUpdateComment updates a comment on a pull request. +func (b *Client) PullRequestUpdateComment(ctx context.Context, prID, id int, raw string) (*PullRequestComment, error) { + u, err := b.repoURL("pullrequests", strconv.Itoa(prID), "comments", strconv.Itoa(id)) + if err != nil { + return nil, err + } + res, err := b.json(ctx, http.MethodPut, u, map[string]any{ + "content": map[string]string{ + "raw": raw, + }, + }) + if err != nil { + return nil, err + } + return responseDecode[PullRequestComment](res, http.StatusOK) +} + +func (b *Client) repoURL(elems ...string) (string, error) { + return url.JoinPath(b.baseURL, append([]string{"repositories", b.workspace, b.repoSlug}, elems...)...) +} + +// json sends a JSON request to the Bitbucket API. +func (b *Client) json(ctx context.Context, method, u string, data any) (*http.Response, error) { + d, err := json.Marshal(data) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, method, u, bytes.NewReader(d)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + return b.client.Do(req) +} + +// responseDecode decodes the response body +// if the status code is the expected status. +// otherwise, it decodes the body as an error. +func responseDecode[T any](r *http.Response, s int) (*T, error) { + defer r.Body.Close() + d := json.NewDecoder(r.Body) + if r.StatusCode != s { + var res struct { + Type string `json:"type"` // always "error" + Error Error `json:"error"` + } + if err := d.Decode(&res); err != nil { + return nil, fmt.Errorf("bitbucket: failed to decode error response: %w", err) + } + return nil, &res.Error + } + var res T + if err := d.Decode(&res); err != nil { + return nil, fmt.Errorf("bitbucket: failed to decode response: %w", err) + } + return &res, nil +} + +// Error implements the error interface. +func (e *Error) Error() string { + if e.Detail != "" { + return fmt.Sprintf("bitbucket: %s: %s", e.Message, e.Detail) + } + return fmt.Sprintf("bitbucket: %s", e.Message) +} + +// AddText adds a text data to the commit report. +func (r *CommitReport) AddText(title, value string) { + r.Data = append(r.Data, ReportData{ + Title: title, Type: "TEXT", + Value: value, + }) +} + +// AddBoolean adds a boolean data to the commit report. +func (r *CommitReport) AddBoolean(title string, value bool) { + r.Data = append(r.Data, ReportData{ + Title: title, Type: "BOOLEAN", + Value: value, + }) +} + +// AddNumber adds a number data to the commit report. +func (r *CommitReport) AddNumber(title string, value int64) { + r.Data = append(r.Data, ReportData{ + Title: title, Type: "NUMBER", + Value: value, + }) +} + +// AddPercentage adds a percentage data to the commit report. +func (r *CommitReport) AddPercentage(title string, value float64) { + r.Data = append(r.Data, ReportData{ + Title: title, Type: "PERCENTAGE", + Value: value, + }) +} + +// AddDate adds a date data to the commit report. +func (r *CommitReport) AddDate(title string, value time.Time) { + r.Data = append(r.Data, ReportData{ + Title: title, Type: "DATE", + Value: value.UnixMilli(), + }) +} + +// AddDuration adds a duration data to the commit report. +func (r *CommitReport) AddDuration(title string, value time.Duration) { + r.Data = append(r.Data, ReportData{ + Title: title, Type: "DURATION", + Value: value.Milliseconds(), + }) +} + +// AddLink adds a link data to the commit report. +func (r *CommitReport) AddLink(title string, text string, u *url.URL) { + r.Data = append(r.Data, ReportData{ + Title: title, Type: "LINK", + Value: map[string]string{ + "text": text, "href": u.String(), + }, + }) +} + +var _ error = (*Error)(nil)