Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion config/assignment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
35 changes: 35 additions & 0 deletions config/assignment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
7 changes: 6 additions & 1 deletion config/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,18 @@ func (cfg *AssignmentConfig) Show() {
}

mergeMethod := MergeCommit
squashOption := SquashDefaultOff
if cfg.MergeRequest != nil {
mergeMethod = cfg.MergeRequest.MergeMethod
squashOption = cfg.MergeRequest.SquashOption

Copilot AI Apr 23, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When cfg.MergeRequest is non-nil but SquashOption is left unset (zero value ""), this assignment overwrites the default SquashDefaultOff and Show() will print an empty squash option. Consider only overriding the default when cfg.MergeRequest.SquashOption is non-empty (or ensure MergeRequest is always fully defaulted when constructed manually).

Suggested change
squashOption = cfg.MergeRequest.SquashOption
if cfg.MergeRequest.SquashOption != "" {
squashOption = cfg.MergeRequest.SquashOption
}

Copilot uses AI. Check for mistakes.
}
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"))
Expand Down
17 changes: 16 additions & 1 deletion config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ type Release struct {
}

type MergeRequest struct {
MergeMethod MergeMethod
MergeMethod MergeMethod
SquashOption SquashOption
}

type ReleaseMergeRequest struct {
Expand Down Expand Up @@ -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"
)
13 changes: 13 additions & 0 deletions config/urls_show_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
17 changes: 15 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
...
Expand Down Expand Up @@ -321,14 +322,16 @@ Control how merge requests are merged in every generated project:
```yaml
<assignment>:
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

| 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

Expand All @@ -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
Expand Down
53 changes: 53 additions & 0 deletions gitlab/integration_gitlab_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
16 changes: 16 additions & 0 deletions gitlab/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -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)
Expand Down
Loading