From 380fde740eb7ede40bd3911185219b1752b0a4a1 Mon Sep 17 00:00:00 2001 From: "Giau. Tran Minh" Date: Thu, 12 Dec 2024 00:43:08 +0700 Subject: [PATCH] atlasaction: added bitbucket client for SCM --- atlasaction/action.go | 42 ++- atlasaction/bitbucket.go | 186 +++++++++++ atlasaction/internal/bitbucket/bitbucket.go | 341 ++++++++++++++++++++ 3 files changed, 553 insertions(+), 16 deletions(-) create mode 100644 atlasaction/internal/bitbucket/bitbucket.go diff --git a/atlasaction/action.go b/atlasaction/action.go index e031d2bf..325f817e 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. } @@ -1086,6 +1092,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 @@ -1094,22 +1118,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 304bb825..5f30ee94 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,185 @@ 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 + } +} + +// SchemaApply implements Reporter. +func (a *bbPipe) SchemaApply(context.Context, *atlasexec.SchemaApply) { +} + +// SchemaPlan implements Reporter. +func (a *bbPipe) SchemaPlan(ctx context.Context, r *atlasexec.SchemaPlan) { + 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.Lint) + 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 + } +} + +// 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.tmpl", r) + if err != nil { + return err + } + return c.comment(ctx, tc.PullRequest, 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.tmpl", p) + if err != nil { + return err + } + return c.comment(ctx, tc.PullRequest, p.File.Name, comment) +} + +func (c *bbClient) comment(ctx context.Context, pr *PullRequest, id, comment string) error { + comments, err := c.PullRequestComments(ctx, pr.Number) + if err != nil { + return err + } + marker := commentMarker(id) + bc := &bitbucket.Comment{Content: bitbucket.Content{Raw: comment + "\n\n" + marker}} + if found := slices.IndexFunc(comments, func(c bitbucket.Comment) bool { + return strings.Contains(c.Content.Raw, marker) + }); found != -1 { + bc.ID = comments[found].ID + return c.PullRequestUpdateComment(ctx, pr.Number, bc) + } + return c.PullRequestCreateComment(ctx, pr.Number, bc) +} + +// 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..a1b7f380 --- /dev/null +++ b/atlasaction/internal/bitbucket/bitbucket.go @@ -0,0 +1,341 @@ +// 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" + "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"` + 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 string `json:"annotation_type"` + Summary string `json:"summary"` + Result Result `json:"result,omitempty"` + Severity string `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"` + } + // Comment is a comment. + Comment struct { + Content Content `json:"content"` + ID int `json:"id,omitempty"` + } + Content 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"` + } + // 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 + } +} + +// 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) error { + if len(r.Data) > 10 { + return fmt.Errorf("bitbucket: maximum of 10 data points allowed") + } + 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[Comment] + if err := json.NewDecoder(res.Body).Decode(&response); err != nil { + return nil, err + } + result = append(result, response.Values...) + 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, c *Comment) error { + buf, err := json.Marshal(c) + if err != nil { + return err + } + u, err := b.repoURL("pullrequests", strconv.Itoa(prID), "comments") + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(buf)) + 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, c *Comment) error { + buf, err := json.Marshal(c) + if err != nil { + return err + } + u, err := b.repoURL("pullrequests", strconv.Itoa(prID), "comments", strconv.Itoa(c.ID)) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, bytes.NewReader(buf)) + 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(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(), + }, + }) +}