diff --git a/atlasaction/action.go b/atlasaction/action.go index f4d058e8..52530db0 100644 --- a/atlasaction/action.go +++ b/atlasaction/action.go @@ -1098,6 +1098,12 @@ func (a *Actions) SCM(tc *TriggerContext) (SCMClient, error) { a.Warningf("GITLAB_TOKEN is not set, the action may not have all the permissions") } return gitlabClient(a.Getenv("CI_PROJECT_ID"), tc.SCM.APIURL, token), nil + case atlasexec.SCMTypeBitbucket: + return BitbucketClient( + a.Getenv("BITBUCKET_WORKSPACE"), + a.Getenv("BITBUCKET_REPO_SLUG"), + a.Getenv("BITBUCKET_ACCESS_TOKEN"), + ) default: return nil, ErrNoSCM // Not implemented yet. } @@ -1243,6 +1249,24 @@ func appliedStmts(a *atlasexec.MigrateApply) int { return total } +func filterIssues(steps []*atlasexec.StepReport) []*atlasexec.StepReport { + result := make([]*atlasexec.StepReport, 0, len(steps)) + for _, s := range steps { + switch { + case s.Error != "": + result = append(result, s) + case s.Result == nil: // No result. + case s.Result.Error != "" || len(s.Result.Reports) > 0: + result = append(result, s) + } + } + return result +} + +func stepIsError(s *atlasexec.StepReport) bool { + return s.Error != "" || (s.Result != nil && s.Result.Error != "") +} + var ( //go:embed comments/*.tmpl comments embed.FS @@ -1251,22 +1275,8 @@ var ( Funcs(template.FuncMap{ "execTime": execTime, "appliedStmts": appliedStmts, - "filterIssues": func(steps []*atlasexec.StepReport) []*atlasexec.StepReport { - result := make([]*atlasexec.StepReport, 0, len(steps)) - for _, s := range steps { - switch { - case s.Error != "": - result = append(result, s) - case s.Result == nil: // No result. - case s.Result.Error != "" || len(s.Result.Reports) > 0: - result = append(result, s) - } - } - return result - }, - "stepIsError": func(s *atlasexec.StepReport) bool { - return s.Error != "" || (s.Result != nil && s.Result.Error != "") - }, + "filterIssues": filterIssues, + "stepIsError": stepIsError, "firstUpper": func(s string) string { if s == "" { return "" diff --git a/atlasaction/bitbucket.go b/atlasaction/bitbucket.go index ecbce214..aebc8108 100644 --- a/atlasaction/bitbucket.go +++ b/atlasaction/bitbucket.go @@ -6,19 +6,34 @@ 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" ) +// proxyURL is the URL to the proxy server. +// This is used to authenticate the Bitbucket client, +// then the pipe is run in the Docker container. +// +// https://support.atlassian.com/bitbucket-cloud/docs/code-insights/#Authentication +var proxyURL = &url.URL{ + Scheme: "http", + Host: "host.docker.internal:29418", +} + type bbPipe struct { *coloredLogger getenv func(string) string @@ -101,4 +116,134 @@ 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) { + commitID := a.getenv("BITBUCKET_COMMIT") + cr, err := MigrateLintReport(commitID, r) + if err != nil { + a.Errorf("failed to generate commit report: %v", err) + return + } + c, err := bitbucket.NewClient( + a.getenv("BITBUCKET_WORKSPACE"), + a.getenv("BITBUCKET_REPO_SLUG"), + bitbucket.WithProxyAuthenticate(proxyURL), + ) + if err != nil { + a.Errorf("failed to create bitbucket client: %v", err) + return + } + if err = c.CreateReport(ctx, commitID, cr); err != nil { + a.Errorf("failed to create commit report: %v", err) + return + } +} + +// SchemaApply implements Reporter. +func (a *bbPipe) SchemaApply(context.Context, *atlasexec.SchemaApply) { +} + +// SchemaPlan implements Reporter. +func (a *bbPipe) SchemaPlan(_ context.Context, r *atlasexec.SchemaPlan) { + commitID := a.getenv("BITBUCKET_COMMIT") + cr, err := MigrateLintReport(commitID, r.Lint) + if err != nil { + a.Errorf("failed to generate commit report: %v", err) + return + } + _ = cr +} + +// We use our docker image name as the reporter. +// This is used to identify the source of the report. +const bitbucketReporter = "arigaio/atlas-action" + +// MigrateLintReport generates a commit report for the given commit ID +func MigrateLintReport(commit string, r *atlasexec.SummaryReport) (*bitbucket.CommitReport, error) { + externalID, err := hash(commit, r.Env.Dir, r.Env.URL.Redacted()) + 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 Migrate Lint", + Link: r.URL, + LogoURL: "https://ariga.io/assets/favicon.ico", + // Build the report. + Details: "", + Result: bitbucket.ResultPassed, + } + cr.AddText("Working Directory", r.Env.Dir) + cr.AddNumber("Diagnostics", int64(r.DiagnosticsCount())) + cr.AddNumber("Files", int64(len(r.Files))) + 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) + } + 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)) + } else { + cr.Details = "No issues found." + cr.Result = bitbucket.ResultPassed + cr.AddPercentage("Health Score", 1) + } + return cr, nil +} + +type bbClient struct { + *bitbucket.Client +} + +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 +} + +// UpsertComment implements SCMClient. +func (b *bbClient) UpsertComment(ctx context.Context, pr *PullRequest, id string, comment string) error { + c, err := b.PullRequestComments(ctx, pr.Number) + if err != nil { + return err + } + marker := commentMarker(id) + if found := slices.IndexFunc(c, func(c bitbucket.Comment) bool { + return strings.Contains(c.Body, marker) + }); found != -1 { + return b.PullRequestUpdateComment(ctx, pr.Number, c[found].ID, comment+"\n\n"+marker) + } + return b.PullRequestCreateComment(ctx, pr.Number, comment+"\n\n"+marker) +} + +// 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..5bd94cb3 --- /dev/null +++ b/atlasaction/internal/bitbucket/bitbucket.go @@ -0,0 +1,321 @@ +// 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 ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "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"` + ExternalID string `json:"external_id"` + Reporter string `json:"reporter"` + Link string `json:"link"` + RemoteLinkEnabled bool `json:"remote_link_enabled"` + LogoURL string `json:"logo_url"` + ReportType ReportType `json:"report_type"` + Result Result `json:"result"` + // Map of data to be reported. + // Maximum of 10 data points. + Data []ReportData `json:"data"` + } + // 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"` + Type string `json:"type"` + Value any `json:"value"` + } + // ReportAnnotation is the annotation to be reported. + ReportAnnotation struct { + ExternalID string `json:"external_id"` + AnnotationType string `json:"annotation_type"` + Path string `json:"path"` + Line int `json:"line"` + Summary string `json:"summary"` + Details string `json:"details"` + Result Result `json:"result"` + Severity string `json:"severity"` + Link string `json:"link"` + } + // Comment is a comment. + Comment struct { + ID int `json:"id"` + Body string `json:"body"` + } + // 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"` + } + // 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" +) + +// 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 + } +} + +// WithProxyAuthenticate returns a ClientOption that sets the proxy for the client. +func WithProxyAuthenticate(proxy *url.URL) ClientOption { + return func(c *Client) error { + 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) error { + data, err := json.Marshal(r) + if err != nil { + return err + } + u, err := b.repoURL("commit", commit, "reports", r.ExternalID) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, strings.NewReader(string(data))) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + res, err := b.client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code %d", res.StatusCode) + } + return nil +} + +// PullRequestComments returns the comments of a pull request. +func (b *Client) PullRequestComments(ctx context.Context, prID int) (result []Comment, 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 + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code %d", res.StatusCode) + } + var response PaginatedResponse[struct { + ID int `json:"id"` + Content struct { + Raw string `json:"raw"` + } `json:"content"` + }] + if err := json.NewDecoder(res.Body).Decode(&response); err != nil { + return nil, err + } + for _, c := range response.Values { + result = append(result, Comment{ID: c.ID, Body: c.Content.Raw}) + } + u = response.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, comment string) error { + content := strings.NewReader(fmt.Sprintf(`{"content":{"raw":%q}}`, comment)) + u, err := b.repoURL("pullrequests", strconv.Itoa(prID), "comments") + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, content) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + res, err := b.client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusCreated { + return fmt.Errorf("unexpected status code %d", res.StatusCode) + } + return nil +} + +// PullRequestUpdateComment updates a comment on a pull request. +func (b *Client) PullRequestUpdateComment(ctx context.Context, prID int, commentID int, comment string) error { + content := strings.NewReader(fmt.Sprintf(`{"content":{"raw":%q}}`, comment)) + u, err := b.repoURL("pullrequests", strconv.Itoa(prID), "comments", strconv.Itoa(commentID)) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, content) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + res, err := b.client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code %d", res.StatusCode) + } + return nil +} + +func (b *Client) repoURL(elems ...string) (string, error) { + return url.JoinPath(b.baseURL, append([]string{"repositories", b.workspace, b.repoSlug}, elems...)...) +} + +// AddText adds a text data to the commit report. +func (r *CommitReport) AddText(name, value string) { + r.Data = append(r.Data, ReportData{ + Title: name, Type: "TEXT", Value: value, + }) +} + +// AddBoolean adds a boolean data to the commit report. +func (r *CommitReport) AddBoolean(name string, value bool) { + r.Data = append(r.Data, ReportData{ + Title: name, Type: "BOOLEAN", Value: value, + }) +} + +// AddNumber adds a number data to the commit report. +func (r *CommitReport) AddNumber(name string, value int64) { + r.Data = append(r.Data, ReportData{ + Title: name, Type: "NUMBER", Value: value, + }) +} + +// AddPercentage adds a percentage data to the commit report. +func (r *CommitReport) AddPercentage(name string, value float64) { + r.Data = append(r.Data, ReportData{ + Title: name, Type: "PERCENTAGE", Value: value, + }) +} + +// AddDate adds a date data to the commit report. +func (r *CommitReport) AddDate(name string, value time.Time) { + r.Data = append(r.Data, ReportData{ + Title: name, Type: "DATE", Value: value.UnixMilli(), + }) +} + +// AddDuration adds a duration data to the commit report. +func (r *CommitReport) AddDuration(name string, value time.Duration) { + r.Data = append(r.Data, ReportData{ + Title: name, Type: "DURATION", Value: value.Milliseconds(), + }) +} + +// AddLink adds a link data to the commit report. +func (r *CommitReport) AddLink(name string, text string, u *url.URL) { + r.Data = append(r.Data, ReportData{ + Title: name, Type: "LINK", + Value: map[string]string{ + "text": text, "href": u.String(), + }, + }) +}