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
15 changes: 15 additions & 0 deletions config/assignment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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}
}
24 changes: 24 additions & 0 deletions config/assignment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})

Expand Down Expand Up @@ -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)
}
}
6 changes: 3 additions & 3 deletions config/release.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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"),
Expand Down
21 changes: 17 additions & 4 deletions config/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Comment on lines +16 to +24

startercode := aurora.Sprintf(aurora.Red("not defined"))
if cfg.Startercode != nil {
issueNumbers := aurora.Sprintf(aurora.Red("not defined"))
Expand Down Expand Up @@ -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`,
Expand All @@ -100,7 +110,7 @@ func (cfg *AssignmentConfig) Show() {
%s %s
%s %s`),
aurora.Cyan("MergeRequest:"),
mergeRequest,
releaseMergeRequest,
aurora.Cyan("DockerImages:"),
dockerImages,
)
Expand Down Expand Up @@ -142,7 +152,8 @@ Per: %s
Base-URL: %s
Description: %s
AccessLevel: %s
Container-Registry: %s
MergeRequest: %s
%s %s
Comment on lines +155 to +156
Startercode: %s
Seeding: %s
Release: %s
Expand All @@ -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),
Expand Down
20 changes: 19 additions & 1 deletion config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type AssignmentConfig struct {
Description string
ContainerRegistry bool
AccessLevel AccessLevel
MergeRequest *MergeRequest
Students []*Student
Groups []*Group
Startercode *Startercode
Expand Down Expand Up @@ -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
Expand All @@ -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"
)
20 changes: 18 additions & 2 deletions config/urls_show_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
},
Expand Down Expand Up @@ -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)
}
}
21 changes: 21 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ Patterns are **regular expressions**, so:
per: student
accesslevel: developer
containerRegistry: false
mergeRequest:
mergeMethod: merge # merge | semi_linear | ff

startercode:
...
Expand All @@ -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 |

Expand Down Expand Up @@ -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
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 @@ -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) {
Expand Down
20 changes: 20 additions & 0 deletions gitlab/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion gitlab/report_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
}
Expand Down
2 changes: 1 addition & 1 deletion gitlab/report_helper_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading