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
 ```
 
-</details>
\ No newline at end of file
+</details>
+<!-- generated by ariga/atlas-action for 20241010143904 -->
\ 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
+}