From 63f35a62f276d0b1081eb958e19e3d637a386f2f Mon Sep 17 00:00:00 2001 From: Oliver Braun Date: Thu, 23 Apr 2026 20:28:11 +0200 Subject: [PATCH] feat: add squash option to merge requests and update related configurations Co-authored-by: Copilot --- config/assignment.go | 14 +++++++- config/assignment_test.go | 35 ++++++++++++++++++++ config/show.go | 7 +++- config/types.go | 17 +++++++++- config/urls_show_test.go | 13 ++++++++ docs/configuration.md | 17 ++++++++-- gitlab/integration_gitlab_test.go | 53 +++++++++++++++++++++++++++++++ gitlab/projects.go | 16 ++++++++++ 8 files changed, 167 insertions(+), 5 deletions(-) diff --git a/config/assignment.go b/config/assignment.go index 8ad5853..225a0c8 100644 --- a/config/assignment.go +++ b/config/assignment.go @@ -147,5 +147,17 @@ func mergeRequest(assignmentKey string) *MergeRequest { mergeMethod = MergeCommit } - return &MergeRequest{MergeMethod: mergeMethod} + squashOption := SquashDefaultOff + switch viper.GetString(assignmentKey + ".mergeRequest.squashOption") { + case "never": + squashOption = SquashNever + case "always": + squashOption = SquashAlways + case "default_on": + squashOption = SquashDefaultOn + case "default_off": + squashOption = SquashDefaultOff + } + + return &MergeRequest{MergeMethod: mergeMethod, SquashOption: squashOption} } diff --git a/config/assignment_test.go b/config/assignment_test.go index d65e079..c7562ed 100644 --- a/config/assignment_test.go +++ b/config/assignment_test.go @@ -147,4 +147,39 @@ func TestGetAssignmentConfig_DefaultMergeRequest(t *testing.T) { if cfg.MergeRequest.MergeMethod != MergeCommit { t.Fatalf("MergeRequest.MergeMethod = %q, want %q", cfg.MergeRequest.MergeMethod, MergeCommit) } + if cfg.MergeRequest.SquashOption != SquashDefaultOff { + t.Fatalf("MergeRequest.SquashOption = %q, want %q", cfg.MergeRequest.SquashOption, SquashDefaultOff) + } +} + +func TestGetAssignmentConfig_SquashOption(t *testing.T) { + cases := []struct { + value string + want SquashOption + }{ + {"never", SquashNever}, + {"always", SquashAlways}, + {"default_on", SquashDefaultOn}, + {"default_off", SquashDefaultOff}, + } + for _, tc := range cases { + t.Run(tc.value, func(t *testing.T) { + resetViper(t) + viper.Set("gitlab.host", "https://gitlab.example.org") + viper.Set("course", true) + viper.Set("course.coursepath", "mpd") + viper.Set("course.a1", true) + viper.Set("course.a1.assignmentpath", "blatt-01") + viper.Set("course.a1.mergeRequest.squashOption", tc.value) + + cfg := GetAssignmentConfig("course", "a1") + + if cfg.MergeRequest == nil { + t.Fatal("MergeRequest should not be nil") + } + if cfg.MergeRequest.SquashOption != tc.want { + t.Fatalf("SquashOption = %q, want %q", cfg.MergeRequest.SquashOption, tc.want) + } + }) + } } diff --git a/config/show.go b/config/show.go index c53197a..f90d4e6 100644 --- a/config/show.go +++ b/config/show.go @@ -14,13 +14,18 @@ func (cfg *AssignmentConfig) Show() { } mergeMethod := MergeCommit + squashOption := SquashDefaultOff if cfg.MergeRequest != nil { mergeMethod = cfg.MergeRequest.MergeMethod + squashOption = cfg.MergeRequest.SquashOption } mergeRequestCfg := aurora.Sprintf(aurora.Cyan(` - %s %s`), + %s %s + %s %s`), aurora.Cyan("MergeMethod:"), aurora.Yellow(mergeMethod), + aurora.Cyan("SquashOption:"), + aurora.Yellow(squashOption), ) startercode := aurora.Sprintf(aurora.Red("not defined")) diff --git a/config/types.go b/config/types.go index e4f2f97..c72c834 100644 --- a/config/types.go +++ b/config/types.go @@ -70,7 +70,8 @@ type Release struct { } type MergeRequest struct { - MergeMethod MergeMethod + MergeMethod MergeMethod + SquashOption SquashOption } type ReleaseMergeRequest struct { @@ -105,3 +106,17 @@ const ( // FastForward only allows fast-forward merges; no merge commits. FastForward MergeMethod = "ff" ) + +// SquashOption represents the squash-on-merge setting for GitLab projects. +type SquashOption string + +const ( + // SquashNever disables squashing for all merge requests. + SquashNever SquashOption = "never" + // SquashAlways squashes all merge requests automatically. + SquashAlways SquashOption = "always" + // SquashDefaultOff lets users opt in to squash per MR (default off). + SquashDefaultOff SquashOption = "default_off" + // SquashDefaultOn lets users opt out of squash per MR (default on). + SquashDefaultOn SquashOption = "default_on" +) diff --git a/config/urls_show_test.go b/config/urls_show_test.go index 2ab5ee7..cd27e78 100644 --- a/config/urls_show_test.go +++ b/config/urls_show_test.go @@ -241,3 +241,16 @@ func TestShow_WithMergeMethod(t *testing.T) { t.Fatalf("Show() output does not contain merge method semi_linear: %q", out) } } + +func TestShow_WithSquashOption(t *testing.T) { + cfg := &AssignmentConfig{ + MergeRequest: &MergeRequest{MergeMethod: MergeCommit, SquashOption: SquashAlways}, + } + out := captureStdout(t, func() { cfg.Show() }) + if !strings.Contains(out, "SquashOption:") { + t.Fatalf("Show() output does not contain SquashOption key: %q", out) + } + if !strings.Contains(out, "always") { + t.Fatalf("Show() output does not contain squash option 'always': %q", out) + } +} diff --git a/docs/configuration.md b/docs/configuration.md index a138eca..e196df2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -123,6 +123,7 @@ Patterns are **regular expressions**, so: containerRegistry: false mergeRequest: mergeMethod: merge # merge | semi_linear | ff + squashOption: default_off # never | always | default_off | default_on startercode: ... @@ -321,7 +322,8 @@ Control how merge requests are merged in every generated project: ```yaml : mergeRequest: - mergeMethod: merge # merge | semi_linear | ff + mergeMethod: merge # merge | semi_linear | ff + squashOption: default_off # never | always | default_off | default_on ``` ### Merge Request keys @@ -329,6 +331,7 @@ Control how merge requests are merged in every generated project: | Key | Purpose | Default | Notes | |---|---|---|---| | `mergeRequest.mergeMethod` | Merge strategy for all MRs | `merge` | Applied at project creation | +| `mergeRequest.squashOption` | Squash-on-merge setting | `default_off` | Applied at project creation | ### Merge method values @@ -338,12 +341,22 @@ Control how merge requests are merged in every generated project: | `semi_linear` | Merge commit with semi-linear history | Require a linear history; fast-forward commits are rebased before merge | | `ff` | Fast-forward merge | No merge commits; only fast-forward merges allowed | +### Squash option values + +| Value | GitLab UI name | Description | +|---|---|---| +| `never` | Do not allow | Squashing is disabled; the checkbox is hidden | +| `always` | Require | Every MR is squashed automatically; the checkbox is hidden | +| `default_off` | Allow | Squash checkbox visible but unchecked by default (GitLab default) | +| `default_on` | Encourage | Squash checkbox visible and checked by default | + **Example:** ```yaml blatt01: mergeRequest: - mergeMethod: ff # Students must keep a linear history + mergeMethod: ff # Students must keep a linear history + squashOption: always # All commits squashed into one on merge ``` ## Full example diff --git a/gitlab/integration_gitlab_test.go b/gitlab/integration_gitlab_test.go index 818083c..def0db2 100644 --- a/gitlab/integration_gitlab_test.go +++ b/gitlab/integration_gitlab_test.go @@ -377,6 +377,59 @@ func TestIntegration_GitLab_Operations(t *testing.T) { }) } }) + + // ── Sub-test: SquashOption ──────────────────────────────────────────────── + t.Run("SquashOption", func(t *testing.T) { + cases := []struct { + subPath string + squashOption config.SquashOption + wantGitLab gitlabapi.SquashOptionValue + }{ + {"squash-never-a1", config.SquashNever, gitlabapi.SquashOptionNever}, + {"squash-always-a1", config.SquashAlways, gitlabapi.SquashOptionAlways}, + {"squash-default-off-a1", config.SquashDefaultOff, gitlabapi.SquashOptionDefaultOff}, + {"squash-default-on-a1", config.SquashDefaultOn, gitlabapi.SquashOptionDefaultOn}, + } + + for _, tc := range cases { + tc := tc + t.Run(string(tc.squashOption), func(t *testing.T) { + un := "student1" + path := parent.FullPath + "/" + tc.subPath + cfg := &config.AssignmentConfig{ + Course: "it", + Name: "a1", + Path: path, + URL: baseURL + "/" + path, + Per: config.PerStudent, + Description: "squash option integration test", + ContainerRegistry: false, + MergeRequest: &config.MergeRequest{MergeMethod: config.MergeCommit, SquashOption: tc.squashOption}, + Students: []*config.Student{{Username: &un, Raw: un}}, + } + + groupID, err := client.createGroup(cfg) + if err != nil { + t.Fatalf("createGroup(%q) failed: %v", tc.subPath, err) + } + + repoName := cfg.RepoNameWithSuffix(un) + _, _, err = client.generateProject(cfg, repoName, groupID) + if err != nil { + t.Fatalf("generateProject failed: %v", err) + } + + projectPath := cfg.Path + "/" + repoName + proj, _, err := client.Projects.GetProject(projectPath, &gitlabapi.GetProjectOptions{}) + if err != nil { + t.Fatalf("GetProject failed: %v", err) + } + if proj.SquashOption != tc.wantGitLab { + t.Fatalf("SquashOption = %q, want %q", proj.SquashOption, tc.wantGitLab) + } + }) + } + }) } func TestIntegration_GitLab_GroupAndProjectLifecycle(t *testing.T) { diff --git a/gitlab/projects.go b/gitlab/projects.go index 635cba4..a086340 100644 --- a/gitlab/projects.go +++ b/gitlab/projects.go @@ -15,8 +15,10 @@ func (c *Client) generateProject(assignmentCfg *config.AssignmentConfig, name st // Merge method should already be defaulted by config parsing. // Keep this fallback for safety when AssignmentConfig is constructed manually. mergeMethod := config.MergeCommit + squashOption := config.SquashDefaultOff if assignmentCfg.MergeRequest != nil { mergeMethod = assignmentCfg.MergeRequest.MergeMethod + squashOption = assignmentCfg.MergeRequest.SquashOption } // Convert glabs MergeMethod to GitLab API MergeMethodValue @@ -30,6 +32,19 @@ func (c *Client) generateProject(assignmentCfg *config.AssignmentConfig, name st gitlabMergeMethod = gitlab.NoFastForwardMerge } + // Convert glabs SquashOption to GitLab API SquashOptionValue + var gitlabSquashOption gitlab.SquashOptionValue + switch squashOption { + case config.SquashNever: + gitlabSquashOption = gitlab.SquashOptionNever + case config.SquashAlways: + gitlabSquashOption = gitlab.SquashOptionAlways + case config.SquashDefaultOn: + gitlabSquashOption = gitlab.SquashOptionDefaultOn + default: + gitlabSquashOption = gitlab.SquashOptionDefaultOff + } + p := &gitlab.CreateProjectOptions{ Name: gitlab.Ptr(name), Description: gitlab.Ptr(assignmentCfg.Description), @@ -42,6 +57,7 @@ func (c *Client) generateProject(assignmentCfg *config.AssignmentConfig, name st ContainerRegistryEnabled: gitlab.Ptr(assignmentCfg.ContainerRegistry), OnlyAllowMergeIfAllStatusChecksPassed: gitlab.Ptr(false), MergeMethod: gitlab.Ptr(gitlabMergeMethod), + SquashOption: gitlab.Ptr(gitlabSquashOption), } project, _, err := c.Projects.CreateProject(p)