diff --git a/config/assignment.go b/config/assignment.go index 53074e5..8ad5853 100644 --- a/config/assignment.go +++ b/config/assignment.go @@ -57,6 +57,7 @@ func GetAssignmentConfig(course, assignment string, onlyForStudentsOrGroups ...s Description: description(assignmentKey), ContainerRegistry: containerRegistry, AccessLevel: accessLevel(assignmentKey), + MergeRequest: mergeRequest(assignmentKey), Students: students(per, course, assignment, onlyForStudentsOrGroups...), Groups: groups(per, course, assignment, onlyForStudentsOrGroups...), Startercode: startercode(assignmentKey), @@ -134,3 +135,17 @@ func description(assignmentKey string) string { return description } + +func mergeRequest(assignmentKey string) *MergeRequest { + mergeMethod := MergeCommit + switch viper.GetString(assignmentKey + ".mergeRequest.mergeMethod") { + case "semi_linear": + mergeMethod = SemiLinearHistory + case "ff": + mergeMethod = FastForward + case "merge": + mergeMethod = MergeCommit + } + + return &MergeRequest{MergeMethod: mergeMethod} +} diff --git a/config/assignment_test.go b/config/assignment_test.go index b08d350..d65e079 100644 --- a/config/assignment_test.go +++ b/config/assignment_test.go @@ -94,6 +94,8 @@ func TestGetAssignmentConfig(t *testing.T) { viper.Set("course.a1.assignmentpath", "blatt-01") viper.Set("course.a1.per", "student") viper.Set("course.a1.description", "blatt 1") + viper.Set("course.a1.mergeRequest", map[string]any{"mergeMethod": "semi_linear"}) + viper.Set("course.a1.mergeRequest.mergeMethod", "semi_linear") viper.Set("course.a1.release", map[string]any{"dockerImages": []string{"registry/app"}}) viper.Set("course.a1.release.dockerImages", []string{"registry/app"}) @@ -123,4 +125,26 @@ func TestGetAssignmentConfig(t *testing.T) { if cfg.AccessLevel != Developer { t.Fatalf("AccessLevel = %v, want %v", cfg.AccessLevel, Developer) } + if cfg.MergeRequest == nil || cfg.MergeRequest.MergeMethod != SemiLinearHistory { + t.Fatalf("MergeRequest = %#v, want semi_linear", cfg.MergeRequest) + } +} + +func TestGetAssignmentConfig_DefaultMergeRequest(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") + + cfg := GetAssignmentConfig("course", "a1") + + if cfg.MergeRequest == nil { + t.Fatal("MergeRequest should be initialized with defaults") + } + if cfg.MergeRequest.MergeMethod != MergeCommit { + t.Fatalf("MergeRequest.MergeMethod = %q, want %q", cfg.MergeRequest.MergeMethod, MergeCommit) + } } diff --git a/config/release.go b/config/release.go index 30222a9..526f6a1 100644 --- a/config/release.go +++ b/config/release.go @@ -14,12 +14,12 @@ func release(assignmentKey string) *Release { } return &Release{ - MergeRequest: mergeRequest(assignmentKey), + MergeRequest: releaseMergeRequest(assignmentKey), DockerImages: dockerImages(assignmentKey), } } -func mergeRequest(assignmentKey string) *MergeRequest { +func releaseMergeRequest(assignmentKey string) *ReleaseMergeRequest { mergeRequestMap := viper.GetStringMapString(assignmentKey + ".release.mergeRequest") if len(mergeRequestMap) == 0 { log.Debug().Str("assignmemtKey", assignmentKey).Msg("no release by merge request provided") @@ -36,7 +36,7 @@ func mergeRequest(assignmentKey string) *MergeRequest { targetBranch = tB } - return &MergeRequest{ + return &ReleaseMergeRequest{ SourceBranch: sourceBranch, TargetBranch: targetBranch, HasPipeline: viper.GetBool(assignmentKey + ".release.mergeRequest.pipeline"), diff --git a/config/show.go b/config/show.go index 27f5751..c53197a 100644 --- a/config/show.go +++ b/config/show.go @@ -13,6 +13,16 @@ func (cfg *AssignmentConfig) Show() { containerRegistry = aurora.Green("enabled") } + mergeMethod := MergeCommit + if cfg.MergeRequest != nil { + mergeMethod = cfg.MergeRequest.MergeMethod + } + mergeRequestCfg := aurora.Sprintf(aurora.Cyan(` + %s %s`), + aurora.Cyan("MergeMethod:"), + aurora.Yellow(mergeMethod), + ) + startercode := aurora.Sprintf(aurora.Red("not defined")) if cfg.Startercode != nil { issueNumbers := aurora.Sprintf(aurora.Red("not defined")) @@ -71,9 +81,9 @@ func (cfg *AssignmentConfig) Show() { } release := aurora.Sprintf(aurora.Red("not defined")) if cfg.Release != nil { - mergeRequest := aurora.Sprintf(aurora.Red("not defined")) + releaseMergeRequest := aurora.Sprintf(aurora.Red("not defined")) if cfg.Release.MergeRequest != nil { - mergeRequest = aurora.Sprintf(` + releaseMergeRequest = aurora.Sprintf(` %s %s %s %s %s %t`, @@ -100,7 +110,7 @@ func (cfg *AssignmentConfig) Show() { %s %s %s %s`), aurora.Cyan("MergeRequest:"), - mergeRequest, + releaseMergeRequest, aurora.Cyan("DockerImages:"), dockerImages, ) @@ -142,7 +152,8 @@ Per: %s Base-URL: %s Description: %s AccessLevel: %s -Container-Registry: %s +MergeRequest: %s +%s %s Startercode: %s Seeding: %s Release: %s @@ -156,6 +167,8 @@ Release: %s aurora.Yellow(cfg.URL), aurora.Yellow(cfg.Description), aurora.Yellow(cfg.AccessLevel.String()), + mergeRequestCfg, + aurora.Cyan("Container-Registry:"), containerRegistry, aurora.Yellow(startercode), aurora.Yellow(seeding), diff --git a/config/types.go b/config/types.go index 8f899b8..e4f2f97 100644 --- a/config/types.go +++ b/config/types.go @@ -19,6 +19,7 @@ type AssignmentConfig struct { Description string ContainerRegistry bool AccessLevel AccessLevel + MergeRequest *MergeRequest Students []*Student Groups []*Group Startercode *Startercode @@ -64,11 +65,15 @@ type Clone struct { } type Release struct { - MergeRequest *MergeRequest + MergeRequest *ReleaseMergeRequest DockerImages []string } type MergeRequest struct { + MergeMethod MergeMethod +} + +type ReleaseMergeRequest struct { SourceBranch string TargetBranch string HasPipeline bool @@ -87,3 +92,16 @@ const ( Developer AccessLevel = 30 Maintainer AccessLevel = 40 ) + +// MergeMethod represents the merge strategy for GitLab projects. +// Values correspond to glabs config format, not the GitLab API directly. +type MergeMethod string + +const ( + // MergeCommit creates a merge commit for every merge (GitLab default). + MergeCommit MergeMethod = "merge" + // SemiLinearHistory requires linear history: rebase before creating merge commit. + SemiLinearHistory MergeMethod = "semi_linear" + // FastForward only allows fast-forward merges; no merge commits. + FastForward MergeMethod = "ff" +) diff --git a/config/urls_show_test.go b/config/urls_show_test.go index a91a521..2ab5ee7 100644 --- a/config/urls_show_test.go +++ b/config/urls_show_test.go @@ -144,7 +144,7 @@ func TestShow_WithClone(t *testing.T) { func TestShow_WithRelease_MergeRequestAndDockerImages(t *testing.T) { cfg := &AssignmentConfig{ Release: &Release{ - MergeRequest: &MergeRequest{ + MergeRequest: &ReleaseMergeRequest{ SourceBranch: "develop", TargetBranch: "main", HasPipeline: true, @@ -158,7 +158,7 @@ func TestShow_WithRelease_MergeRequestAndDockerImages(t *testing.T) { func TestShow_WithRelease_MergeRequestOnly(t *testing.T) { cfg := &AssignmentConfig{ Release: &Release{ - MergeRequest: &MergeRequest{ + MergeRequest: &ReleaseMergeRequest{ SourceBranch: "develop", TargetBranch: "main", }, @@ -225,3 +225,19 @@ func TestShow_PerGroup_ListsGroups(t *testing.T) { t.Fatalf("Show(PerGroup) output does not contain team1: %q", out) } } + +func TestShow_WithMergeMethod(t *testing.T) { + cfg := &AssignmentConfig{ + MergeRequest: &MergeRequest{MergeMethod: SemiLinearHistory}, + } + out := captureStdout(t, func() { cfg.Show() }) + if !strings.Contains(out, "MergeRequest:") { + t.Fatalf("Show() output does not contain MergeRequest header: %q", out) + } + if !strings.Contains(out, "MergeMethod:") { + t.Fatalf("Show() output does not contain nested MergeMethod key: %q", out) + } + if !strings.Contains(out, "semi_linear") { + t.Fatalf("Show() output does not contain merge method semi_linear: %q", out) + } +} diff --git a/docs/configuration.md b/docs/configuration.md index c580318..0fe1b9e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -121,6 +121,8 @@ Patterns are **regular expressions**, so: per: student accesslevel: developer containerRegistry: false + mergeRequest: + mergeMethod: merge # merge | semi_linear | ff startercode: ... @@ -141,6 +143,7 @@ Patterns are **regular expressions**, so: | `description` | GitLab project description | `generated by glabs` | Visible in UI | | `accesslevel` | Initial project access (see below) | `developer` | Can be changed later with `setaccess` | | `containerRegistry` | Enable container registry | `false` | Auto-enabled if `release.dockerImages` set | +| `mergeRequest.mergeMethod` | Merge strategy (see below) | `merge` | Applied at project creation | | `students` | Override/add assignment-specific students | — | Merged with course-level | | `groups` | Override/add assignment-specific groups | — | Merged with course-level | @@ -306,6 +309,24 @@ release: | `mergeRequest.pipeline` | Wait for CI | `false` | Require passing checks | | `dockerImages` | Images to build | `[]` | Creates container registry entries | +### Merge method values + +The assignment-level `mergeRequest.mergeMethod` option controls how merge requests are merged in every generated project: + +| Value | GitLab UI name | Description | +|---|---|---| +| `merge` | Merge commit | Always create a merge commit (GitLab default) | +| `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 | + +**Example:** + +```yaml +blatt01: + mergeRequest: + mergeMethod: ff # Students must keep a linear history +``` + **Notes:** - Container registry is auto-enabled when `dockerImages` is set diff --git a/gitlab/integration_gitlab_test.go b/gitlab/integration_gitlab_test.go index 77bb14a..818083c 100644 --- a/gitlab/integration_gitlab_test.go +++ b/gitlab/integration_gitlab_test.go @@ -324,6 +324,59 @@ func TestIntegration_GitLab_Operations(t *testing.T) { t.Fatalf("expected user %q to be a project member after Setaccess()", itUsername) } }) + + // ── Sub-test: MergeMethod ───────────────────────────────────────────────── + t.Run("MergeMethod", func(t *testing.T) { + cases := []struct { + subPath string + mergeMethod config.MergeMethod + wantGitLab gitlabapi.MergeMethodValue + }{ + {"mergemethod-merge-a1", config.MergeCommit, gitlabapi.NoFastForwardMerge}, + {"mergemethod-semi-a1", config.SemiLinearHistory, gitlabapi.RebaseMerge}, + {"mergemethod-ff-a1", config.FastForward, gitlabapi.FastForwardMerge}, + } + + for _, tc := range cases { + tc := tc + t.Run(string(tc.mergeMethod), 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: "merge method integration test", + ContainerRegistry: false, + MergeRequest: &config.MergeRequest{MergeMethod: tc.mergeMethod}, + 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) + project, _, 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) + } + _ = project + if proj.MergeMethod != tc.wantGitLab { + t.Fatalf("MergeMethod = %q, want %q", proj.MergeMethod, tc.wantGitLab) + } + }) + } + }) } func TestIntegration_GitLab_GroupAndProjectLifecycle(t *testing.T) { diff --git a/gitlab/projects.go b/gitlab/projects.go index c770080..635cba4 100644 --- a/gitlab/projects.go +++ b/gitlab/projects.go @@ -11,6 +11,25 @@ import ( func (c *Client) generateProject(assignmentCfg *config.AssignmentConfig, name string, inID int64) (*gitlab.Project, bool, error) { generated := false + + // Merge method should already be defaulted by config parsing. + // Keep this fallback for safety when AssignmentConfig is constructed manually. + mergeMethod := config.MergeCommit + if assignmentCfg.MergeRequest != nil { + mergeMethod = assignmentCfg.MergeRequest.MergeMethod + } + + // Convert glabs MergeMethod to GitLab API MergeMethodValue + var gitlabMergeMethod gitlab.MergeMethodValue + switch mergeMethod { + case config.SemiLinearHistory: + gitlabMergeMethod = gitlab.RebaseMerge + case config.FastForward: + gitlabMergeMethod = gitlab.FastForwardMerge + default: + gitlabMergeMethod = gitlab.NoFastForwardMerge + } + p := &gitlab.CreateProjectOptions{ Name: gitlab.Ptr(name), Description: gitlab.Ptr(assignmentCfg.Description), @@ -22,6 +41,7 @@ func (c *Client) generateProject(assignmentCfg *config.AssignmentConfig, name st Visibility: gitlab.Ptr(gitlab.PrivateVisibility), ContainerRegistryEnabled: gitlab.Ptr(assignmentCfg.ContainerRegistry), OnlyAllowMergeIfAllStatusChecksPassed: gitlab.Ptr(false), + MergeMethod: gitlab.Ptr(gitlabMergeMethod), } project, _, err := c.Projects.CreateProject(p) diff --git a/gitlab/report_contract_test.go b/gitlab/report_contract_test.go index a041f7a..746bd4f 100644 --- a/gitlab/report_contract_test.go +++ b/gitlab/report_contract_test.go @@ -159,7 +159,7 @@ func TestReport_HappyPath_WithRelease(t *testing.T) { Path: "mpd/ss26/blatt-01", URL: "https://gitlab.example.org/mpd/ss26/blatt-01", Release: &config.Release{ - MergeRequest: &config.MergeRequest{SourceBranch: "develop", TargetBranch: "main"}, + MergeRequest: &config.ReleaseMergeRequest{SourceBranch: "develop", TargetBranch: "main"}, DockerImages: []string{"myimage:latest"}, }, } diff --git a/gitlab/report_helper_contract_test.go b/gitlab/report_helper_contract_test.go index ea55b4b..4ae5667 100644 --- a/gitlab/report_helper_contract_test.go +++ b/gitlab/report_helper_contract_test.go @@ -78,7 +78,7 @@ func TestProjectReport_AggregatesReleaseAndCommitData(t *testing.T) { assignmentCfg := &config.AssignmentConfig{ Release: &config.Release{ - MergeRequest: &config.MergeRequest{ + MergeRequest: &config.ReleaseMergeRequest{ SourceBranch: "develop", TargetBranch: "main", HasPipeline: true,