Skip to content

Commit

Permalink
atlasaction: move gitlab client to internal package (#288)
Browse files Browse the repository at this point in the history
  • Loading branch information
giautm authored Dec 25, 2024
1 parent 254cfc4 commit 02e5011
Show file tree
Hide file tree
Showing 6 changed files with 239 additions and 168 deletions.
4 changes: 2 additions & 2 deletions atlasaction/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
135 changes: 24 additions & 111 deletions atlasaction/gitlab_ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -81,141 +79,56 @@ 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,
})
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)
97 changes: 48 additions & 49 deletions atlasaction/gitlab_ci_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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")
},
},
})
}
6 changes: 3 additions & 3 deletions atlasaction/testdata/gitlab/schema-plan-approve.txtar
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,17 @@ 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
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=
Expand Down
7 changes: 4 additions & 3 deletions atlasaction/testdata/gitlab/schema-plan.txtar
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

</details>
</details>
<!-- generated by ariga/atlas-action for 20241010143904 -->
Loading

0 comments on commit 02e5011

Please sign in to comment.