diff --git a/.changes/unreleased/Changed-20250829-213725.yaml b/.changes/unreleased/Changed-20250829-213725.yaml new file mode 100644 index 00000000..5e362b8e --- /dev/null +++ b/.changes/unreleased/Changed-20250829-213725.yaml @@ -0,0 +1,7 @@ +kind: Changed +body: >- + gitlab: + Submit Merge Requests with `remove_source_branch=true`. + This will delete the source branch when the MR is merged. + Opt out of this behavior with the `spice.forge.gitlab.removeSourceBranch` option. +time: 2025-08-29T21:37:25.235962-07:00 diff --git a/doc/includes/cli-reference.md b/doc/includes/cli-reference.md index 66c6fd9f..2398dd2b 100644 --- a/doc/includes/cli-reference.md +++ b/doc/includes/cli-reference.md @@ -12,7 +12,7 @@ gs (git-spice) is a command line tool for stacking Git branches. * `-C`, `--dir=DIR`: Change to DIR before doing anything * `--[no-]prompt`: Whether to prompt for missing information -**Configuration**: [spice.forge.github.apiUrl](/cli/config.md#spiceforgegithubapiurl), [spice.forge.github.url](/cli/config.md#spiceforgegithuburl), [spice.forge.gitlab.apiURL](/cli/config.md#spiceforgegitlabapiurl), [spice.forge.gitlab.oauth.clientID](/cli/config.md#spiceforgegitlaboauthclientid), [spice.forge.gitlab.url](/cli/config.md#spiceforgegitlaburl) +**Configuration**: [spice.forge.github.apiUrl](/cli/config.md#spiceforgegithubapiurl), [spice.forge.github.url](/cli/config.md#spiceforgegithuburl), [spice.forge.gitlab.apiURL](/cli/config.md#spiceforgegitlabapiurl), [spice.forge.gitlab.oauth.clientID](/cli/config.md#spiceforgegitlaboauthclientid), [spice.forge.gitlab.removeSourceBranch](/cli/config.md#spiceforgegitlabremovesourcebranch), [spice.forge.gitlab.url](/cli/config.md#spiceforgegitlaburl) ## Shell diff --git a/doc/src/cli/config.md b/doc/src/cli/config.md index 150af52f..7fc2341d 100644 --- a/doc/src/cli/config.md +++ b/doc/src/cli/config.md @@ -172,6 +172,17 @@ For Self-Hosted GitLab instances, you must set this value to a custom Client ID. See also [GitLab Self-Hosted](../setup/auth.md#gitlab-self-hosted). +### spice.forge.gitlab.removeSourceBranch + + + +Whether to remove the source branch when a Merge Request is merged. + +**Accepted values:** + +- `true` (default) +- `false` + ### spice.log.all Whether $$gs log short$$ and $$gs log long$$ should show all stacks by default, diff --git a/internal/fixturetest/fixturetest.go b/internal/fixturetest/fixturetest.go index e84333fa..9da1d455 100644 --- a/internal/fixturetest/fixturetest.go +++ b/internal/fixturetest/fixturetest.go @@ -44,11 +44,10 @@ type TestingT interface { // Config configures the behavior of the fixture system. type Config struct { - // Update is a pointer to a value speciftying - // whether we're in update mode or read mode. + // Update reports whether we're in update more or read mode. // // This must not be nil. - Update *bool // required + Update func() bool // required } // Fixture is a value that is sourced from a function in update mode, @@ -71,7 +70,7 @@ func (f Fixture[T]) Get(t TestingT) T { t.Helper() fpath := filepath.Join("testdata", t.Name(), f.name) - if *f.cfg.Update { + if f.cfg.Update() { v := f.gen() require.NoError(t, os.MkdirAll(filepath.Dir(fpath), 0o755)) diff --git a/internal/fixturetest/fixturetest_test.go b/internal/fixturetest/fixturetest_test.go index 80d8bc4f..9f0d8e07 100644 --- a/internal/fixturetest/fixturetest_test.go +++ b/internal/fixturetest/fixturetest_test.go @@ -12,19 +12,21 @@ import ( func TestFixture(t *testing.T) { t.Chdir(t.TempDir()) - cfg := fixturetest.Config{Update: new(bool)} + var giveUpdate bool + + cfg := fixturetest.Config{Update: func() bool { return giveUpdate }} fixture := fixturetest.New(cfg, "number", rand.Int) // Initial generation. - *cfg.Update = true + giveUpdate = true v1 := fixture.Get(t) // Read from disk. - *cfg.Update = false + giveUpdate = false assert.Equal(t, v1, fixture.Get(t)) // Update again. - *cfg.Update = true + giveUpdate = true // At least one attempt out of N should succeed. assert.EventuallyWithT(t, func(t *assert.CollectT) { v2 := fixture.Get(collectTAdapter{t, "Update"}) diff --git a/internal/forge/github/flag_test.go b/internal/forge/github/flag_test.go index 671f30fe..3f69422c 100644 --- a/internal/forge/github/flag_test.go +++ b/internal/forge/github/flag_test.go @@ -2,13 +2,12 @@ package github import "flag" -var UpdateFixtures *bool +func UpdateFixtures() bool { + return flag.Lookup("update").Value.(flag.Getter).Get().(bool) +} func init() { - if updateFlag := flag.Lookup("update"); updateFlag != nil { - value := updateFlag.Value.(flag.Getter).Get().(bool) - UpdateFixtures = &value - } else { - UpdateFixtures = flag.Bool("update", false, "update test fixtures") + if flag.Lookup("update") == nil { + flag.Bool("update", false, "update test fixtures") } } diff --git a/internal/forge/github/integration_test.go b/internal/forge/github/integration_test.go index fb2b2d0b..d735c52e 100644 --- a/internal/forge/github/integration_test.go +++ b/internal/forge/github/integration_test.go @@ -31,10 +31,7 @@ import ( // This file tests basic, end-to-end interactions with the GitHub API // using recorded fixtures. -var ( - _update = github.UpdateFixtures - _fixtures = fixturetest.Config{Update: _update} -) +var _fixtures = fixturetest.Config{Update: github.UpdateFixtures} // To avoid looking this up for every test that needs the repo ID, // we'll just hardcode it here. @@ -52,7 +49,7 @@ func newRecorder(t *testing.T, name string) *recorder.Recorder { }) return httptest.NewTransportRecorder(t, name, httptest.TransportRecorderOptions{ - Update: _update, + Update: github.UpdateFixtures, WrapRealTransport: func(t testing.TB, transport http.RoundTripper) http.RoundTripper { githubToken := os.Getenv("GITHUB_TOKEN") require.NotEmpty(t, githubToken, @@ -247,7 +244,7 @@ func TestIntegration_Repository_SubmitEditChange(t *testing.T) { gitRepo *git.Repository // only when _update is true gitWork *git.Worktree ) - if *_update { + if github.UpdateFixtures() { t.Setenv("GIT_AUTHOR_EMAIL", "bot@example.com") t.Setenv("GIT_AUTHOR_NAME", "gs-test[bot]") t.Setenv("GIT_COMMITTER_EMAIL", "bot@example.com") @@ -331,7 +328,7 @@ func TestIntegration_Repository_SubmitEditChange(t *testing.T) { newBase := newBaseFixture.Get(t) t.Logf("Pushing new base: %s", newBase) - if *_update { + if github.UpdateFixtures() { require.NoError(t, gitWork.Push(t.Context(), git.PushOptions{ Remote: "origin", @@ -409,7 +406,7 @@ func TestIntegration_Repository_SubmitEditChange_labels(t *testing.T) { gitRepo *git.Repository // only when _update is true gitWork *git.Worktree ) - if *_update { + if github.UpdateFixtures() { t.Setenv("GIT_AUTHOR_EMAIL", "bot@example.com") t.Setenv("GIT_AUTHOR_NAME", "gs-test[bot]") t.Setenv("GIT_COMMITTER_EMAIL", "bot@example.com") @@ -566,7 +563,7 @@ func TestIntegration_Repository_SubmitChange_baseBranchDoesNotExist(t *testing.T gitRepo *git.Repository // only when _update is true gitWork *git.Worktree ) - if *_update { + if github.UpdateFixtures() { t.Setenv("GIT_AUTHOR_EMAIL", "bot@example.com") t.Setenv("GIT_AUTHOR_NAME", "gs-test[bot]") t.Setenv("GIT_COMMITTER_EMAIL", "bot@example.com") diff --git a/internal/forge/gitlab/comment_test.go b/internal/forge/gitlab/comment_test.go index 38c588c2..62626085 100644 --- a/internal/forge/gitlab/comment_test.go +++ b/internal/forge/gitlab/comment_test.go @@ -162,7 +162,7 @@ func TestListChangeComments(t *testing.T) { "owner", "repo", silogtest.New(t), client, - &repoID, + &repositoryOptions{RepositoryID: &repoID}, ) require.NoError(t, err) diff --git a/internal/forge/gitlab/flag_test.go b/internal/forge/gitlab/flag_test.go index a67fbbd7..55a8a8ad 100644 --- a/internal/forge/gitlab/flag_test.go +++ b/internal/forge/gitlab/flag_test.go @@ -2,13 +2,12 @@ package gitlab import "flag" -var UpdateFixtures *bool +func UpdateFixtures() bool { + return flag.Lookup("update").Value.(flag.Getter).Get().(bool) +} func init() { - if updateFlag := flag.Lookup("update"); updateFlag != nil { - value := updateFlag.Value.(flag.Getter).Get().(bool) - UpdateFixtures = &value - } else { - UpdateFixtures = flag.Bool("update", false, "update test fixtures") + if flag.Lookup("update") == nil { + flag.Bool("update", false, "update test fixtures") } } diff --git a/internal/forge/gitlab/forge.go b/internal/forge/gitlab/forge.go index bed0e532..15034cff 100644 --- a/internal/forge/gitlab/forge.go +++ b/internal/forge/gitlab/forge.go @@ -38,6 +38,10 @@ type Options struct { // ClientID is the OAuth client ID for GitLab OAuth device flow. // This should be used if the GitLab instance is Self Managed. ClientID string `name:"gitlab-oauth-client-id" hidden:"" env:"GITLAB_OAUTH_CLIENT_ID" config:"forge.gitlab.oauth.clientID" help:"GitLab OAuth client ID"` + + // RemoveSourceBranch specifies whether a branch should be deleted + // after its Merge Request is merged. + RemoveSourceBranch bool `name:"gitlab-remove-source-branch" hidden:"" config:"forge.gitlab.removeSourceBranch" default:"true" help:"Remove source branch after merging a merge request"` } // Forge builds a GitLab Forge. @@ -101,7 +105,9 @@ func (f *Forge) OpenRepository(ctx context.Context, token forge.AuthenticationTo return nil, fmt.Errorf("create GitLab client: %w", err) } - return newRepository(ctx, f, rid.owner, rid.name, f.logger(), glc, nil) + return newRepository(ctx, f, rid.owner, rid.name, f.logger(), glc, &repositoryOptions{ + RemoveSourceBranchOnMerge: f.Options.RemoveSourceBranch, + }) } // RepositoryID is a unique identifier for a GitLab repository. diff --git a/internal/forge/gitlab/integration_test.go b/internal/forge/gitlab/integration_test.go index ae501b8b..65cde76f 100644 --- a/internal/forge/gitlab/integration_test.go +++ b/internal/forge/gitlab/integration_test.go @@ -107,7 +107,13 @@ func TestIntegration_Repository_FindChangeByID(t *testing.T) { ctx := t.Context() rec := newRecorder(t, t.Name()) ghc := newGitLabClient(rec.GetDefaultClient()) - repo, err := gitlab.NewRepository(ctx, new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), ghc, _testRepoID) + repo, err := gitlab.NewRepository( + ctx, + new(gitlab.Forge), + "abg", "test-repo", + silogtest.New(t), ghc, + &gitlab.RepositoryOptions{RepositoryID: _testRepoID}, + ) require.NoError(t, err) t.Run("found", func(t *testing.T) { @@ -140,7 +146,11 @@ func TestIntegration_Repository_FindChangesByBranch(t *testing.T) { ctx := t.Context() rec := newRecorder(t, t.Name()) ghc := newGitLabClient(rec.GetDefaultClient()) - repo, err := gitlab.NewRepository(ctx, new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), ghc, _testRepoID) + repo, err := gitlab.NewRepository( + ctx, + new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), ghc, + &gitlab.RepositoryOptions{RepositoryID: _testRepoID}, + ) require.NoError(t, err) t.Run("found", func(t *testing.T) { @@ -174,7 +184,12 @@ func TestIntegration_Repository_ChangesStates(t *testing.T) { ctx := t.Context() rec := newRecorder(t, t.Name()) ghc := newGitLabClient(rec.GetDefaultClient()) - repo, err := gitlab.NewRepository(ctx, new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), ghc, _testRepoID) + repo, err := gitlab.NewRepository( + ctx, + new(gitlab.Forge), + "abg", "test-repo", silogtest.New(t), ghc, + &gitlab.RepositoryOptions{RepositoryID: _testRepoID}, + ) require.NoError(t, err) states, err := repo.ChangesStates(ctx, []forge.ChangeID{ @@ -197,7 +212,11 @@ func TestIntegration_Repository_ListChangeTemplates(t *testing.T) { ctx := t.Context() rec := newRecorder(t, t.Name()) ghc := newGitLabClient(rec.GetDefaultClient()) - repo, err := gitlab.NewRepository(ctx, new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), ghc, _testRepoID) + repo, err := gitlab.NewRepository( + ctx, new(gitlab.Forge), "abg", "test-repo", + silogtest.New(t), ghc, + &gitlab.RepositoryOptions{RepositoryID: _testRepoID}, + ) require.NoError(t, err) templates, err := repo.ListChangeTemplates(ctx) @@ -227,7 +246,12 @@ func TestIntegration_Repository_NewChangeMetadata(t *testing.T) { rec := newRecorder(t, t.Name()) ghc := newGitLabClient(rec.GetDefaultClient()) - repo, err := gitlab.NewRepository(ctx, new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), ghc, _testRepoID) + repo, err := gitlab.NewRepository( + ctx, + new(gitlab.Forge), "abg", "test-repo", + silogtest.New(t), ghc, + &gitlab.RepositoryOptions{RepositoryID: _testRepoID}, + ) require.NoError(t, err) t.Run("valid", func(t *testing.T) { @@ -261,7 +285,7 @@ func TestIntegration_Repository_SubmitEditChange(t *testing.T) { gitRepo *git.Repository // only when _update is true gitWork *git.Worktree ) - if *_update { + if gitlab.UpdateFixtures() { t.Setenv("GIT_AUTHOR_EMAIL", "bot@example.com") t.Setenv("GIT_AUTHOR_NAME", "gs-test[bot]") t.Setenv("GIT_COMMITTER_EMAIL", "bot@example.com") @@ -324,7 +348,7 @@ func TestIntegration_Repository_SubmitEditChange(t *testing.T) { rec := newRecorder(t, t.Name()) ghc := newGitLabClient(rec.GetDefaultClient()) repo, err := gitlab.NewRepository( - ctx, new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), ghc, _testRepoID, + ctx, new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), ghc, &gitlab.RepositoryOptions{RepositoryID: _testRepoID}, ) require.NoError(t, err) @@ -345,7 +369,7 @@ func TestIntegration_Repository_SubmitEditChange(t *testing.T) { newBase := newBaseFixture.Get(t) t.Logf("Pushing new base: %s", newBase) - if *_update { + if gitlab.UpdateFixtures() { require.NoError(t, gitWork.Push(ctx, git.PushOptions{ Remote: "origin", @@ -424,7 +448,7 @@ func TestIntegration_Repository_SubmitEditChange_labels(t *testing.T) { gitRepo *git.Repository // only when _update is true gitWork *git.Worktree ) - if *_update { + if gitlab.UpdateFixtures() { t.Setenv("GIT_AUTHOR_EMAIL", "bot@example.com") t.Setenv("GIT_AUTHOR_NAME", "gs-test[bot]") t.Setenv("GIT_COMMITTER_EMAIL", "bot@example.com") @@ -490,7 +514,7 @@ func TestIntegration_Repository_SubmitEditChange_labels(t *testing.T) { rec := newRecorder(t, t.Name()) ghc := newGitLabClient(rec.GetDefaultClient()) repo, err := gitlab.NewRepository( - ctx, new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), ghc, _testRepoID, + ctx, new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), ghc, &gitlab.RepositoryOptions{RepositoryID: _testRepoID}, ) require.NoError(t, err) @@ -528,7 +552,7 @@ func TestIntegration_Repository_comments(t *testing.T) { rec := newRecorder(t, t.Name()) ghc := newGitLabClient(rec.GetDefaultClient()) repo, err := gitlab.NewRepository( - ctx, new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), ghc, _testRepoID, + ctx, new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), ghc, &gitlab.RepositoryOptions{RepositoryID: _testRepoID}, ) require.NoError(t, err) @@ -567,7 +591,7 @@ func TestIntegration_Repository_ListChangeComments_simple(t *testing.T) { rec := newRecorder(t, t.Name()) ghc := newGitLabClient(rec.GetDefaultClient()) repo, err := gitlab.NewRepository( - ctx, new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), ghc, _testRepoID, + ctx, new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), ghc, &gitlab.RepositoryOptions{RepositoryID: _testRepoID}, ) require.NoError(t, err) @@ -611,7 +635,7 @@ func TestIntegration_Repository_ListChangeComments_paginated(t *testing.T) { rec := newRecorder(t, t.Name()) ghc := newGitLabClient(rec.GetDefaultClient()) repo, err := gitlab.NewRepository( - ctx, new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), ghc, _testRepoID, + ctx, new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), ghc, &gitlab.RepositoryOptions{RepositoryID: _testRepoID}, ) require.NoError(t, err) @@ -663,6 +687,108 @@ func TestIntegration_Repository_notFoundError(t *testing.T) { assert.ErrorContains(t, err, "404 Not Found") } +func TestIntegration_Repository_SubmitChange_removeSourceBranch(t *testing.T) { + ctx := t.Context() + branchFixture := fixturetest.New(_fixtures, "branch", func() string { + return randomString(8) + }) + + branchName := branchFixture.Get(t) + t.Logf("Creating branch: %s", branchName) + + var ( + gitRepo *git.Repository // only when _update is true + gitWork *git.Worktree + ) + if gitlab.UpdateFixtures() { + t.Setenv("GIT_AUTHOR_EMAIL", "bot@example.com") + t.Setenv("GIT_AUTHOR_NAME", "gs-test[bot]") + t.Setenv("GIT_COMMITTER_EMAIL", "bot@example.com") + t.Setenv("GIT_COMMITTER_NAME", "gs-test[bot]") + + output := ioutil.TestLogWriter(t, "[git] ") + + t.Logf("Cloning test-repo...") + repoDir := t.TempDir() + cmd := exec.Command("git", "clone", "git@gitlab.com:abg/test-repo.git", repoDir) + cmd.Stdout = output + cmd.Stderr = output + require.NoError(t, cmd.Run(), "failed to clone test-repo") + + var err error + gitWork, err = git.OpenWorktree(ctx, repoDir, git.OpenOptions{ + Log: silogtest.New(t), + }) + require.NoError(t, err, "failed to open git repo") + gitRepo = gitWork.Repository() + + require.NoError(t, gitRepo.CreateBranch(ctx, git.CreateBranchRequest{ + Name: branchName, + }), "could not create branch: %s", branchName) + require.NoError(t, gitWork.Checkout(ctx, branchName), + "could not checkout branch: %s", branchName) + require.NoError(t, os.WriteFile( + filepath.Join(repoDir, branchName+".txt"), + []byte(randomString(32)), + 0o644, + ), "could not write file to branch") + + cmd = exec.Command("git", "add", ".") + cmd.Dir = repoDir + cmd.Stdout = output + cmd.Stderr = output + require.NoError(t, cmd.Run(), "git add failed") + require.NoError(t, gitWork.Commit(ctx, git.CommitRequest{ + Message: "commit from test", + }), "could not commit changes") + + t.Logf("Pushing to origin") + require.NoError(t, + gitWork.Push(ctx, git.PushOptions{ + Remote: "origin", + Refspec: git.Refspec(branchName), + }), "error pushing branch") + + t.Cleanup(func() { + ctx := context.WithoutCancel(t.Context()) + t.Logf("Deleting remote branch: %s", branchName) + assert.NoError(t, + gitWork.Push(ctx, git.PushOptions{ + Remote: "origin", + Refspec: git.Refspec(":" + branchName), + }), "error deleting branch") + }) + } + + rec := newRecorder(t, t.Name()) + ghc := newGitLabClient(rec.GetDefaultClient()) + repo, err := gitlab.NewRepository( + ctx, new(gitlab.Forge), "abg", "test-repo", silogtest.New(t), ghc, + &gitlab.RepositoryOptions{ + RepositoryID: _testRepoID, + RemoveSourceBranchOnMerge: true, + }, + ) + require.NoError(t, err) + + change, err := repo.SubmitChange(ctx, forge.SubmitChangeRequest{ + Subject: branchName, + Body: "Test MR with RemoveSourceBranch option", + Base: "main", + Head: branchName, + }) + require.NoError(t, err, "error creating MR") + + mrID := change.ID.(*gitlab.MR) + mr, _, err := ghc.MergeRequests.GetMergeRequest( + *_testRepoID, mrID.Number, nil, + gogitlab.WithContext(ctx), + ) + require.NoError(t, err, "error fetching created MR") + assert.True(t, mr.ForceRemoveSourceBranch, + "RemoveSourceBranch should be true on created MR") +} + const _alnum = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" // randomString generates a random alphanumeric string of length n. diff --git a/internal/forge/gitlab/repository.go b/internal/forge/gitlab/repository.go index 8cea26f1..5fc7b65f 100644 --- a/internal/forge/gitlab/repository.go +++ b/internal/forge/gitlab/repository.go @@ -1,6 +1,7 @@ package gitlab import ( + "cmp" "context" "fmt" "strconv" @@ -23,18 +24,29 @@ type Repository struct { // Information about the current user: userID int userRole gitlab.AccessLevelValue + + removeSourceBranchOnMerge bool } var _ forge.Repository = (*Repository)(nil) +type repositoryOptions struct { + RepositoryID *int // if nil, repository ID will be looked up + + RemoveSourceBranchOnMerge bool +} + func newRepository( ctx context.Context, forge *Forge, owner, repo string, log *silog.Logger, client *gitlabClient, - repoID *int, // if nil, repository ID will be looked up + opts *repositoryOptions, ) (*Repository, error) { + opts = cmp.Or(opts, &repositoryOptions{}) + repoID := opts.RepositoryID + var projectIdentifier string if repoID != nil { projectIdentifier = strconv.Itoa(*repoID) @@ -71,6 +83,8 @@ func newRepository( userID: user.ID, userRole: accessLevel, repoID: project.ID, + + removeSourceBranchOnMerge: opts.RemoveSourceBranchOnMerge, }, nil } diff --git a/internal/forge/gitlab/repository_int_test.go b/internal/forge/gitlab/repository_int_test.go index 26cdb90a..b6e2b32a 100644 --- a/internal/forge/gitlab/repository_int_test.go +++ b/internal/forge/gitlab/repository_int_test.go @@ -3,3 +3,6 @@ package gitlab // NewRepository re-exports the private NewRepository function // for testing. var NewRepository = newRepository + +// RepositoryOptions re-exports the private repositoryOptions type +type RepositoryOptions = repositoryOptions diff --git a/internal/forge/gitlab/submit.go b/internal/forge/gitlab/submit.go index ec40af96..07546ed1 100644 --- a/internal/forge/gitlab/submit.go +++ b/internal/forge/gitlab/submit.go @@ -19,6 +19,9 @@ func (r *Repository) SubmitChange(ctx context.Context, req forge.SubmitChangeReq TargetBranch: &req.Base, SourceBranch: &req.Head, } + if r.removeSourceBranchOnMerge { + input.RemoveSourceBranch = gitlab.Ptr(true) + } if req.Body != "" { input.Description = &req.Body } diff --git a/internal/forge/gitlab/testdata/TestIntegration_Repository_SubmitChange_removeSourceBranch/branch b/internal/forge/gitlab/testdata/TestIntegration_Repository_SubmitChange_removeSourceBranch/branch new file mode 100644 index 00000000..1bebc5c2 --- /dev/null +++ b/internal/forge/gitlab/testdata/TestIntegration_Repository_SubmitChange_removeSourceBranch/branch @@ -0,0 +1 @@ +"KBZMSKvC" \ No newline at end of file diff --git a/internal/forge/gitlab/testdata/fixtures/TestIntegration_Repository_SubmitChange_removeSourceBranch.yaml b/internal/forge/gitlab/testdata/fixtures/TestIntegration_Repository_SubmitChange_removeSourceBranch.yaml new file mode 100644 index 00000000..b7c29f96 --- /dev/null +++ b/internal/forge/gitlab/testdata/fixtures/TestIntegration_Repository_SubmitChange_removeSourceBranch.yaml @@ -0,0 +1,107 @@ +--- +version: 2 +interactions: +- id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + User-Agent: + - go-gitlab + url: https://gitlab.com/api/v4/projects/64779801 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: "{\"id\":64779801,\"description\":null,\"name\":\"test-repo\",\"name_with_namespace\":\"Abhinav Gupta / test-repo\",\"path\":\"test-repo\",\"path_with_namespace\":\"abg/test-repo\",\"created_at\":\"2024-11-23T16:35:46.252Z\",\"default_branch\":\"main\",\"tag_list\":[],\"topics\":[],\"ssh_url_to_repo\":\"git@gitlab.com:abg/test-repo.git\",\"http_url_to_repo\":\"https://gitlab.com/abg/test-repo.git\",\"web_url\":\"https://gitlab.com/abg/test-repo\",\"readme_url\":null,\"forks_count\":0,\"avatar_url\":null,\"star_count\":0,\"last_activity_at\":\"2025-08-01T05:01:19.569Z\",\"visibility\":\"public\",\"namespace\":{\"id\":1117393,\"name\":\"Abhinav Gupta\",\"path\":\"abg\",\"kind\":\"user\",\"full_path\":\"abg\",\"parent_id\":null,\"avatar_url\":\"https://secure.gravatar.com/avatar/e9a34bfd0e7f9ab63137b7653f656daaddbb65e84d3ef0852febd4c3e889a835?s=80\\u0026d=identicon\",\"web_url\":\"https://gitlab.com/abg\"},\"container_registry_image_prefix\":\"registry.gitlab.com/abg/test-repo\",\"_links\":{\"self\":\"https://gitlab.com/api/v4/projects/64779801\",\"merge_requests\":\"https://gitlab.com/api/v4/projects/64779801/merge_requests\",\"repo_branches\":\"https://gitlab.com/api/v4/projects/64779801/repository/branches\",\"labels\":\"https://gitlab.com/api/v4/projects/64779801/labels\",\"events\":\"https://gitlab.com/api/v4/projects/64779801/events\",\"members\":\"https://gitlab.com/api/v4/projects/64779801/members\",\"cluster_agents\":\"https://gitlab.com/api/v4/projects/64779801/cluster_agents\"},\"marked_for_deletion_at\":null,\"marked_for_deletion_on\":null,\"packages_enabled\":false,\"empty_repo\":false,\"archived\":false,\"owner\":{\"id\":930270,\"username\":\"abg\",\"public_email\":\"\",\"name\":\"Abhinav Gupta\",\"state\":\"active\",\"locked\":false,\"avatar_url\":\"https://secure.gravatar.com/avatar/e9a34bfd0e7f9ab63137b7653f656daaddbb65e84d3ef0852febd4c3e889a835?s=80\\u0026d=identicon\",\"web_url\":\"https://gitlab.com/abg\"},\"resolve_outdated_diff_discussions\":false,\"container_expiration_policy\":{\"cadence\":\"1d\",\"enabled\":false,\"keep_n\":10,\"older_than\":\"90d\",\"name_regex\":\".*\",\"name_regex_keep\":null,\"next_run_at\":\"2024-11-24T16:35:46.274Z\"},\"repository_object_format\":\"sha1\",\"issues_enabled\":false,\"merge_requests_enabled\":true,\"wiki_enabled\":false,\"jobs_enabled\":false,\"snippets_enabled\":false,\"container_registry_enabled\":false,\"service_desk_enabled\":true,\"can_create_merge_request_in\":true,\"issues_access_level\":\"disabled\",\"repository_access_level\":\"enabled\",\"merge_requests_access_level\":\"enabled\",\"forking_access_level\":\"enabled\",\"wiki_access_level\":\"disabled\",\"builds_access_level\":\"disabled\",\"snippets_access_level\":\"disabled\",\"pages_access_level\":\"disabled\",\"analytics_access_level\":\"disabled\",\"container_registry_access_level\":\"disabled\",\"security_and_compliance_access_level\":\"disabled\",\"releases_access_level\":\"disabled\",\"environments_access_level\":\"disabled\",\"feature_flags_access_level\":\"disabled\",\"infrastructure_access_level\":\"disabled\",\"monitor_access_level\":\"disabled\",\"model_experiments_access_level\":\"disabled\",\"model_registry_access_level\":\"disabled\",\"emails_disabled\":true,\"emails_enabled\":false,\"show_diff_preview_in_email\":false,\"shared_runners_enabled\":true,\"lfs_enabled\":false,\"creator_id\":930270,\"import_url\":null,\"import_type\":null,\"import_status\":\"none\",\"import_error\":null,\"description_html\":\"\",\"updated_at\":\"2025-08-01T05:16:50.109Z\",\"ci_default_git_depth\":20,\"ci_delete_pipelines_in_seconds\":null,\"ci_forward_deployment_enabled\":true,\"ci_forward_deployment_rollback_allowed\":true,\"ci_job_token_scope_enabled\":false,\"ci_separated_caches\":true,\"ci_allow_fork_pipelines_to_run_in_parent_project\":true,\"ci_id_token_sub_claim_components\":[\"project_path\",\"ref_type\",\"ref\"],\"build_git_strategy\":\"fetch\",\"keep_latest_artifact\":true,\"restrict_user_defined_variables\":false,\"ci_pipeline_variables_minimum_override_role\":\"developer\",\"runner_token_expiration_interval\":null,\"group_runners_enabled\":true,\"resource_group_default_process_mode\":\"unordered\",\"auto_cancel_pending_pipelines\":\"enabled\",\"build_timeout\":3600,\"auto_devops_enabled\":false,\"auto_devops_deploy_strategy\":\"continuous\",\"ci_push_repository_for_job_token_allowed\":false,\"runners_token\":\"GR1348941cpXSMHzfUhGHayaS5Bxv\",\"ci_config_path\":\"\",\"public_jobs\":true,\"shared_with_groups\":[],\"only_allow_merge_if_pipeline_succeeds\":false,\"allow_merge_on_skipped_pipeline\":false,\"request_access_enabled\":true,\"only_allow_merge_if_all_discussions_are_resolved\":false,\"remove_source_branch_after_merge\":true,\"printing_merge_request_link_enabled\":true,\"merge_method\":\"ff\",\"merge_request_title_regex\":null,\"merge_request_title_regex_description\":null,\"squash_option\":\"default_on\",\"enforce_auth_checks_on_uploads\":true,\"suggestion_commit_message\":\"\",\"merge_commit_template\":null,\"squash_commit_template\":null,\"issue_branch_template\":null,\"warn_about_potentially_unwanted_characters\":true,\"autoclose_referenced_issues\":true,\"max_artifacts_size\":null,\"external_authorization_classification_label\":\"\",\"requirements_enabled\":false,\"requirements_access_level\":\"enabled\",\"security_and_compliance_enabled\":false,\"compliance_frameworks\":[],\"duo_remote_flows_enabled\":false,\"web_based_commit_signing_enabled\":false,\"permissions\":{\"project_access\":{\"access_level\":50,\"notification_level\":3},\"group_access\":null}}" + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 476.211834ms +- id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + User-Agent: + - go-gitlab + url: https://gitlab.com/api/v4/user + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: "{\"id\":930270,\"username\":\"abg\",\"public_email\":\"\",\"name\":\"Abhinav Gupta\",\"state\":\"active\",\"locked\":false,\"avatar_url\":\"https://secure.gravatar.com/avatar/e9a34bfd0e7f9ab63137b7653f656daaddbb65e84d3ef0852febd4c3e889a835?s=80\\u0026d=identicon\",\"web_url\":\"https://gitlab.com/abg\",\"created_at\":\"2017-01-07T03:40:46.795Z\",\"bio\":\"\",\"location\":\"\",\"linkedin\":\"\",\"twitter\":\"\",\"discord\":\"\",\"website_url\":\"https://abhinavg.net\",\"github\":\"\",\"job_title\":\"\",\"pronouns\":\"\",\"organization\":\"\",\"bot\":false,\"work_information\":null,\"local_time\":null,\"last_sign_in_at\":\"2025-08-01T05:01:49.254Z\",\"confirmed_at\":\"2021-10-19T01:30:11.688Z\",\"last_activity_on\":\"2025-08-30\",\"email\":\"mail@abhinavg.net\",\"theme_id\":3,\"color_scheme_id\":2,\"projects_limit\":100000,\"current_sign_in_at\":\"2025-08-30T04:18:16.940Z\",\"identities\":[{\"provider\":\"github\",\"extern_uid\":\"41730\",\"saml_provider_id\":null}],\"can_create_group\":true,\"can_create_project\":true,\"two_factor_enabled\":true,\"external\":false,\"private_profile\":false,\"commit_email\":\"mail@abhinavg.net\",\"preferred_language\":\"en\",\"shared_runners_minutes_limit\":null,\"extra_shared_runners_minutes_limit\":null,\"scim_identities\":[]}" + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 200.842625ms +- id: 2 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 153 + host: gitlab.com + body: "{\"title\":\"KBZMSKvC\",\"description\":\"Test MR with RemoveSourceBranch option\",\"source_branch\":\"KBZMSKvC\",\"target_branch\":\"main\",\"remove_source_branch\":true}" + headers: + Content-Type: + - application/json + User-Agent: + - go-gitlab + url: https://gitlab.com/api/v4/projects/64779801/merge_requests + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: 1800 + body: "{\"id\":411415737,\"iid\":20,\"project_id\":64779801,\"title\":\"KBZMSKvC\",\"description\":\"Test MR with RemoveSourceBranch option\",\"state\":\"opened\",\"created_at\":\"2025-08-30T04:28:26.282Z\",\"updated_at\":\"2025-08-30T04:28:26.282Z\",\"merged_by\":null,\"merge_user\":null,\"merged_at\":null,\"closed_by\":null,\"closed_at\":null,\"target_branch\":\"main\",\"source_branch\":\"KBZMSKvC\",\"user_notes_count\":0,\"upvotes\":0,\"downvotes\":0,\"author\":{\"id\":930270,\"username\":\"abg\",\"public_email\":\"\",\"name\":\"Abhinav Gupta\",\"state\":\"active\",\"locked\":false,\"avatar_url\":\"https://secure.gravatar.com/avatar/e9a34bfd0e7f9ab63137b7653f656daaddbb65e84d3ef0852febd4c3e889a835?s=80\\u0026d=identicon\",\"web_url\":\"https://gitlab.com/abg\"},\"assignees\":[],\"assignee\":null,\"reviewers\":[],\"source_project_id\":64779801,\"target_project_id\":64779801,\"labels\":[],\"draft\":false,\"imported\":false,\"imported_from\":\"none\",\"work_in_progress\":false,\"milestone\":null,\"merge_when_pipeline_succeeds\":false,\"merge_status\":\"checking\",\"detailed_merge_status\":\"preparing\",\"merge_after\":null,\"sha\":\"c27717daf4b59114db77628b8f07a7b2b0576c41\",\"merge_commit_sha\":null,\"squash_commit_sha\":null,\"discussion_locked\":null,\"should_remove_source_branch\":null,\"force_remove_source_branch\":true,\"prepared_at\":null,\"reference\":\"!20\",\"references\":{\"short\":\"!20\",\"relative\":\"!20\",\"full\":\"abg/test-repo!20\"},\"web_url\":\"https://gitlab.com/abg/test-repo/-/merge_requests/20\",\"time_stats\":{\"time_estimate\":0,\"total_time_spent\":0,\"human_time_estimate\":null,\"human_total_time_spent\":null},\"squash\":false,\"squash_on_merge\":false,\"task_completion_status\":{\"count\":0,\"completed_count\":0},\"has_conflicts\":false,\"blocking_discussions_resolved\":true,\"approvals_before_merge\":null,\"subscribed\":true,\"changes_count\":null,\"head_pipeline\":null,\"diff_refs\":null,\"merge_error\":null,\"user\":{\"can_merge\":true}}" + headers: + Content-Length: + - "1800" + Content-Type: + - application/json + status: 201 Created + code: 201 + duration: 793.083833ms +- id: 3 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: gitlab.com + headers: + User-Agent: + - go-gitlab + url: https://gitlab.com/api/v4/projects/64779801/merge_requests/20 + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + uncompressed: true + body: "{\"id\":411415737,\"iid\":20,\"project_id\":64779801,\"title\":\"KBZMSKvC\",\"description\":\"Test MR with RemoveSourceBranch option\",\"state\":\"opened\",\"created_at\":\"2025-08-30T04:28:26.282Z\",\"updated_at\":\"2025-08-30T04:28:26.282Z\",\"merged_by\":null,\"merge_user\":null,\"merged_at\":null,\"closed_by\":null,\"closed_at\":null,\"target_branch\":\"main\",\"source_branch\":\"KBZMSKvC\",\"user_notes_count\":0,\"upvotes\":0,\"downvotes\":0,\"author\":{\"id\":930270,\"username\":\"abg\",\"public_email\":\"\",\"name\":\"Abhinav Gupta\",\"state\":\"active\",\"locked\":false,\"avatar_url\":\"https://secure.gravatar.com/avatar/e9a34bfd0e7f9ab63137b7653f656daaddbb65e84d3ef0852febd4c3e889a835?s=80\\u0026d=identicon\",\"web_url\":\"https://gitlab.com/abg\"},\"assignees\":[],\"assignee\":null,\"reviewers\":[],\"source_project_id\":64779801,\"target_project_id\":64779801,\"labels\":[],\"draft\":false,\"imported\":false,\"imported_from\":\"none\",\"work_in_progress\":false,\"milestone\":null,\"merge_when_pipeline_succeeds\":false,\"merge_status\":\"checking\",\"detailed_merge_status\":\"preparing\",\"merge_after\":null,\"sha\":\"c27717daf4b59114db77628b8f07a7b2b0576c41\",\"merge_commit_sha\":null,\"squash_commit_sha\":null,\"discussion_locked\":null,\"should_remove_source_branch\":null,\"force_remove_source_branch\":true,\"prepared_at\":null,\"reference\":\"!20\",\"references\":{\"short\":\"!20\",\"relative\":\"!20\",\"full\":\"abg/test-repo!20\"},\"web_url\":\"https://gitlab.com/abg/test-repo/-/merge_requests/20\",\"time_stats\":{\"time_estimate\":0,\"total_time_spent\":0,\"human_time_estimate\":null,\"human_total_time_spent\":null},\"squash\":false,\"squash_on_merge\":false,\"task_completion_status\":{\"count\":0,\"completed_count\":0},\"has_conflicts\":false,\"blocking_discussions_resolved\":true,\"approvals_before_merge\":null,\"subscribed\":true,\"changes_count\":null,\"head_pipeline\":null,\"diff_refs\":null,\"merge_error\":null,\"first_contribution\":false,\"user\":{\"can_merge\":true}}" + headers: + Content-Type: + - application/json + status: 200 OK + code: 200 + duration: 369.470791ms diff --git a/internal/httptest/recorder.go b/internal/httptest/recorder.go index 06712777..37ad90da 100644 --- a/internal/httptest/recorder.go +++ b/internal/httptest/recorder.go @@ -19,9 +19,7 @@ import ( // [TransportRecorder]. type TransportRecorderOptions struct { // Update specifies whether the Recorder should update fixtures. - // - // If unset, we will assume false. - Update *bool + Update func() bool // WrapRealTransport wraps the real HTTP transport // with the given function. @@ -53,7 +51,7 @@ func NewTransportRecorder( return nil } - if opts.Update != nil && *opts.Update { + if opts.Update != nil && opts.Update() { mode = recorder.ModeRecordOnly if opts.WrapRealTransport != nil { realTransport = opts.WrapRealTransport(t, realTransport)