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
9 changes: 8 additions & 1 deletion config/assignment.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,5 +159,12 @@ func mergeRequest(assignmentKey string) *MergeRequest {
squashOption = SquashDefaultOff
}

return &MergeRequest{MergeMethod: mergeMethod, SquashOption: squashOption}
return &MergeRequest{
MergeMethod: mergeMethod,
SquashOption: squashOption,
PipelineMustSucceed: viper.GetBool(assignmentKey + ".mergeRequest.pipeline"),
SkippedPipelinesAreSuccessful: viper.GetBool(assignmentKey + ".mergeRequest.skippedPipelinesAreSuccessful"),
AllThreadsMustBeResolved: viper.GetBool(assignmentKey + ".mergeRequest.allThreadsMustBeResolved"),
StatusChecksMustSucceed: viper.GetBool(assignmentKey + ".mergeRequest.statusChecksMustSucceed"),
}
}
44 changes: 44 additions & 0 deletions config/assignment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,18 @@ func TestGetAssignmentConfig_DefaultMergeRequest(t *testing.T) {
if cfg.MergeRequest.SquashOption != SquashDefaultOff {
t.Fatalf("MergeRequest.SquashOption = %q, want %q", cfg.MergeRequest.SquashOption, SquashDefaultOff)
}
if cfg.MergeRequest.PipelineMustSucceed {
t.Fatal("MergeRequest.PipelineMustSucceed = true, want false")
}
if cfg.MergeRequest.SkippedPipelinesAreSuccessful {
t.Fatal("MergeRequest.SkippedPipelinesAreSuccessful = true, want false")
}
if cfg.MergeRequest.AllThreadsMustBeResolved {
t.Fatal("MergeRequest.AllThreadsMustBeResolved = true, want false")
}
if cfg.MergeRequest.StatusChecksMustSucceed {
t.Fatal("MergeRequest.StatusChecksMustSucceed = true, want false")
}
}

func TestGetAssignmentConfig_SquashOption(t *testing.T) {
Expand Down Expand Up @@ -183,3 +195,35 @@ func TestGetAssignmentConfig_SquashOption(t *testing.T) {
})
}
}

func TestGetAssignmentConfig_MergeRequestChecks(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.pipeline", true)
viper.Set("course.a1.mergeRequest.skippedPipelinesAreSuccessful", true)
viper.Set("course.a1.mergeRequest.allThreadsMustBeResolved", true)
viper.Set("course.a1.mergeRequest.statusChecksMustSucceed", true)

cfg := GetAssignmentConfig("course", "a1")

if cfg.MergeRequest == nil {
t.Fatal("MergeRequest should not be nil")
}
if !cfg.MergeRequest.PipelineMustSucceed {
t.Fatal("PipelineMustSucceed = false, want true")
}
if !cfg.MergeRequest.SkippedPipelinesAreSuccessful {
t.Fatal("SkippedPipelinesAreSuccessful = false, want true")
}
if !cfg.MergeRequest.AllThreadsMustBeResolved {
t.Fatal("AllThreadsMustBeResolved = false, want true")
}
if !cfg.MergeRequest.StatusChecksMustSucceed {
t.Fatal("StatusChecksMustSucceed = false, want true")
}
}
24 changes: 22 additions & 2 deletions config/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,37 @@ func (cfg *AssignmentConfig) Show() {

mergeMethod := MergeCommit
squashOption := SquashDefaultOff
pipelineMustSucceed := false
skippedPipelinesAreSuccessful := false
allThreadsMustBeResolved := false
statusChecksMustSucceed := false
if cfg.MergeRequest != nil {
mergeMethod = cfg.MergeRequest.MergeMethod
squashOption = cfg.MergeRequest.SquashOption
pipelineMustSucceed = cfg.MergeRequest.PipelineMustSucceed
skippedPipelinesAreSuccessful = cfg.MergeRequest.SkippedPipelinesAreSuccessful
allThreadsMustBeResolved = cfg.MergeRequest.AllThreadsMustBeResolved
statusChecksMustSucceed = cfg.MergeRequest.StatusChecksMustSucceed
}
mergeRequestCfg := aurora.Sprintf(aurora.Cyan(`
%s %s
%s %s`),
%s %s
%s %s
%s %t
%s %t
%s %t
%s %t`),
aurora.Cyan("MergeMethod:"),
aurora.Yellow(mergeMethod),
aurora.Cyan("SquashOption:"),
aurora.Yellow(squashOption),
aurora.Cyan("PipelineMustSucceed:"),
aurora.Yellow(pipelineMustSucceed),
aurora.Cyan("SkippedPipelinesAreSuccessful:"),
aurora.Yellow(skippedPipelinesAreSuccessful),
aurora.Cyan("AllThreadsMustBeResolved:"),
aurora.Yellow(allThreadsMustBeResolved),
aurora.Cyan("StatusChecksMustSucceed:"),
aurora.Yellow(statusChecksMustSucceed),
Comment on lines 30 to +48

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.

The format string uses %t for the merge-check values, but the arguments passed are aurora.Yellow(<bool>) (an aurora.Value), not a bool. This will render as %!t(...) and likely breaks Show() output (and the new test). Use %s (and keep coloring), or keep %t but pass the raw bools (and optionally colorize via aurora.Sprintf/string conversion).

Copilot uses AI. Check for mistakes.
)

startercode := aurora.Sprintf(aurora.Red("not defined"))
Expand Down
8 changes: 6 additions & 2 deletions config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,12 @@ type Release struct {
}

type MergeRequest struct {
MergeMethod MergeMethod
SquashOption SquashOption
MergeMethod MergeMethod
SquashOption SquashOption
PipelineMustSucceed bool
SkippedPipelinesAreSuccessful bool
AllThreadsMustBeResolved bool
StatusChecksMustSucceed bool
}

type ReleaseMergeRequest struct {
Expand Down
26 changes: 26 additions & 0 deletions config/urls_show_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,3 +254,29 @@ func TestShow_WithSquashOption(t *testing.T) {
t.Fatalf("Show() output does not contain squash option 'always': %q", out)
}
}

func TestShow_WithMergeChecks(t *testing.T) {
cfg := &AssignmentConfig{
MergeRequest: &MergeRequest{
MergeMethod: MergeCommit,
SquashOption: SquashDefaultOff,
PipelineMustSucceed: true,
SkippedPipelinesAreSuccessful: true,
AllThreadsMustBeResolved: true,
StatusChecksMustSucceed: true,
},
}
out := captureStdout(t, func() { cfg.Show() })
if !strings.Contains(out, "PipelineMustSucceed:") || !strings.Contains(out, "true") {
t.Fatalf("Show() output does not contain PipelineMustSucceed=true: %q", out)
}
if !strings.Contains(out, "AllThreadsMustBeResolved:") {
t.Fatalf("Show() output does not contain AllThreadsMustBeResolved key: %q", out)
}
if !strings.Contains(out, "SkippedPipelinesAreSuccessful:") {
t.Fatalf("Show() output does not contain SkippedPipelinesAreSuccessful key: %q", out)
}
if !strings.Contains(out, "StatusChecksMustSucceed:") {
t.Fatalf("Show() output does not contain StatusChecksMustSucceed key: %q", out)
}
}
12 changes: 12 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,10 @@ Control how merge requests are merged in every generated project:
mergeRequest:
mergeMethod: merge # merge | semi_linear | ff
squashOption: default_off # never | always | default_off | default_on
pipeline: false # Require successful pipeline before merge
skippedPipelinesAreSuccessful: false
allThreadsMustBeResolved: false
statusChecksMustSucceed: false
```

### Merge Request keys
Expand All @@ -332,6 +336,10 @@ Control how merge requests are merged in every generated project:
|---|---|---|---|
| `mergeRequest.mergeMethod` | Merge strategy for all MRs | `merge` | Applied at project creation |
| `mergeRequest.squashOption` | Squash-on-merge setting | `default_off` | Applied at project creation |
| `mergeRequest.pipeline` | Pipelines must succeed | `false` | Maps to GitLab merge check |
| `mergeRequest.skippedPipelinesAreSuccessful` | Treat skipped pipelines as successful | `false` | Only relevant when `mergeRequest.pipeline=true` |
| `mergeRequest.allThreadsMustBeResolved` | All threads must be resolved | `false` | Maps to GitLab merge check |
| `mergeRequest.statusChecksMustSucceed` | Status checks must succeed | `false` | Maps to GitLab merge check |

### Merge method values

Expand All @@ -357,6 +365,10 @@ blatt01:
mergeRequest:
mergeMethod: ff # Students must keep a linear history
squashOption: always # All commits squashed into one on merge
pipeline: true # Block merge until latest pipeline succeeds
skippedPipelinesAreSuccessful: false
allThreadsMustBeResolved: true
statusChecksMustSucceed: true
```

## Full example
Expand Down
4 changes: 4 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ mpd:
| Assignment | `description` | `generated by glabs` |
| Assignment | `accesslevel` | `developer` |
| Assignment | `containerRegistry` | `false` |
| Assignment | `mergeRequest.pipeline` | `false` |
| Assignment | `mergeRequest.skippedPipelinesAreSuccessful` | `false` |
| Assignment | `mergeRequest.allThreadsMustBeResolved` | `false` |
| Assignment | `mergeRequest.statusChecksMustSucceed` | `false` |
| Startercode | `fromBranch` | `main` |
| Startercode | `toBranch` | `main` |
| Startercode | `devBranch` | value of `toBranch` |
Expand Down
35 changes: 23 additions & 12 deletions gitlab/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,17 @@ func (c *Client) generateProject(assignmentCfg *config.AssignmentConfig, name st
// Keep this fallback for safety when AssignmentConfig is constructed manually.
mergeMethod := config.MergeCommit
squashOption := config.SquashDefaultOff
pipelineMustSucceed := false
skippedPipelinesAreSuccessful := false
allThreadsMustBeResolved := false
statusChecksMustSucceed := false
if assignmentCfg.MergeRequest != nil {
mergeMethod = assignmentCfg.MergeRequest.MergeMethod
squashOption = assignmentCfg.MergeRequest.SquashOption
pipelineMustSucceed = assignmentCfg.MergeRequest.PipelineMustSucceed
skippedPipelinesAreSuccessful = assignmentCfg.MergeRequest.SkippedPipelinesAreSuccessful
allThreadsMustBeResolved = assignmentCfg.MergeRequest.AllThreadsMustBeResolved
statusChecksMustSucceed = assignmentCfg.MergeRequest.StatusChecksMustSucceed
}

// Convert glabs MergeMethod to GitLab API MergeMethodValue
Expand Down Expand Up @@ -46,18 +54,21 @@ func (c *Client) generateProject(assignmentCfg *config.AssignmentConfig, name st
}

p := &gitlab.CreateProjectOptions{
Name: gitlab.Ptr(name),
Description: gitlab.Ptr(assignmentCfg.Description),
NamespaceID: gitlab.Ptr(inID),
MergeRequestsAccessLevel: gitlab.Ptr(gitlab.EnabledAccessControl),
IssuesAccessLevel: gitlab.Ptr(gitlab.EnabledAccessControl),
BuildsAccessLevel: gitlab.Ptr(gitlab.EnabledAccessControl),
JobsEnabled: gitlab.Ptr(true),
Visibility: gitlab.Ptr(gitlab.PrivateVisibility),
ContainerRegistryEnabled: gitlab.Ptr(assignmentCfg.ContainerRegistry),
OnlyAllowMergeIfAllStatusChecksPassed: gitlab.Ptr(false),
MergeMethod: gitlab.Ptr(gitlabMergeMethod),
SquashOption: gitlab.Ptr(gitlabSquashOption),
Name: gitlab.Ptr(name),
Description: gitlab.Ptr(assignmentCfg.Description),
NamespaceID: gitlab.Ptr(inID),
MergeRequestsAccessLevel: gitlab.Ptr(gitlab.EnabledAccessControl),
IssuesAccessLevel: gitlab.Ptr(gitlab.EnabledAccessControl),
BuildsAccessLevel: gitlab.Ptr(gitlab.EnabledAccessControl),
JobsEnabled: gitlab.Ptr(true),
Visibility: gitlab.Ptr(gitlab.PrivateVisibility),
ContainerRegistryEnabled: gitlab.Ptr(assignmentCfg.ContainerRegistry),
OnlyAllowMergeIfPipelineSucceeds: gitlab.Ptr(pipelineMustSucceed),
AllowMergeOnSkippedPipeline: gitlab.Ptr(skippedPipelinesAreSuccessful),
OnlyAllowMergeIfAllDiscussionsAreResolved: gitlab.Ptr(allThreadsMustBeResolved),
OnlyAllowMergeIfAllStatusChecksPassed: gitlab.Ptr(statusChecksMustSucceed),
MergeMethod: gitlab.Ptr(gitlabMergeMethod),
SquashOption: gitlab.Ptr(gitlabSquashOption),
}

project, _, err := c.Projects.CreateProject(p)
Expand Down
49 changes: 49 additions & 0 deletions gitlab/projects_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,52 @@ func TestGenerateProject_FallsBackToExistingProject(t *testing.T) {
t.Fatalf("project = %#v", project)
}
}

func TestGenerateProject_SetsMergeRequestChecks(t *testing.T) {
var createBody string

client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects" {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("ReadAll failed: %v", err)
}
createBody = string(body)
_, _ = w.Write([]byte(`{"id":31,"name":"repo-a","path_with_namespace":"mpd/ss26/repo-a"}`))
return
}
w.WriteHeader(http.StatusNotFound)
})

assignmentCfg := &config.AssignmentConfig{
Description: "desc",
Path: "mpd/ss26",
MergeRequest: &config.MergeRequest{
PipelineMustSucceed: true,
SkippedPipelinesAreSuccessful: true,
AllThreadsMustBeResolved: true,
StatusChecksMustSucceed: true,
},
}

_, _, err := client.generateProject(assignmentCfg, "repo-a", 123)
if err != nil {
t.Fatalf("generateProject() returned error: %v", err)
}

checks := []string{
`"only_allow_merge_if_pipeline_succeeds":true`,
`"allow_merge_on_skipped_pipeline":true`,
`"only_allow_merge_if_all_discussions_are_resolved":true`,
`"only_allow_merge_if_all_status_checks_passed":true`,
"only_allow_merge_if_pipeline_succeeds=true",
"allow_merge_on_skipped_pipeline=true",
"only_allow_merge_if_all_discussions_are_resolved=true",
"only_allow_merge_if_all_status_checks_passed=true",
}
for i := 0; i < 4; i++ {
if !strings.Contains(createBody, checks[i]) && !strings.Contains(createBody, checks[i+4]) {
t.Fatalf("create project request body missing merge check field %q: %q", checks[i], createBody)
}
}
}
Loading