From 02e5011403ebdab508fb0d7eec05ca28b73dc408 Mon Sep 17 00:00:00 2001 From: "Giau. Tran Minh" <12751435+giautm@users.noreply.github.com> Date: Wed, 25 Dec 2024 13:00:47 +0700 Subject: [PATCH] atlasaction: move gitlab client to internal package (#288) --- atlasaction/action.go | 4 +- atlasaction/gitlab_ci.go | 135 +++------------ atlasaction/gitlab_ci_test.go | 97 ++++++----- .../testdata/gitlab/schema-plan-approve.txtar | 6 +- atlasaction/testdata/gitlab/schema-plan.txtar | 7 +- internal/gitlab/gitlab.go | 158 ++++++++++++++++++ 6 files changed, 239 insertions(+), 168 deletions(-) create mode 100644 internal/gitlab/gitlab.go diff --git a/atlasaction/action.go b/atlasaction/action.go index 4cd01b19..957d98bd 100644 --- a/atlasaction/action.go +++ b/atlasaction/action.go @@ -1050,11 +1050,11 @@ func (tc *TriggerContext) SCMClient() (SCMClient, error) { if token == "" { tc.Act.Warningf("GITLAB_TOKEN is not set, the action may not have all the permissions") } - return gitlabClient( + return GitLabClient( tc.Act.Getenv("CI_PROJECT_ID"), tc.SCM.APIURL, token, - ), nil + ) case atlasexec.SCMTypeBitbucket: token := tc.Act.Getenv("BITBUCKET_ACCESS_TOKEN") if token == "" { diff --git a/atlasaction/gitlab_ci.go b/atlasaction/gitlab_ci.go index e2480dec..78dd4c5b 100644 --- a/atlasaction/gitlab_ci.go +++ b/atlasaction/gitlab_ci.go @@ -6,16 +6,14 @@ package atlasaction import ( "context" - "encoding/json" "fmt" "io" - "net/http" "os" "slices" "strconv" "strings" - "time" + "ariga.io/atlas-action/internal/gitlab" "ariga.io/atlas-go-sdk/atlasexec" ) @@ -81,54 +79,32 @@ func (a *gitlabCI) GetTriggerContext(context.Context) (*TriggerContext, error) { return ctx, nil } -var _ Action = (*gitlabCI)(nil) - -type gitlabTransport struct { - Token string -} - -func (t *gitlabTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req.Header.Set("PRIVATE-TOKEN", t.Token) - return http.DefaultTransport.RoundTrip(req) -} - -type gitlabAPI struct { - baseURL string - project string - client *http.Client +type glClient struct { + *gitlab.Client } -func gitlabClient(project, baseURL, token string) *gitlabAPI { - httpClient := &http.Client{Timeout: time.Second * 30} - if token != "" { - httpClient.Transport = &gitlabTransport{Token: token} - } - return &gitlabAPI{ - baseURL: baseURL, - project: project, - client: httpClient, +func GitLabClient(project, baseURL, token string) (*glClient, error) { + c, err := gitlab.NewClient(project, + gitlab.WithBaseURL(baseURL), + gitlab.WithToken(token), + ) + if err != nil { + return nil, err } + return &glClient{Client: c}, nil } -type GitlabComment struct { - ID int `json:"id"` - Body string `json:"body"` - System bool `json:"system"` -} - -var _ SCMClient = (*gitlabAPI)(nil) - // CommentLint implements SCMClient. -func (c *gitlabAPI) CommentLint(ctx context.Context, tc *TriggerContext, r *atlasexec.SummaryReport) error { +func (c *glClient) 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) + return c.upsertComment(ctx, tc.PullRequest, tc.Act.GetInput("dir-name"), comment) } // CommentPlan implements SCMClient. -func (c *gitlabAPI) CommentPlan(ctx context.Context, tc *TriggerContext, p *atlasexec.SchemaPlan) error { +func (c *glClient) CommentPlan(ctx context.Context, tc *TriggerContext, p *atlasexec.SchemaPlan) error { // Report the schema plan to the user and add a comment to the PR. comment, err := RenderTemplate("schema-plan.tmpl", map[string]any{ "Plan": p, @@ -136,86 +112,23 @@ func (c *gitlabAPI) CommentPlan(ctx context.Context, tc *TriggerContext, p *atla if err != nil { return fmt.Errorf("failed to generate schema plan comment: %w", err) } - return c.comment(ctx, tc.PullRequest, p.File.Name, comment) + return c.upsertComment(ctx, tc.PullRequest, p.File.Name, comment) } -func (c *gitlabAPI) comment(ctx context.Context, pr *PullRequest, id, comment string) error { - url := fmt.Sprintf("%v/projects/%v/merge_requests/%v/notes", c.baseURL, c.project, pr.Number) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) +func (c *glClient) upsertComment(ctx context.Context, pr *PullRequest, id, comment string) error { + comments, err := c.PullRequestNotes(ctx, pr.Number) if err != nil { return err } - req.Header.Set("Content-Type", "application/json") - res, err := c.client.Do(req) - if err != nil { - return fmt.Errorf("error querying gitlab comments with %v/%v, %w", c.project, pr.Number, err) - } - defer res.Body.Close() - buf, err := io.ReadAll(res.Body) - if err != nil { - return fmt.Errorf("error reading PR issue comments from %v/%v, %v", c.project, pr.Number, err) - } - if res.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected status code %v when calling Gitlab API. body: %s", res.StatusCode, string(buf)) - } - var comments []GitlabComment - if err = json.Unmarshal(buf, &comments); err != nil { - return fmt.Errorf("error parsing gitlab notes with %v/%v from %v, %w", c.project, pr.Number, string(buf), err) - } - var ( - marker = commentMarker(id) - body = fmt.Sprintf(`{"body": %q}`, comment+"\n"+marker) - ) - if found := slices.IndexFunc(comments, func(c GitlabComment) bool { + marker := commentMarker(id) + comment += "\n" + marker + if found := slices.IndexFunc(comments, func(c gitlab.Note) bool { return !c.System && strings.Contains(c.Body, marker) }); found != -1 { - return c.updateComment(ctx, pr, comments[found].ID, body) + return c.UpdateNote(ctx, pr.Number, comments[found].ID, comment) } - return c.createComment(ctx, pr, comment) + return c.CreateNote(ctx, pr.Number, comment) } -func (c *gitlabAPI) createComment(ctx context.Context, pr *PullRequest, comment string) error { - body := strings.NewReader(fmt.Sprintf(`{"body": %q}`, comment)) - url := fmt.Sprintf("%v/projects/%v/merge_requests/%v/notes", c.baseURL, c.project, pr.Number) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - res, err := c.client.Do(req) - if err != nil { - return err - } - defer res.Body.Close() - if res.StatusCode != http.StatusCreated { - b, err := io.ReadAll(res.Body) - if err != nil { - return fmt.Errorf("unexpected status code %v: unable to read body %v", res.StatusCode, err) - } - return fmt.Errorf("unexpected status code %v: with body: %v", res.StatusCode, string(b)) - } - return err -} - -func (c *gitlabAPI) updateComment(ctx context.Context, pr *PullRequest, NoteId int, comment string) error { - body := strings.NewReader(fmt.Sprintf(`{"body": %q}`, comment)) - url := fmt.Sprintf("%v/projects/%v/merge_requests/%v/notes/%v", c.baseURL, c.project, pr.Number, NoteId) - req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, body) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - res, err := c.client.Do(req) - if err != nil { - return err - } - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - b, err := io.ReadAll(res.Body) - if err != nil { - return fmt.Errorf("unexpected status code %v: unable to read body %v", res.StatusCode, err) - } - return fmt.Errorf("unexpected status code %v: with body: %v", res.StatusCode, string(b)) - } - return err -} +var _ Action = (*gitlabCI)(nil) +var _ SCMClient = (*glClient)(nil) diff --git a/atlasaction/gitlab_ci_test.go b/atlasaction/gitlab_ci_test.go index 3d4704d1..578556af 100644 --- a/atlasaction/gitlab_ci_test.go +++ b/atlasaction/gitlab_ci_test.go @@ -9,19 +9,61 @@ import ( "strconv" "testing" - "ariga.io/atlas-action/atlasaction" + "ariga.io/atlas-action/internal/gitlab" "github.com/gorilla/mux" "github.com/rogpeppe/go-internal/testscript" "github.com/stretchr/testify/require" ) -func newMockHandler(dir string) http.Handler { +func TestGitlabCI(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + testscript.Run(t, testscript.Params{ + Dir: "testdata/gitlab", + Setup: func(e *testscript.Env) error { + commentsDir := filepath.Join(e.WorkDir, "comments") + srv := httptest.NewServer(mockClientHandler(commentsDir, "token")) + if err := os.Mkdir(commentsDir, os.ModePerm); err != nil { + return err + } + e.Defer(srv.Close) + e.Setenv("MOCK_ATLAS", filepath.Join(wd, "mock-atlas.sh")) + e.Setenv("CI_API_V4_URL", srv.URL) + e.Setenv("CI_PROJECT_ID", "1") + e.Setenv("GITLAB_CI", "true") + e.Setenv("GITLAB_TOKEN", "token") + return nil + }, + Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ + "output": func(ts *testscript.TestScript, neg bool, args []string) { + if len(args) == 0 { + _, err := os.Stat(ts.MkAbs(".env")) + if neg { + if !os.IsNotExist(err) { + ts.Fatalf("expected no output, but got some") + } + return + } + if err != nil { + ts.Fatalf("expected output, but got none") + return + } + return + } + cmpFiles(ts, neg, args[0], ".env") + }, + }, + }) +} + +func mockClientHandler(dir, token string) http.Handler { counter := 1 r := mux.NewRouter() r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if tok := r.Header.Get("PRIVATE-TOKEN"); tok != "token" { + if t := r.Header.Get("PRIVATE-TOKEN"); t != token { http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return } next.ServeHTTP(w, r) }) @@ -33,7 +75,7 @@ func newMockHandler(dir string) http.Handler { http.Error(w, err.Error(), http.StatusInternalServerError) return } - comments := make([]*atlasaction.GitlabComment, len(entries)) + comments := make([]*gitlab.Note, len(entries)) for i, e := range entries { b, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { @@ -43,10 +85,7 @@ func newMockHandler(dir string) http.Handler { if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } - comments[i] = &atlasaction.GitlabComment{ - ID: id, - Body: string(b), - } + comments[i] = &gitlab.Note{ID: id, Body: string(b)} } if err = json.NewEncoder(w).Encode(comments); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -63,6 +102,7 @@ func newMockHandler(dir string) http.Handler { } if err := os.WriteFile(filepath.Join(dir, strconv.Itoa(counter)), []byte(body.Body+"\n"), 0666); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) + return } counter++ w.WriteHeader(http.StatusCreated) @@ -86,44 +126,3 @@ func newMockHandler(dir string) http.Handler { }) return r } - -func TestGitlabCI(t *testing.T) { - wd, err := os.Getwd() - require.NoError(t, err) - testscript.Run(t, testscript.Params{ - Dir: "testdata/gitlab", - Setup: func(e *testscript.Env) error { - commentsDir := filepath.Join(e.WorkDir, "comments") - srv := httptest.NewServer(newMockHandler(commentsDir)) - if err := os.Mkdir(commentsDir, os.ModePerm); err != nil { - return err - } - e.Defer(srv.Close) - e.Setenv("MOCK_ATLAS", filepath.Join(wd, "mock-atlas.sh")) - e.Setenv("CI_API_V4_URL", srv.URL) - e.Setenv("CI_PROJECT_ID", "1") - e.Setenv("GITLAB_CI", "true") - e.Setenv("GITLAB_TOKEN", "token") - return nil - }, - Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ - "output": func(ts *testscript.TestScript, neg bool, args []string) { - if len(args) == 0 { - _, err := os.Stat(ts.MkAbs(".env")) - if neg { - if !os.IsNotExist(err) { - ts.Fatalf("expected no output, but got some") - } - return - } - if err != nil { - ts.Fatalf("expected output, but got none") - return - } - return - } - cmpFiles(ts, neg, args[0], ".env") - }, - }, - }) -} diff --git a/atlasaction/testdata/gitlab/schema-plan-approve.txtar b/atlasaction/testdata/gitlab/schema-plan-approve.txtar index edaeff43..7741ce1c 100644 --- a/atlasaction/testdata/gitlab/schema-plan-approve.txtar +++ b/atlasaction/testdata/gitlab/schema-plan-approve.txtar @@ -7,7 +7,7 @@ stdout 'No schema plan found' # One pending plan. atlas-action --action=schema/plan/approve -cmp .env expected.env +output expected-output.env # Multiple pending plans. ! atlas-action --action=schema/plan/approve @@ -15,9 +15,9 @@ stdout 'No plan URL provided, searching for the pending plan' stdout 'Found schema plan: atlas://plans/1234' stdout 'Found schema plan: atlas://plans/5678' stdout 'found multiple schema plans, please approve or delete the existing plans' -cmp .env expected.env +output expected-output.env --- expected.env -- +-- expected-output.env -- link=https://test.atlasgo.cloud/schemas/123/plans/456 plan=atlas://plans/1234 status= diff --git a/atlasaction/testdata/gitlab/schema-plan.txtar b/atlasaction/testdata/gitlab/schema-plan.txtar index d3acb919..8d72fad2 100644 --- a/atlasaction/testdata/gitlab/schema-plan.txtar +++ b/atlasaction/testdata/gitlab/schema-plan.txtar @@ -15,9 +15,9 @@ atlas-action --action=schema/plan stdout 'Schema plan does not exist, creating a new one with name "pr-1-3RRRcLHF"' cmp comments-expected/1 comments/1 +output expected-output.env -cmp .env expected.env --- expected.env -- +-- expected-output.env -- link=http://test.atlasgo.cloud/schemas/141733920769/plans/210453397511 plan=atlas://app/plans/20241010143904 status=PENDING @@ -160,4 +160,5 @@ the database with the desired state. Otherwise, Atlas will report a schema drift atlas schema plan push --pending --file 20241010143904.plan.hcl ``` - \ No newline at end of file + + \ No newline at end of file diff --git a/internal/gitlab/gitlab.go b/internal/gitlab/gitlab.go new file mode 100644 index 00000000..e281888d --- /dev/null +++ b/internal/gitlab/gitlab.go @@ -0,0 +1,158 @@ +// 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 gitlab + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type ( + Client struct { + baseURL string + project string + client *http.Client + } + ClientOption func(*Client) error + Note struct { + ID int `json:"id"` + Body string `json:"body"` + System bool `json:"system"` + } + PrivateToken struct { + Token string + Base http.RoundTripper + } +) + +const DefaultBaseURL = "https://gitlab.com/api/v4" + +// WithBaseURL sets the base URL for the Gitlab client. +func WithBaseURL(url string) ClientOption { + return func(c *Client) error { + c.baseURL = url + return nil + } +} + +// WithToken sets the private token for the Gitlab client. +func WithToken(token string) ClientOption { + return func(c *Client) error { + c.client.Transport = &PrivateToken{Token: token, Base: c.client.Transport} + return nil + } +} + +func NewClient(project string, opts ...ClientOption) (*Client, error) { + c := &Client{ + baseURL: DefaultBaseURL, + project: project, + client: &http.Client{Timeout: time.Second * 30}, + } + for _, opt := range opts { + if err := opt(c); err != nil { + return nil, err + } + } + return c, nil +} + +func (c *Client) PullRequestNotes(ctx context.Context, prID int) ([]Note, error) { + url := fmt.Sprintf("%v/projects/%v/merge_requests/%v/notes", c.baseURL, c.project, prID) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + res, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("error querying gitlab comments with %v/%v, %w", c.project, prID, err) + } + defer res.Body.Close() + buf, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("error reading PR issue comments from %v/%v, %v", c.project, prID, err) + } + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code %v when calling Gitlab API. body: %s", res.StatusCode, string(buf)) + } + var comments []Note + if err = json.Unmarshal(buf, &comments); err != nil { + return nil, fmt.Errorf("error parsing gitlab notes with %v/%v from %v, %w", c.project, prID, string(buf), err) + } + return comments, nil +} + +func (c *Client) CreateNote(ctx context.Context, prID int, comment string) error { + body := strings.NewReader(fmt.Sprintf(`{"body":%q}`, comment)) + url := fmt.Sprintf("%v/projects/%v/merge_requests/%v/notes", c.baseURL, c.project, prID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + res, err := c.client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusCreated { + b, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("unexpected status code %v: unable to read body %v", res.StatusCode, err) + } + return fmt.Errorf("unexpected status code %v: with body: %v", res.StatusCode, string(b)) + } + return err +} + +func (c *Client) UpdateNote(ctx context.Context, prID int, noteID int, comment string) error { + body := strings.NewReader(fmt.Sprintf(`{"body":%q}`, comment)) + url := fmt.Sprintf("%v/projects/%v/merge_requests/%v/notes/%v", c.baseURL, c.project, prID, noteID) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, body) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + res, err := c.client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + b, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("unexpected status code %v: unable to read body %v", res.StatusCode, err) + } + return fmt.Errorf("unexpected status code %v: with body: %v", res.StatusCode, string(b)) + } + return err +} + +func (t *PrivateToken) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("PRIVATE-TOKEN", t.Token) + return t.base().RoundTrip(req) +} + +func (t *PrivateToken) CancelRequest(req *http.Request) { + type canceler interface { + CancelRequest(*http.Request) + } + if tr, ok := t.Base.(canceler); ok { + tr.CancelRequest(req) + } +} + +func (t *PrivateToken) base() http.RoundTripper { + if t.Base != nil { + return t.Base + } + return http.DefaultTransport +}