diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5508c06..4470d77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,64 +1,135 @@ name: ci + on: - push: - branches: - - "**" + push: + branches: + - "**" + pull_request: + workflow_dispatch: + inputs: + run_integration: + description: "Run integration tests with GitLab Testcontainers" + required: false + default: false + type: boolean permissions: - contents: write + contents: write + pull-requests: write jobs: - golangci: - name: lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Go from go.mod - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - - name: Install golangci-lint - run: | - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest - echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - - name: Run golangci-lint - run: golangci-lint run --timeout=10m + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Go from go.mod + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Install golangci-lint + run: | + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" + - name: Run golangci-lint + run: golangci-lint run --timeout=10m + + test-fast: + name: test (fast) + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v4 + - name: Set up Go from go.mod + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Download dependencies + run: go mod download + - name: Run fast tests + run: go test -v ./... + + coverage: + name: coverage + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v4 + - name: Set up Go from go.mod + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Download dependencies + run: go mod download + - name: Generate coverage + id: coverage-output + run: | + go test ./... -coverprofile=coverage.out + go tool cover -func=coverage.out | tee coverage.txt + { + echo 'coverage_text<> "$GITHUB_OUTPUT" + echo "## Coverage" >> "$GITHUB_STEP_SUMMARY" + echo '```text' >> "$GITHUB_STEP_SUMMARY" + cat coverage.txt >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + - name: Upload coverage artifact + uses: actions/upload-artifact@v4 + with: + name: go-coverage + path: | + coverage.out + coverage.txt + - name: Comment coverage on PR + if: github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: coverage + message: | + ## Coverage + + ```text + ${{ steps.coverage-output.outputs.coverage_text }} + ``` - test: - name: test - runs-on: ubuntu-latest - steps: - - name: Check out code into the Go module directory - uses: actions/checkout@v4 - - name: Set up Go from go.mod - uses: actions/setup-go@v5 - with: - go-version-file: go.mod - id: go - - name: Get dependencies - run: | - go get -v -t -d ./... - if [ -f Gopkg.toml ]; then - curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh - dep ensure - fi - - name: Test - run: | - go test -v ./... + test-integration: + name: test (integration) + runs-on: ubuntu-latest + timeout-minutes: 90 + if: >- + github.ref == 'refs/heads/main' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.run_integration == 'true') + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v4 + - name: Set up Go from go.mod + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Download dependencies + run: go mod download + - name: Run integration tests + env: + GLABS_RUN_GITLAB_TC: "1" + run: | + go test -tags=integration ./gitlab/... -count=1 -v -run '^TestIntegration_' - release: - runs-on: ubuntu-latest - needs: - - golangci - - test - steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - uses: go-semantic-release/action@v1.0.0 - with: - changelog-generator-opt: "emojis=true" - allow-initial-development-versions: true - hooks: goreleaser - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + release: + runs-on: ubuntu-latest + needs: + - golangci + - test-fast + - coverage + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: go-semantic-release/action@v1.0.0 + with: + changelog-generator-opt: "emojis=true" + allow-initial-development-versions: true + hooks: goreleaser + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 599e961..464d5ce 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,35 @@ glabs report [--html|--json] Issues and pull requests are welcome. +## Testing + +Default unit and contract tests: + +```sh +go test ./... +``` + +Integration tests with GitLab Testcontainers (opt-in): + +```sh +# Group/project lifecycle (createGroup, generateProject, …) +GLABS_RUN_GITLAB_TC=1 go test -tags=integration -v -count=1 ./gitlab/... -run TestIntegration_GitLab_GroupAndProjectLifecycle + +# Archive, Delete, ProtectToBranch, Setaccess end-to-end +GLABS_RUN_GITLAB_TC=1 go test -tags=integration -v -count=1 ./gitlab/... -run TestIntegration_GitLab_Operations + +# Run all integration tests at once +GLABS_RUN_GITLAB_TC=1 go test -tags=integration -v -count=1 ./gitlab/... -run '^TestIntegration_' +``` + +Notes: + +- Integration tests are intentionally opt-in because starting GitLab CE in a container takes 5–25 minutes. +- `GLABS_RUN_GITLAB_TC` means: run GitLab Testcontainer tests. +- Set `GLABS_RUN_GITLAB_TC=1` to enable them; without it the tests are skipped automatically. +- Example: `GLABS_RUN_GITLAB_TC=0` (or variable unset) keeps integration tests disabled. +- In CI, trigger them via the `run_integration` workflow dispatch input (dedicated `test-integration` job). + ## License MIT, see [LICENSE](LICENSE). diff --git a/cmd/smoke_test.go b/cmd/smoke_test.go new file mode 100644 index 0000000..4ccd1af --- /dev/null +++ b/cmd/smoke_test.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func resetReportGlobals(t *testing.T) { + t.Helper() + oldHTML := Html + oldJSON := Json + oldTemplate := Template + oldExportTemplate := ExportTemplate + oldOutput := OutPut + t.Cleanup(func() { + Html = oldHTML + Json = oldJSON + Template = oldTemplate + ExportTemplate = oldExportTemplate + OutPut = oldOutput + }) +} + +func TestRootCommand_HasSubcommands(t *testing.T) { + if len(rootCmd.Commands()) == 0 { + t.Fatal("expected root command to have subcommands") + } +} + +func TestReportCmd_ArgsRequireTwoArgs(t *testing.T) { + resetReportGlobals(t) + ExportTemplate = false + + err := reportCmd.Args(reportCmd, []string{"course-only"}) + if err == nil { + t.Fatal("expected args validation error") + } +} + +func TestReportCmd_ArgsAllowNoArgsWhenExportTemplate(t *testing.T) { + resetReportGlobals(t) + ExportTemplate = true + + if err := reportCmd.Args(reportCmd, nil); err != nil { + t.Fatalf("Args() unexpected error: %v", err) + } +} + +func TestReportCmd_RunPanicsForHtmlAndJSONTogether(t *testing.T) { + resetReportGlobals(t) + Html = true + Json = true + + defer func() { + if r := recover(); r == nil { + t.Fatal("expected panic when --html and --json are both set") + } + }() + + reportCmd.Run(reportCmd, []string{"mpd", "blatt01"}) +} + +func TestReportCmd_ExportDefaultTemplateToFile(t *testing.T) { + resetReportGlobals(t) + ExportTemplate = true + Html = true + + out := filepath.Join(t.TempDir(), "default-report-template.html") + OutPut = out + + reportCmd.Run(reportCmd, nil) + + data, err := os.ReadFile(out) + if err != nil { + t.Fatalf("reading template output file failed: %v", err) + } + if len(data) == 0 { + t.Fatal("expected template output file to be non-empty") + } + if !strings.Contains(string(data), " 0 { + resp += "," + } + resp += fmt.Sprintf(`{"id":%d,"name":"%s","username":"%s"}`, i+100, username, username) + } + resp += "]" + _, _ = w.Write([]byte(resp)) + return + + case r.Method == http.MethodGet && len(r.URL.Path) > len("/api/v4/users/") && r.URL.Path[:14] == "/api/v4/users/": + var id int + _, _ = fmt.Sscanf(r.URL.Path, "/api/v4/users/%d", &id) + username, ok := idUsers[id] + if !ok { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 User Not Found"}`)) + return + } + _, _ = w.Write([]byte(fmt.Sprintf(`{"id":%d,"name":"%s","username":"%s"}`, id, username, username))) + return + } + + w.WriteHeader(http.StatusNotFound) + }) + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + apiClient, err := gitlabapi.NewClient("token", gitlabapi.WithBaseURL(server.URL+"/api/v4")) + if err != nil { + t.Fatalf("creating gitlab test client failed: %v", err) + } + + return &Client{apiClient} +} + +func TestCheckDupsInGroups_NoDuplicates(t *testing.T) { + groups := []*config.Group{ + {Name: "g1", Members: []*config.Student{{Raw: "alice"}, {Raw: "bob"}}}, + {Name: "g2", Members: []*config.Student{{Raw: "carol"}}}, + } + + dups := checkDupsInGroups(groups) + if len(dups) != 0 { + t.Fatalf("expected no duplicates, got %#v", dups) + } +} + +func TestCheckDupsInGroups_WithDuplicates(t *testing.T) { + groups := []*config.Group{ + {Name: "g1", Members: []*config.Student{{Raw: "alice"}, {Raw: "bob"}}}, + {Name: "g2", Members: []*config.Student{{Raw: "bob"}, {Raw: "dave"}}}, + {Name: "g3", Members: []*config.Student{{Raw: "alice"}}}, + } + + dups := checkDupsInGroups(groups) + + if len(dups) != 2 { + t.Fatalf("expected two duplicate entries, got %#v", dups) + } + + if !reflect.DeepEqual(dups["alice"], []string{"g1", "g3"}) { + t.Fatalf("alice groups = %#v", dups["alice"]) + } + if !reflect.DeepEqual(dups["bob"], []string{"g1", "g2"}) { + t.Fatalf("bob groups = %#v", dups["bob"]) + } +} + +func TestCheckCourseReturnsTrueForResolvableStudents(t *testing.T) { + id := 1001 + username := "alice" + email := "new.user@example.org" + + client := newTestClient(t, + map[int]string{1001: "id-user"}, + map[string][]string{ + "alice": {"alice"}, + "new.user@example.org": {}, + }, + ) + + cfg := &config.CourseConfig{ + Course: "course", + Students: []*config.Student{ + {Id: &id, Raw: "1001"}, + {Username: &username, Raw: "alice"}, + {Email: &email, Raw: email}, + }, + } + + if ok := client.CheckCourse(cfg); !ok { + t.Fatal("CheckCourse() = false, want true") + } +} + +func TestCheckCourseReturnsFalseOnMissingUserAndDuplicate(t *testing.T) { + missing := "missinguser" + + client := newTestClient(t, + map[int]string{}, + map[string][]string{}, + ) + + cfg := &config.CourseConfig{ + Course: "course", + Groups: []*config.Group{ + { + Name: "g1", + Members: []*config.Student{ + {Username: &missing, Raw: "missinguser"}, + {Raw: "dup"}, + }, + }, + { + Name: "g2", + Members: []*config.Student{ + {Raw: "dup"}, + }, + }, + }, + } + + if ok := client.CheckCourse(cfg); ok { + t.Fatal("CheckCourse() = true, want false") + } +} diff --git a/gitlab/contract_test_helpers_test.go b/gitlab/contract_test_helpers_test.go new file mode 100644 index 0000000..024e041 --- /dev/null +++ b/gitlab/contract_test_helpers_test.go @@ -0,0 +1,23 @@ +package gitlab + +import ( + "net/http" + "net/http/httptest" + "testing" + + gitlabapi "gitlab.com/gitlab-org/api/client-go/v2" +) + +func newContractClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + + apiClient, err := gitlabapi.NewClient("token", gitlabapi.WithBaseURL(server.URL+"/api/v4")) + if err != nil { + t.Fatalf("creating gitlab test client failed: %v", err) + } + + return &Client{apiClient} +} diff --git a/gitlab/delete.go b/gitlab/delete.go index fa1f6b8..6c9b9e3 100644 --- a/gitlab/delete.go +++ b/gitlab/delete.go @@ -2,7 +2,6 @@ package gitlab import ( "fmt" - "os" "github.com/obcode/glabs/config" "github.com/rs/zerolog/log" @@ -13,7 +12,7 @@ func (c *Client) Delete(assignmentCfg *config.AssignmentConfig) { assignmentGitLabGroupID, err := c.getGroupID(assignmentCfg) if err != nil { fmt.Printf("error: GitLab group for assignment does not exist, please create the group %s\n", assignmentCfg.URL) - os.Exit(1) + exitFunc(1) } switch per := assignmentCfg.Per; per { @@ -23,7 +22,7 @@ func (c *Client) Delete(assignmentCfg *config.AssignmentConfig) { c.deletePerStudent(assignmentCfg, assignmentGitLabGroupID) default: fmt.Printf("it is only possible to delete projects for students or groups, not for %v", per) - os.Exit(1) + exitFunc(1) } } diff --git a/gitlab/delete_contract_test.go b/gitlab/delete_contract_test.go new file mode 100644 index 0000000..e2ed53a --- /dev/null +++ b/gitlab/delete_contract_test.go @@ -0,0 +1,213 @@ +package gitlab + +import ( + "fmt" + "net/http" + "strings" + "testing" + + "github.com/obcode/glabs/config" +) + +// groupSearchHandler returns a handler that mocks getGroupID + Search.ProjectsByGroup + DeleteProject. +// groupID: the group to return for path "mpd/ss26/blatt-01" +// projectID: the project to return from group search, 0 → empty results +// deletePath: the DELETE path that should succeed +func makeDeleteHandler(groupID, projectID int64, projectName string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch { + // getGroupIDByFullPath → SearchGroup + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups": + fmt.Fprintf(w, `[{"id":%d,"full_path":"mpd/ss26/blatt-01"}]`, groupID) + + // Search.ProjectsByGroup + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, fmt.Sprintf("/api/v4/groups/%d/search", groupID)): + if projectID == 0 { + _, _ = w.Write([]byte(`[]`)) + } else { + fmt.Fprintf(w, `[{"id":%d,"name":%q}]`, projectID, projectName) + } + + // DeleteProject + case r.Method == http.MethodDelete && r.URL.Path == fmt.Sprintf("/api/v4/projects/%d", projectID): + w.WriteHeader(http.StatusAccepted) + + default: + w.WriteHeader(http.StatusNotFound) + } + } +} + +// ---- Delete (top-level) ----------------------------------------------------- + +func TestDelete_GroupNotFound_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + } + assertExitCode(t, 1, func() { client.Delete(cfg) }) +} + +func TestDelete_InvalidPer_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups" { + _, _ = w.Write([]byte(`[{"id":1,"full_path":"mpd/ss26/blatt-01"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + Per: config.PerFailed, + } + assertExitCode(t, 1, func() { client.Delete(cfg) }) +} + +// ---- deletePerStudent ------------------------------------------------------- + +func TestDeletePerStudent_NoStudents(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + Students: []*config.Student{}, + } + client.deletePerStudent(cfg, 1) +} + +func TestDeletePerStudent_ProjectFound_Deleted(t *testing.T) { + username := "alice" + client := newContractClient(t, makeDeleteHandler(1, 42, "mpd-blatt01-alice")) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + } + client.deletePerStudent(cfg, 1) +} + +func TestDeletePerStudent_ProjectNotFound(t *testing.T) { + username := "alice" + client := newContractClient(t, makeDeleteHandler(1, 0, "")) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + } + // Project not found → delete is a no-op + client.deletePerStudent(cfg, 1) +} + +// ---- deletePerGroup --------------------------------------------------------- + +func TestDeletePerGroup_NoGroups(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + Groups: []*config.Group{}, + } + client.deletePerGroup(cfg, 1) +} + +func TestDeletePerGroup_ProjectFound_Deleted(t *testing.T) { + alice := "alice" + client := newContractClient(t, makeDeleteHandler(1, 43, "mpd-blatt01-team1")) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + UseCoursenameAsPrefix: true, + Groups: []*config.Group{ + {Name: "team1", Members: []*config.Student{{Username: &alice, Raw: "alice"}}}, + }, + } + client.deletePerGroup(cfg, 1) +} + +// ---- delete (low-level) ----------------------------------------------------- + +func TestDelete_LowLevel_SearchError(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"500 Internal Server Error"}`)) + }) + + // Should log error and return without panicking + client.delete(1, "myrepo") +} + +func TestDelete_LowLevel_EmptyResults(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/search") { + _, _ = w.Write([]byte(`[]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + // No results → nothing to delete + client.delete(1, "myrepo") +} + +func TestDelete_LowLevel_NameMismatch(t *testing.T) { + // Search returns a project but with a different name → no deletion + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/search") { + _, _ = w.Write([]byte(`[{"id":99,"name":"different-name"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + client.delete(1, "myrepo") +} + +func TestDelete_LowLevel_DeleteFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/search"): + _, _ = w.Write([]byte(`[{"id":99,"name":"myrepo"}]`)) + case r.Method == http.MethodDelete && r.URL.Path == "/api/v4/projects/99": + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"403 Forbidden"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + // delete fails → logs error, no panic + client.delete(1, "myrepo") +} diff --git a/gitlab/generate.go b/gitlab/generate.go index 7e771f4..847169d 100644 --- a/gitlab/generate.go +++ b/gitlab/generate.go @@ -2,7 +2,6 @@ package gitlab import ( "fmt" - "os" "time" "github.com/logrusorgru/aurora" @@ -23,7 +22,7 @@ func (c *Client) Generate(assignmentCfg *config.AssignmentConfig) { Str("assignmentpath", assignmentCfg.Path). Msg("error while creating group for assignment") fmt.Printf("error: cannot create GitLab group for assignment, please create the group %s\n", assignmentCfg.URL) - os.Exit(1) + exitFunc(1) } } @@ -34,7 +33,7 @@ func (c *Client) Generate(assignmentCfg *config.AssignmentConfig) { if err != nil { fmt.Println(err) - os.Exit(1) + exitFunc(1) } } @@ -45,7 +44,7 @@ func (c *Client) Generate(assignmentCfg *config.AssignmentConfig) { c.generatePerStudent(assignmentCfg, assignmentGitLabGroupID, starterrepo) default: fmt.Printf("it is only possible to generate for students oder groups, not for %v", per) - os.Exit(1) + exitFunc(1) } } diff --git a/gitlab/gitlab.go b/gitlab/gitlab.go index e44e5c1..f527b0a 100644 --- a/gitlab/gitlab.go +++ b/gitlab/gitlab.go @@ -17,7 +17,7 @@ func NewClient() *Client { gitlab.WithBaseURL(viper.GetString("gitlab.host"))) if err != nil { - panic("cannot create a gitlab client") + panicFunc("cannot create a gitlab client") } return &Client{client} diff --git a/gitlab/groups_contract_test.go b/gitlab/groups_contract_test.go new file mode 100644 index 0000000..0760b18 --- /dev/null +++ b/gitlab/groups_contract_test.go @@ -0,0 +1,88 @@ +package gitlab + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/obcode/glabs/config" +) + +func TestGetGroupIDByFullPath_FindsGroup(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups" { + _, _ = w.Write([]byte(`[ + {"id":1,"full_path":"other/path"}, + {"id":42,"full_path":"mpd/ss26/blatt-01"} + ]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + id, err := client.getGroupIDByFullPath("mpd/ss26/blatt-01") + if err != nil { + t.Fatalf("getGroupIDByFullPath() returned error: %v", err) + } + if id != 42 { + t.Fatalf("group id = %d, want 42", id) + } +} + +func TestGetGroupIDByFullPath_ReturnsErrorWhenNotFound(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups" { + _, _ = w.Write([]byte(`[{"id":1,"full_path":"other/path"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + _, err := client.getGroupIDByFullPath("mpd/ss26/blatt-01") + if err == nil { + t.Fatal("getGroupIDByFullPath() expected error, got nil") + } +} + +func TestCreateGroup_WithParentGroup(t *testing.T) { + var createBody string + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups": + _, _ = w.Write([]byte(`[{"id":41,"full_path":"mpd/ss26"}]`)) + return + + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/groups": + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("ReadAll failed: %v", err) + } + createBody = string(body) + _, _ = w.Write([]byte(`{"id":99,"full_path":"mpd/ss26/blatt-01"}`)) + return + } + + w.WriteHeader(http.StatusNotFound) + }) + + assignmentCfg := &config.AssignmentConfig{Course: "mpd", Path: "mpd/ss26/blatt-01"} + id, err := client.createGroup(assignmentCfg) + if err != nil { + t.Fatalf("createGroup() returned error: %v", err) + } + if id != 99 { + t.Fatalf("group id = %d, want 99", id) + } + + if !strings.Contains(createBody, `"name":"blatt-01"`) && !strings.Contains(createBody, "name=blatt-01") { + t.Fatalf("create group request body missing name: %q", createBody) + } + if !strings.Contains(createBody, `"path":"blatt-01"`) && !strings.Contains(createBody, "path=blatt-01") { + t.Fatalf("create group request body missing path: %q", createBody) + } + if !strings.Contains(createBody, `"parent_id":41`) && !strings.Contains(createBody, "parent_id=41") { + t.Fatalf("create group request body missing parent_id: %q", createBody) + } +} diff --git a/gitlab/integration_gitlab_test.go b/gitlab/integration_gitlab_test.go new file mode 100644 index 0000000..77bb14a --- /dev/null +++ b/gitlab/integration_gitlab_test.go @@ -0,0 +1,390 @@ +//go:build integration + +package gitlab + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/obcode/glabs/config" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + gitlabapi "gitlab.com/gitlab-org/api/client-go/v2" +) + +const ( + gitLabImage = "gitlab/gitlab-ce:17.6.1-ce.0" + gitLabRootToken = "glabs-integration-root-token" + runIntegrationEnv = "GLABS_RUN_GITLAB_TC" + gitLabRootPassword = "zXq7!Rp3@Wk9#Tm2vL" +) + +func requireIntegrationEnabled(t *testing.T) { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + if os.Getenv(runIntegrationEnv) != "1" { + t.Skipf("set %s=1 to run GitLab testcontainer integration tests", runIntegrationEnv) + } +} + +func createRootToken(ctx context.Context, t *testing.T, c testcontainers.Container) string { + t.Helper() + + script := strings.Join([]string{ + "user = User.find_by_username('root')", + "token = user.personal_access_tokens.find_by(name: 'glabs-integration-token')", + "token&.revoke!", + "token = user.personal_access_tokens.create!(name: 'glabs-integration-token', scopes: [:api], expires_at: 365.days.from_now)", + fmt.Sprintf("token.set_token('%s')", gitLabRootToken), + "token.save!", + "puts token.token", + }, "; ") + + cmd := []string{"gitlab-rails", "runner", script} + exitCode, reader, err := c.Exec(ctx, cmd) + if err != nil { + t.Fatalf("creating root token via gitlab-rails failed: %v", err) + } + outputBytes, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("reading gitlab-rails output failed: %v", err) + } + if exitCode != 0 { + t.Fatalf("gitlab-rails runner exit code %d, output:\n%s", exitCode, string(outputBytes)) + } + + scanner := bufio.NewScanner(strings.NewReader(string(outputBytes))) + lastLine := "" + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" { + lastLine = line + } + } + if lastLine == "" { + t.Fatalf("could not parse token from gitlab-rails output: %q", string(outputBytes)) + } + + // Docker exec returns a multiplexed stream with binary headers; strip any non-printable bytes. + lastLine = strings.Map(func(r rune) rune { + if r >= 32 && r < 127 { + return r + } + return -1 + }, lastLine) + + return lastLine +} + +func startGitLabContainer(t *testing.T) (*Client, string) { + t.Helper() + + ctx := context.Background() + + req := testcontainers.ContainerRequest{ + Image: gitLabImage, + ExposedPorts: []string{"80/tcp"}, + Env: map[string]string{ + "GITLAB_ROOT_PASSWORD": gitLabRootPassword, + "GITLAB_OMNIBUS_CONFIG": strings.Join([]string{ + "external_url 'http://localhost'", + "nginx['listen_port'] = 80", + "prometheus_monitoring['enable'] = false", + "puma['worker_processes'] = 0", + "sidekiq['max_concurrency'] = 5", + }, "; "), + }, + WaitingFor: wait.ForHTTP("/users/sign_in").WithPort("80/tcp").WithStartupTimeout(25 * time.Minute), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("starting gitlab testcontainer failed: %v", err) + } + t.Cleanup(func() { + _ = container.Terminate(ctx) + }) + + host, err := container.Host(ctx) + if err != nil { + t.Fatalf("getting container host failed: %v", err) + } + port, err := container.MappedPort(ctx, "80/tcp") + if err != nil { + t.Fatalf("getting mapped port failed: %v", err) + } + + baseURL := fmt.Sprintf("http://%s:%s", host, port.Port()) + rootToken := createRootToken(ctx, t, container) + + apiClient, err := gitlabapi.NewClient(rootToken, gitlabapi.WithBaseURL(baseURL+"/api/v4")) + if err != nil { + t.Fatalf("creating gitlab api client failed: %v", err) + } + + return &Client{apiClient}, baseURL +} + +// TestIntegration_GitLab_Operations starts one container and exercises Archive, +// Delete, ProtectToBranch and Setaccess end-to-end in sub-tests so that the +// expensive container start-up happens only once. +func TestIntegration_GitLab_Operations(t *testing.T) { + requireIntegrationEnabled(t) + + client, baseURL := startGitLabContainer(t) + + // ── Shared parent group ────────────────────────────────────────────────── + visibility := gitlabapi.PublicVisibility + parentName := "ops-it-parent" + parentPath := "ops-it-parent" + parent, _, err := client.Groups.CreateGroup(&gitlabapi.CreateGroupOptions{ + Name: &parentName, + Path: &parentPath, + Visibility: &visibility, + }) + if err != nil { + t.Fatalf("creating parent group failed: %v", err) + } + + // ── Shared test user (used by Setaccess sub-test) ──────────────────────── + itUsername := "it-testuser" + itName := "IT Testuser" + itEmail := "it-testuser@example.com" + itPassword := "Pa$$w0rd-test-99" + skipConfirmation := true + _, _, err = client.Users.CreateUser(&gitlabapi.CreateUserOptions{ + Username: &itUsername, + Name: &itName, + Email: &itEmail, + Password: &itPassword, + SkipConfirmation: &skipConfirmation, + }) + if err != nil { + t.Fatalf("creating test user failed: %v", err) + } + + // ── Helper: build an AssignmentConfig for a sub-group of the parent ────── + makeAssignmentCfg := func(subPath, studentUsername string) *config.AssignmentConfig { + path := parent.FullPath + "/" + subPath + un := studentUsername + return &config.AssignmentConfig{ + Course: "it", + Name: "a1", + Path: path, + URL: baseURL + "/" + path, + Per: config.PerStudent, + Description: "integration test", + ContainerRegistry: false, + Students: []*config.Student{{Username: &un, Raw: un}}, + } + } + + // ── Helper: create assignment group + student project ──────────────────── + createGroupAndProject := func(t *testing.T, subPath, studentUsername string, withReadme bool) *config.AssignmentConfig { + t.Helper() + cfg := makeAssignmentCfg(subPath, studentUsername) + groupID, err := client.createGroup(cfg) + if err != nil { + t.Fatalf("createGroup(%q) failed: %v", subPath, err) + } + repoName := cfg.RepoNameWithSuffix(studentUsername) + initReadme := withReadme + _, _, err = client.Projects.CreateProject(&gitlabapi.CreateProjectOptions{ + Name: &repoName, + NamespaceID: &groupID, + InitializeWithReadme: &initReadme, + }) + if err != nil { + t.Fatalf("createProject(%q) failed: %v", repoName, err) + } + return cfg + } + + // ── Sub-test: Archive / Unarchive ───────────────────────────────────────── + t.Run("Archive", func(t *testing.T) { + cfg := createGroupAndProject(t, "archive-a1", "student1", false) + projectPath := cfg.Path + "/" + cfg.RepoNameWithSuffix("student1") + + client.Archive(cfg, false) + + proj, _, err := client.Projects.GetProject(projectPath, &gitlabapi.GetProjectOptions{}) + if err != nil { + t.Fatalf("GetProject after archive failed: %v", err) + } + if !proj.Archived { + t.Fatal("expected project to be archived") + } + + client.Archive(cfg, true) // unarchive + + proj, _, err = client.Projects.GetProject(projectPath, &gitlabapi.GetProjectOptions{}) + if err != nil { + t.Fatalf("GetProject after unarchive failed: %v", err) + } + if proj.Archived { + t.Fatal("expected project to be unarchived after Archive(unarchive=true)") + } + }) + + // ── Sub-test: Delete ────────────────────────────────────────────────────── + t.Run("Delete", func(t *testing.T) { + cfg := createGroupAndProject(t, "delete-a1", "student1", false) + repoName := cfg.RepoNameWithSuffix("student1") + + groupID, err := client.getGroupIDByFullPath(cfg.Path) + if err != nil { + t.Fatalf("getGroupIDByFullPath before delete failed: %v", err) + } + + client.Delete(cfg) + + projects, _, err := client.Search.ProjectsByGroup(groupID, repoName, &gitlabapi.SearchOptions{}) + if err != nil { + t.Fatalf("search after Delete failed: %v", err) + } + for _, p := range projects { + if p.Name == repoName { + t.Fatalf("project %q still exists after Delete()", repoName) + } + } + }) + + // ── Sub-test: ProtectToBranch ───────────────────────────────────────────── + t.Run("ProtectToBranch", func(t *testing.T) { + // withReadme=true so the project has a 'main' branch immediately + cfg := createGroupAndProject(t, "protect-a1", "student1", true) + cfg.Startercode = &config.Startercode{ + ToBranch: "main", + ProtectToBranch: true, + } + + client.ProtectToBranch(cfg) + + projectPath := cfg.Path + "/" + cfg.RepoNameWithSuffix("student1") + proj, _, err := client.Projects.GetProject(projectPath, &gitlabapi.GetProjectOptions{}) + if err != nil { + t.Fatalf("GetProject after ProtectToBranch failed: %v", err) + } + branches, _, err := client.ProtectedBranches.ListProtectedBranches( + proj.ID, &gitlabapi.ListProtectedBranchesOptions{}) + if err != nil { + t.Fatalf("ListProtectedBranches failed: %v", err) + } + found := false + for _, b := range branches { + if b.Name == "main" { + found = true + break + } + } + if !found { + t.Fatal("expected branch 'main' to be listed as protected") + } + }) + + // ── Sub-test: Setaccess ─────────────────────────────────────────────────── + t.Run("Setaccess", func(t *testing.T) { + cfg := createGroupAndProject(t, "setaccess-a1", itUsername, false) + cfg.AccessLevel = config.AccessLevel(gitlabapi.DeveloperPermissions) // 30 + + client.Setaccess(cfg) + + projectPath := cfg.Path + "/" + cfg.RepoNameWithSuffix(itUsername) + proj, _, err := client.Projects.GetProject(projectPath, &gitlabapi.GetProjectOptions{}) + if err != nil { + t.Fatalf("GetProject after Setaccess failed: %v", err) + } + members, _, err := client.ProjectMembers.ListProjectMembers( + proj.ID, &gitlabapi.ListProjectMembersOptions{}) + if err != nil { + t.Fatalf("ListProjectMembers failed: %v", err) + } + found := false + for _, m := range members { + if m.Username == itUsername { + found = true + if gitlabapi.AccessLevelValue(m.AccessLevel) != gitlabapi.DeveloperPermissions { + t.Fatalf("member access level = %v, want DeveloperPermissions (30)", m.AccessLevel) + } + break + } + } + if !found { + t.Fatalf("expected user %q to be a project member after Setaccess()", itUsername) + } + }) +} + +func TestIntegration_GitLab_GroupAndProjectLifecycle(t *testing.T) { + requireIntegrationEnabled(t) + + client, baseURL := startGitLabContainer(t) + + parentName := "mpd-it-parent" + parentPath := "mpd-it-parent" + visibility := gitlabapi.PublicVisibility + parent, _, err := client.Groups.CreateGroup(&gitlabapi.CreateGroupOptions{ + Name: &parentName, + Path: &parentPath, + Visibility: &visibility, + }) + if err != nil { + t.Fatalf("creating parent group failed: %v", err) + } + + assignmentCfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "a1", + Path: parent.FullPath + "/blatt-01", + URL: baseURL + "/" + parent.FullPath + "/blatt-01", + Per: config.PerStudent, + Description: "integration test assignment", + ContainerRegistry: false, + } + + groupID, err := client.createGroup(assignmentCfg) + if err != nil { + t.Fatalf("createGroup failed: %v", err) + } + if groupID == 0 { + t.Fatal("createGroup returned zero group id") + } + + resolvedGroupID, err := client.getGroupIDByFullPath(assignmentCfg.Path) + if err != nil { + t.Fatalf("getGroupIDByFullPath failed: %v", err) + } + if resolvedGroupID != groupID { + t.Fatalf("resolved group id = %d, want %d", resolvedGroupID, groupID) + } + + project, generated, err := client.generateProject(assignmentCfg, "a1-team1", groupID) + if err != nil { + t.Fatalf("generateProject failed: %v", err) + } + if !generated { + t.Fatal("expected generateProject to create a new project") + } + if project == nil || project.PathWithNamespace == "" { + t.Fatalf("invalid project response: %#v", project) + } + + foundProject, err := client.getProjectByName(project.PathWithNamespace) + if err != nil { + t.Fatalf("getProjectByName failed: %v", err) + } + if foundProject.ID != project.ID { + t.Fatalf("found project id = %d, want %d", foundProject.ID, project.ID) + } +} diff --git a/gitlab/issues_contract_test.go b/gitlab/issues_contract_test.go new file mode 100644 index 0000000..a4cedf3 --- /dev/null +++ b/gitlab/issues_contract_test.go @@ -0,0 +1,142 @@ +package gitlab + +import ( + "net/http" + "strings" + "testing" + + "github.com/obcode/glabs/config" + gitlabapi "gitlab.com/gitlab-org/api/client-go/v2" +) + +func TestGetStartercodeProject_ParseSSHURL(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/v4/projects/") { + _, _ = w.Write([]byte(`{"id":42,"path_with_namespace":"mpd/startercode/blatt-01"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Startercode: &config.Startercode{URL: "git@gitlab.example.org:mpd/startercode/blatt-01.git"}, + } + + project, err := client.getStartercodeProject(cfg) + if err != nil { + t.Fatalf("getStartercodeProject() error = %v", err) + } + if project == nil || project.ID != 42 { + t.Fatalf("unexpected project: %#v", project) + } +} + +func TestGetStartercodeProject_ParseHTTPSURLWithoutGitSuffix(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/v4/projects/") { + _, _ = w.Write([]byte(`{"id":43,"path_with_namespace":"mpd/startercode/blatt-02"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Startercode: &config.Startercode{URL: "https://gitlab.example.org/mpd/startercode/blatt-02"}, + } + + project, err := client.getStartercodeProject(cfg) + if err != nil { + t.Fatalf("getStartercodeProject() error = %v", err) + } + if project == nil || project.ID != 43 { + t.Fatalf("unexpected project: %#v", project) + } +} + +func TestGetStartercodeProject_InvalidURL(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Startercode: &config.Startercode{URL: "not-a-valid-url"}, + } + + _, err := client.getStartercodeProject(cfg) + if err == nil { + t.Fatal("expected parse error for invalid startercode URL") + } +} + +func TestGetStartercodeProject_ProjectLookupFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + cfg := &config.AssignmentConfig{ + Startercode: &config.Startercode{URL: "https://gitlab.example.org/mpd/startercode/blatt-03.git"}, + } + + _, err := client.getStartercodeProject(cfg) + if err == nil { + t.Fatal("expected project lookup error") + } +} + +func TestReplicateIssue_Success(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/1/issues/7": + _, _ = w.Write([]byte(`{"id":7001,"iid":7,"title":"Fix tests","description":"Please fix tests"}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/2/issues": + _, _ = w.Write([]byte(`{"id":9901,"iid":99}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + source := &gitlabapi.Project{ID: 1, PathWithNamespace: "mpd/startercode/blatt-01"} + target := &gitlabapi.Project{ID: 2, PathWithNamespace: "mpd/ss26/blatt-01/team1"} + + if err := client.replicateIssue(source, target, 7); err != nil { + t.Fatalf("replicateIssue() error = %v", err) + } +} + +func TestReplicateIssue_GetIssueFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + source := &gitlabapi.Project{ID: 1} + target := &gitlabapi.Project{ID: 2} + + err := client.replicateIssue(source, target, 7) + if err == nil { + t.Fatal("expected error when loading issue fails") + } +} + +func TestReplicateIssue_CreateIssueFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/1/issues/7": + _, _ = w.Write([]byte(`{"id":7001,"iid":7,"title":"Fix tests","description":"Please fix tests"}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/2/issues": + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"403 Forbidden"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + source := &gitlabapi.Project{ID: 1} + target := &gitlabapi.Project{ID: 2} + + err := client.replicateIssue(source, target, 7) + if err == nil { + t.Fatal("expected error when creating issue fails") + } +} diff --git a/gitlab/projects_contract_test.go b/gitlab/projects_contract_test.go new file mode 100644 index 0000000..cfaa061 --- /dev/null +++ b/gitlab/projects_contract_test.go @@ -0,0 +1,137 @@ +package gitlab + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/obcode/glabs/config" +) + +func TestGetProjectByName_FindsSingleProject(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects" { + _, _ = w.Write([]byte(`[{"id":1,"name":"repo","path_with_namespace":"mpd/ss26/repo"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + project, err := client.getProjectByName("mpd/ss26/repo") + if err != nil { + t.Fatalf("getProjectByName() returned error: %v", err) + } + if project == nil || project.ID != 1 { + t.Fatalf("project = %#v", project) + } +} + +func TestGetProjectByName_SelectsExactMatchOnMultipleResults(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects" { + _, _ = w.Write([]byte(`[ + {"id":1,"name":"repo","path_with_namespace":"other/path/repo"}, + {"id":2,"name":"repo","path_with_namespace":"mpd/ss26/repo"} + ]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + project, err := client.getProjectByName("mpd/ss26/repo") + if err != nil { + t.Fatalf("getProjectByName() returned error: %v", err) + } + if project == nil || project.ID != 2 { + t.Fatalf("project = %#v", project) + } +} + +func TestGetProjectByName_ReturnsErrorWhenNotFound(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects" { + _, _ = w.Write([]byte(`[]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + project, err := client.getProjectByName("mpd/ss26/repo") + if err == nil { + t.Fatal("getProjectByName() expected error, got nil") + } + if project != nil { + t.Fatalf("project = %#v, want nil", project) + } +} + +func TestGenerateProject_CreatesProject(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":11,"name":"repo-a","path_with_namespace":"mpd/ss26/repo-a"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + assignmentCfg := &config.AssignmentConfig{ + Description: "desc", + Path: "mpd/ss26", + ContainerRegistry: true, + } + + project, generated, err := client.generateProject(assignmentCfg, "repo-a", 123) + if err != nil { + t.Fatalf("generateProject() returned error: %v", err) + } + if !generated { + t.Fatal("generateProject() generated = false, want true") + } + if project == nil || project.ID != 11 { + t.Fatalf("project = %#v", project) + } + if !strings.Contains(createBody, `"name":"repo-a"`) && !strings.Contains(createBody, "name=repo-a") { + t.Fatalf("create project request body missing name: %q", createBody) + } +} + +func TestGenerateProject_FallsBackToExistingProject(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects": + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":{"name":["has already been taken"]}}`)) + return + + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects": + if got := r.URL.Query().Get("search"); !strings.Contains(got, "repo-a") { + t.Fatalf("search query = %q", got) + } + _, _ = w.Write([]byte(`[{"id":21,"name":"repo-a","path_with_namespace":"mpd/ss26/repo-a"}]`)) + return + } + + w.WriteHeader(http.StatusNotFound) + }) + + assignmentCfg := &config.AssignmentConfig{Description: "desc", Path: "mpd/ss26"} + + project, generated, err := client.generateProject(assignmentCfg, "repo-a", 123) + if err != nil { + t.Fatalf("generateProject() returned error: %v", err) + } + if generated { + t.Fatal("generateProject() generated = true, want false") + } + if project == nil || project.ID != 21 { + t.Fatalf("project = %#v", project) + } +} diff --git a/gitlab/protect.go b/gitlab/protect.go index ab72e5c..2a45ae5 100644 --- a/gitlab/protect.go +++ b/gitlab/protect.go @@ -2,7 +2,6 @@ package gitlab import ( "fmt" - "os" "time" "github.com/logrusorgru/aurora" @@ -16,7 +15,7 @@ func (c *Client) ProtectToBranch(assignmentCfg *config.AssignmentConfig) { _, err := c.getGroupID(assignmentCfg) if err != nil { fmt.Printf("error: GitLab group for assignment does not exist, please create the group %s\n", assignmentCfg.URL) - os.Exit(1) + exitFunc(1) } switch per := assignmentCfg.Per; per { @@ -26,7 +25,7 @@ func (c *Client) ProtectToBranch(assignmentCfg *config.AssignmentConfig) { c.protectToBranchPerStudent(assignmentCfg) default: fmt.Printf("it is only possible to protect the branch for students oder groups, not for %v", per) - os.Exit(1) + exitFunc(1) } } diff --git a/gitlab/protect_contract_test.go b/gitlab/protect_contract_test.go new file mode 100644 index 0000000..b3e7a5e --- /dev/null +++ b/gitlab/protect_contract_test.go @@ -0,0 +1,359 @@ +package gitlab + +import ( + "net/http" + "strings" + "testing" + + "github.com/obcode/glabs/config" + gitlabapi "gitlab.com/gitlab-org/api/client-go/v2" +) + +// ---- ProtectToBranch (top-level) -------------------------------------------- + +func TestProtectToBranch_GroupNotFound_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + Startercode: &config.Startercode{ + ToBranch: "main", + ProtectToBranch: true, + }, + } + assertExitCode(t, 1, func() { client.ProtectToBranch(cfg) }) +} + +func TestProtectToBranch_InvalidPer_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups" { + _, _ = w.Write([]byte(`[{"id":1,"full_path":"mpd/ss26/blatt-01"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + Per: config.PerFailed, + Startercode: &config.Startercode{ + ToBranch: "main", + ProtectToBranch: true, + }, + } + assertExitCode(t, 1, func() { client.ProtectToBranch(cfg) }) +} + +// ---- protectToBranchPerStudent ---------------------------------------------- + +func TestProtectToBranchPerStudent_NoStudents(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + Students: []*config.Student{}, + Startercode: &config.Startercode{ + ToBranch: "main", + ProtectToBranch: true, + }, + } + client.protectToBranchPerStudent(cfg) +} + +func TestProtectToBranchPerStudent_GetProjectFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + username := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + Startercode: &config.Startercode{ + ToBranch: "main", + ProtectToBranch: true, + }, + } + // GetProject fails → prints error and returns + client.protectToBranchPerStudent(cfg) +} + +func TestProtectToBranchPerStudent_Success(t *testing.T) { + pj := `{"id":1,"name":"mpd-blatt01-alice","path_with_namespace":"mpd/ss26/blatt-01/mpd-blatt01-alice","ssh_url_to_repo":"git@example.com:mpd/ss26/mpd-blatt01-alice.git"}` + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "mpd-blatt01-alice"): + _, _ = w.Write([]byte(pj)) + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "protected_branches"): + _, _ = w.Write([]byte(`{"id":1,"name":"main"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + username := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + Startercode: &config.Startercode{ + ToBranch: "main", + ProtectToBranch: true, + }, + } + client.protectToBranchPerStudent(cfg) +} + +// ---- protectToBranchPerGroup ------------------------------------------------ + +func TestProtectToBranchPerGroup_NoGroups(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + Groups: []*config.Group{}, + Startercode: &config.Startercode{ + ToBranch: "main", + ProtectToBranch: true, + }, + } + client.protectToBranchPerGroup(cfg) +} + +func TestProtectToBranchPerGroup_Success(t *testing.T) { + pj := `{"id":2,"name":"mpd-blatt01-team1","path_with_namespace":"mpd/ss26/blatt-01/mpd-blatt01-team1","ssh_url_to_repo":"git@example.com:mpd/ss26/mpd-blatt01-team1.git"}` + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "mpd-blatt01-team1"): + _, _ = w.Write([]byte(pj)) + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "protected_branches"): + _, _ = w.Write([]byte(`{"id":1,"name":"main"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + alice := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + UseCoursenameAsPrefix: true, + Groups: []*config.Group{ + {Name: "team1", Members: []*config.Student{{Username: &alice, Raw: "alice"}}}, + }, + Startercode: &config.Startercode{ + ToBranch: "main", + ProtectToBranch: true, + }, + } + client.protectToBranchPerGroup(cfg) +} + +// ---- protectBranch ---------------------------------------------------------- + +func TestProtectBranch_NoFlags_IsNoOp(t *testing.T) { + // Neither ProtectToBranch nor ProtectDevBranchMergeOnly → nothing happens + called := false + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Startercode: &config.Startercode{ + ToBranch: "main", + ProtectToBranch: false, + ProtectDevBranchMergeOnly: false, + }, + } + project := &gitlabapi.Project{ID: 1, Name: "myrepo"} + err := client.protectBranch(cfg, project, false) + if err != nil { + t.Fatalf("protectBranch() (no-op) error = %v", err) + } + if called { + t.Fatal("protectBranch() made HTTP calls when neither flag is set") + } +} + +func TestProtectBranch_ProtectToBranch_Success(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "protected_branches"): + _, _ = w.Write([]byte(`{"id":1,"name":"main"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + cfg := &config.AssignmentConfig{ + Startercode: &config.Startercode{ + ToBranch: "main", + DevBranch: "develop", + ProtectToBranch: true, + }, + } + project := &gitlabapi.Project{ID: 1, Name: "myrepo"} + err := client.protectBranch(cfg, project, false) + if err != nil { + t.Fatalf("protectBranch(ProtectToBranch) error = %v", err) + } +} + +func TestProtectBranch_ProtectDevBranchMergeOnly_Success(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "protected_branches"): + _, _ = w.Write([]byte(`{"id":1,"name":"develop"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + cfg := &config.AssignmentConfig{ + Startercode: &config.Startercode{ + ToBranch: "main", + DevBranch: "develop", + ProtectDevBranchMergeOnly: true, + }, + } + project := &gitlabapi.Project{ID: 1, Name: "myrepo"} + err := client.protectBranch(cfg, project, false) + if err != nil { + t.Fatalf("protectBranch(ProtectDevBranchMergeOnly) error = %v", err) + } +} + +func TestProtectBranch_BothSameBranch_Success(t *testing.T) { + // ProtectDevBranchMergeOnly=true AND DevBranch==ToBranch → single protectSingleBranch call + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "protected_branches"): + _, _ = w.Write([]byte(`{"id":1,"name":"main"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + cfg := &config.AssignmentConfig{ + Startercode: &config.Startercode{ + ToBranch: "main", + DevBranch: "main", // same as ToBranch + ProtectToBranch: true, + ProtectDevBranchMergeOnly: true, + }, + } + project := &gitlabapi.Project{ID: 1, Name: "myrepo"} + err := client.protectBranch(cfg, project, false) + if err != nil { + t.Fatalf("protectBranch(both, same branch) error = %v", err) + } +} + +// ---- protectSingleBranch ---------------------------------------------------- + +func TestProtectSingleBranch_Success(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "protected_branches"): + _, _ = w.Write([]byte(`{"id":1,"name":"main"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + project := &gitlabapi.Project{ID: 1, Name: "myrepo"} + err := client.protectSingleBranch(project, "main", gitlabapi.MaintainerPermissions, gitlabapi.MaintainerPermissions) + if err != nil { + t.Fatalf("protectSingleBranch() error = %v", err) + } +} + +func TestProtectSingleBranch_UnprotectFails_ProtectStillCalled(t *testing.T) { + // Unprotect returns 404 (branch not yet protected) → protectSingleBranch continues + protectCalled := false + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusNotFound) // not protected yet + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "protected_branches"): + protectCalled = true + _, _ = w.Write([]byte(`{"id":1,"name":"main"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + project := &gitlabapi.Project{ID: 1, Name: "myrepo"} + err := client.protectSingleBranch(project, "main", gitlabapi.NoPermissions, gitlabapi.DeveloperPermissions) + if err != nil { + t.Fatalf("protectSingleBranch() error = %v", err) + } + if !protectCalled { + t.Fatal("protectSingleBranch() did not call ProtectRepositoryBranches") + } +} + +func TestProtectSingleBranch_ProtectFails_ReturnsError(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodDelete && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "protected_branches"): + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"403 Forbidden"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + project := &gitlabapi.Project{ID: 1, Name: "myrepo"} + err := client.protectSingleBranch(project, "main", gitlabapi.MaintainerPermissions, gitlabapi.MaintainerPermissions) + if err == nil { + t.Fatal("protectSingleBranch() expected error on 403, got nil") + } +} diff --git a/gitlab/report.go b/gitlab/report.go index c7ab94a..66f383f 100644 --- a/gitlab/report.go +++ b/gitlab/report.go @@ -25,24 +25,24 @@ func (c *Client) Report(assignmentCfg *config.AssignmentConfig, templateFile *st } if err != nil { - panic(err) + panicFunc(err) } if output != nil { os.Remove(*output) //nolint f, err := os.Create(*output) if err != nil { - panic(err) + panicFunc(err) } defer f.Close() //nolint err = tmpl.Execute(f, report) if err != nil { - panic(err) + panicFunc(err) } } else { err = tmpl.Execute(os.Stdout, report) if err != nil { - panic(err) + panicFunc(err) } } @@ -61,24 +61,24 @@ func (c *Client) ReportHTML(assignmentCfg *config.AssignmentConfig, templateFile tmpl, err = htmlTemplate.New("html").Parse(r.HTMLTemplate) } if err != nil { - panic(err) + panicFunc(err) } if output != nil { os.Remove(*output) //nolint f, err := os.Create(*output) if err != nil { - panic(err) + panicFunc(err) } defer f.Close() //nolint err = tmpl.Execute(f, report) if err != nil { - panic(err) + panicFunc(err) } } else { err = tmpl.Execute(os.Stdout, report) if err != nil { - panic(err) + panicFunc(err) } } } @@ -88,13 +88,13 @@ func (c *Client) ReportJSON(assignmentCfg *config.AssignmentConfig, output *stri json, err := json.MarshalIndent(report, "", " ") if err != nil { - panic(err) + panicFunc(err) } if output != nil { err := os.WriteFile(*output, json, 0644) if err != nil { - panic(err) + panicFunc(err) } } else { fmt.Println(string(json)) diff --git a/gitlab/report_contract_test.go b/gitlab/report_contract_test.go new file mode 100644 index 0000000..a041f7a --- /dev/null +++ b/gitlab/report_contract_test.go @@ -0,0 +1,314 @@ +package gitlab + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/obcode/glabs/config" +) + +// makeFullReportHandler returns a handler that mocks all calls needed by report(). +// It serves: getGroupID, ListGroupProjects, and full projectReport for one project. +func makeFullReportHandler(groupID, projectID int64, projectName string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch { + // getGroupIDByFullPath + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups": + json.NewEncoder(w).Encode([]map[string]interface{}{ //nolint + {"id": groupID, "full_path": "mpd/ss26/blatt-01"}, + }) + + // ListGroupProjects - no next page + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/projects") && + strings.Contains(r.URL.Path, "/groups/"): + json.NewEncoder(w).Encode([]map[string]interface{}{ //nolint + {"id": projectID, "name": projectName, + "created_at": "2026-04-01T09:00:00Z", + "last_activity_at": "2026-04-02T10:00:00Z", + "path_with_namespace": "mpd/ss26/blatt-01/" + projectName, + "ssh_url_to_repo": "git@example.com:mpd/ss26/blatt-01/" + projectName + ".git"}, + }) + + // ListBranches + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/repository/branches"): + _, _ = w.Write([]byte(`[{"name":"main"}]`)) + + // ListCommits + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/repository/commits"): + _, _ = w.Write([]byte(`[{"title":"first commit","committer_name":"alice","committed_date":"2026-04-01T10:00:00Z","web_url":"https://gitlab.example.org/c1"}]`)) + + // ListProjectMembers + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/members"): + _, _ = w.Write([]byte(`[{"id":1,"name":"Alice","username":"alice","web_url":"https://gitlab.example.org/alice"}]`)) + + // ListProjectMergeRequests + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/merge_requests"): + _, _ = w.Write([]byte(`[]`)) + + // Registry (docker images) - return empty + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/registry/repositories"): + _, _ = w.Write([]byte(`[]`)) + + default: + w.WriteHeader(http.StatusNotFound) + } + } +} + +// ---- report (internal) ------------------------------------------------------ + +func TestReport_GroupNotFound_ReturnsNil(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + result := client.report(cfg) + if result != nil { + t.Fatalf("report() = %#v, want nil", result) + } +} + +func TestReport_ListGroupProjectsFails_ReturnsNil(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups": + _, _ = w.Write([]byte(`[{"id":1,"full_path":"mpd/ss26/blatt-01"}]`)) + default: + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message":"500 Internal Server Error"}`)) + } + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + result := client.report(cfg) + if result != nil { + t.Fatalf("report() = %#v, want nil after ListGroupProjects failure", result) + } +} + +func TestReport_HappyPath_NoProjects(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups": + _, _ = w.Write([]byte(`[{"id":1,"full_path":"mpd/ss26/blatt-01"}]`)) + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/groups/1/projects"): + _, _ = w.Write([]byte(`[]`)) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + result := client.report(cfg) + if result == nil { + t.Fatal("report() returned nil, want non-nil") + } + if result.Course != "mpd" { + t.Fatalf("Course = %q, want mpd", result.Course) + } + if len(result.Projects) != 0 { + t.Fatalf("Projects = %d, want 0", len(result.Projects)) + } +} + +func TestReport_HappyPath_WithProject(t *testing.T) { + client := newContractClient(t, makeFullReportHandler(1, 10, "mpd-blatt01-alice")) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + result := client.report(cfg) + if result == nil { + t.Fatal("report() returned nil, want non-nil") + } + if len(result.Projects) != 1 { + t.Fatalf("Projects = %d, want 1", len(result.Projects)) + } +} + +func TestReport_HappyPath_WithRelease(t *testing.T) { + client := newContractClient(t, makeFullReportHandler(1, 10, "mpd-blatt01-alice")) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + Release: &config.Release{ + MergeRequest: &config.MergeRequest{SourceBranch: "develop", TargetBranch: "main"}, + DockerImages: []string{"myimage:latest"}, + }, + } + result := client.report(cfg) + if result == nil { + t.Fatal("report() returned nil, want non-nil") + } + if !result.HasReleaseMergeRequest { + t.Fatal("HasReleaseMergeRequest should be true") + } + if !result.HasReleaseDockerImages { + t.Fatal("HasReleaseDockerImages should be true") + } +} + +// ---- Report (text output) --------------------------------------------------- + +func TestReport_TextTemplate_ToStdout(t *testing.T) { + client := newContractClient(t, makeFullReportHandler(1, 11, "mpd-blatt01-alice")) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + // nil template → uses default text template; nil output → uses stdout + client.Report(cfg, nil, nil) +} + +func TestReport_TextTemplate_ToFile(t *testing.T) { + client := newContractClient(t, makeFullReportHandler(1, 12, "mpd-blatt01-alice")) + + outFile := filepath.Join(t.TempDir(), "report.txt") + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + client.Report(cfg, nil, &outFile) + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("reading output file: %v", err) + } + if len(data) == 0 { + t.Fatal("Report() wrote empty file") + } +} + +func TestReport_CustomTemplate_ToFile(t *testing.T) { + // Write a custom template file + tmplContent := `Course: {{.Course}}, Assignment: {{.Assignment}}` + tmplFile := filepath.Join(t.TempDir(), "tmpl.txt") + if err := os.WriteFile(tmplFile, []byte(tmplContent), 0644); err != nil { + t.Fatalf("writing template: %v", err) + } + + client := newContractClient(t, makeFullReportHandler(1, 13, "mpd-blatt01-alice")) + + outFile := filepath.Join(t.TempDir(), "out.txt") + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + client.Report(cfg, &tmplFile, &outFile) + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("reading output file: %v", err) + } + if !strings.Contains(string(data), "mpd") { + t.Fatalf("Report() output missing course name: %q", data) + } +} + +// ---- ReportHTML ------------------------------------------------------------- + +func TestReportHTML_DefaultTemplate_ToStdout(t *testing.T) { + client := newContractClient(t, makeFullReportHandler(1, 14, "mpd-blatt01-alice")) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + client.ReportHTML(cfg, nil, nil) +} + +func TestReportHTML_DefaultTemplate_ToFile(t *testing.T) { + client := newContractClient(t, makeFullReportHandler(1, 15, "mpd-blatt01-alice")) + + outFile := filepath.Join(t.TempDir(), "report.html") + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + client.ReportHTML(cfg, nil, &outFile) + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("reading output file: %v", err) + } + if len(data) == 0 { + t.Fatal("ReportHTML() wrote empty file") + } +} + +// ---- ReportJSON ------------------------------------------------------------- + +func TestReportJSON_ToStdout(t *testing.T) { + client := newContractClient(t, makeFullReportHandler(1, 16, "mpd-blatt01-alice")) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + client.ReportJSON(cfg, nil) +} + +func TestReportJSON_ToFile(t *testing.T) { + client := newContractClient(t, makeFullReportHandler(1, 17, "mpd-blatt01-alice")) + + outFile := filepath.Join(t.TempDir(), "report.json") + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + } + client.ReportJSON(cfg, &outFile) + + data, err := os.ReadFile(outFile) + if err != nil { + t.Fatalf("reading output file: %v", err) + } + var parsed map[string]interface{} + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("ReportJSON() wrote invalid JSON: %v", err) + } + if parsed["course"] != "mpd" { + t.Fatalf("JSON course = %q, want mpd", parsed["course"]) + } +} diff --git a/gitlab/report_helper.go b/gitlab/report_helper.go index 0820bf8..89b09ab 100644 --- a/gitlab/report_helper.go +++ b/gitlab/report_helper.go @@ -80,6 +80,8 @@ func (c *Client) report(assignmentCfg *config.AssignmentConfig) *report.Reports PerPage: 0, }, } + } else { + break } } diff --git a/gitlab/report_helper_contract_test.go b/gitlab/report_helper_contract_test.go new file mode 100644 index 0000000..ea55b4b --- /dev/null +++ b/gitlab/report_helper_contract_test.go @@ -0,0 +1,136 @@ +package gitlab + +import ( + "net/http" + "strings" + "testing" + "time" + + "github.com/obcode/glabs/config" + gitlabapi "gitlab.com/gitlab-org/api/client-go/v2" +) + +func TestProjectReport_AggregatesReleaseAndCommitData(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/7/repository/branches": + _, _ = w.Write([]byte(`[{"name":"main"},{"name":"develop"}]`)) + return + + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/7/repository/commits": + ref := r.URL.Query().Get("ref_name") + if ref == "main" { + _, _ = w.Write([]byte(`[ + {"title":"initial","committer_name":"alice","committed_date":"2026-04-20T10:00:00Z","web_url":"https://gitlab.example.org/c1"} + ]`)) + return + } + if ref == "develop" { + _, _ = w.Write([]byte(`[ + {"title":"latest","committer_name":"bob","committed_date":"2026-04-22T12:00:00Z","web_url":"https://gitlab.example.org/c2"} + ]`)) + return + } + _, _ = w.Write([]byte(`[]`)) + return + + case r.Method == http.MethodGet && + (r.URL.Path == "/api/v4/projects/7/members" || r.URL.Path == "/api/v4/projects/7/members/all"): + _, _ = w.Write([]byte(`[{"id":100,"name":"Alice","username":"alice","web_url":"https://gitlab.example.org/alice"}]`)) + return + + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/7/merge_requests": + if r.URL.Query().Get("state") != "opened" { + t.Fatalf("merge request state query = %q", r.URL.Query().Get("state")) + } + _, _ = w.Write([]byte(`[ + {"iid":11,"source_branch":"develop","target_branch":"main","web_url":"https://gitlab.example.org/mr/11"} + ]`)) + return + + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/7/merge_requests/11/pipelines": + _, _ = w.Write([]byte(`[ + {"id":1,"status":"failed","created_at":"2026-04-22T11:00:00Z"}, + {"id":2,"status":"success","created_at":"2026-04-22T12:30:00Z"} + ]`)) + return + + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v4/projects/7/registry/repositories"): + _, _ = w.Write([]byte(`[ + {"name":"registry/app","location":"registry.example.org/mpd/ss26/repo/app"} + ]`)) + return + } + + w.WriteHeader(http.StatusNotFound) + }) + + createdAt := time.Date(2026, 4, 20, 9, 0, 0, 0, time.UTC) + lastActivity := time.Date(2026, 4, 20, 9, 0, 0, 0, time.UTC) + project := &gitlabapi.Project{ + ID: 7, + Name: "mpd-a1-team1", + CreatedAt: &createdAt, + LastActivityAt: &lastActivity, + OpenIssuesCount: 3, + WebURL: "https://gitlab.example.org/mpd-a1-team1", + } + + assignmentCfg := &config.AssignmentConfig{ + Release: &config.Release{ + MergeRequest: &config.MergeRequest{ + SourceBranch: "develop", + TargetBranch: "main", + HasPipeline: true, + }, + DockerImages: []string{"registry/app", "registry/missing"}, + }, + } + + projectName, report := client.projectReport(assignmentCfg, project) + + if projectName != "mpd-a1-team1" { + t.Fatalf("projectName = %q", projectName) + } + if report == nil { + t.Fatal("report is nil") + } + if report.Commits != 2 { + t.Fatalf("commits = %d, want 2", report.Commits) + } + if report.LastCommit == nil || report.LastCommit.Title != "latest" { + t.Fatalf("last commit = %#v", report.LastCommit) + } + if report.OpenMergeRequestsCount != 1 { + t.Fatalf("open merge requests = %d", report.OpenMergeRequestsCount) + } + if !report.IsActive { + t.Fatal("project should be marked active") + } + if len(report.Members) != 1 { + t.Fatalf("members len = %d, want 1", len(report.Members)) + } + + if report.Release == nil || report.Release.MergeRequest == nil { + t.Fatalf("release merge request = %#v", report.Release) + } + if !report.Release.MergeRequest.Found { + t.Fatal("release merge request should be found") + } + if report.Release.MergeRequest.PipelineStatus != "success" { + t.Fatalf("pipeline status = %q, want success", report.Release.MergeRequest.PipelineStatus) + } + + if report.Release.DockerImages == nil { + t.Fatal("docker images report should be set") + } + if report.Release.DockerImages.Status != "1 of 2 available" { + t.Fatalf("docker images status = %q", report.Release.DockerImages.Status) + } + if len(report.Release.DockerImages.Images) != 1 { + t.Fatalf("docker images len = %d, want 1", len(report.Release.DockerImages.Images)) + } + if report.Release.DockerImages.Images[0].Wanted != "registry/app" { + t.Fatalf("docker image wanted = %q", report.Release.DockerImages.Images[0].Wanted) + } +} diff --git a/gitlab/runtime.go b/gitlab/runtime.go new file mode 100644 index 0000000..60c755b --- /dev/null +++ b/gitlab/runtime.go @@ -0,0 +1,9 @@ +package gitlab + +import "os" + +var exitFunc = os.Exit + +var panicFunc = func(v interface{}) { + panic(v) +} diff --git a/gitlab/runtime_test.go b/gitlab/runtime_test.go new file mode 100644 index 0000000..27af9a4 --- /dev/null +++ b/gitlab/runtime_test.go @@ -0,0 +1,129 @@ +package gitlab + +import ( + "fmt" + "net/http" + "testing" + + "github.com/obcode/glabs/config" + "github.com/spf13/viper" +) + +type exitTriggered struct { + code int +} + +func (e exitTriggered) Error() string { + return fmt.Sprintf("exit called with code %d", e.code) +} + +func withExitCapture(t *testing.T) func() { + t.Helper() + origExit := exitFunc + exitFunc = func(code int) { + panic(exitTriggered{code: code}) + } + return func() { + exitFunc = origExit + } +} + +func withPanicCapture(t *testing.T, fn func(v interface{})) func() { + t.Helper() + origPanic := panicFunc + panicFunc = fn + return func() { + panicFunc = origPanic + } +} + +func assertExitCode(t *testing.T, expected int, fn func()) { + t.Helper() + defer func() { + r := recover() + if r == nil { + t.Fatalf("expected exit with code %d, got no panic", expected) + } + e, ok := r.(exitTriggered) + if !ok { + t.Fatalf("expected exitTriggered panic, got %T", r) + } + if e.code != expected { + t.Fatalf("exit code = %d, want %d", e.code, expected) + } + }() + fn() +} + +func TestGenerate_UsesExitSeamForInvalidPer(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups" { + _, _ = w.Write([]byte(`[{"id":1,"full_path":"mpd/ss26/blatt-01"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + Per: config.PerFailed, + } + + assertExitCode(t, 1, func() { + client.Generate(cfg) + }) +} + +func TestProtectToBranch_UsesExitSeamForInvalidPer(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups" { + _, _ = w.Write([]byte(`[{"id":1,"full_path":"mpd/ss26/blatt-01"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + URL: "https://gitlab.example.org/mpd/ss26/blatt-01", + Per: config.PerFailed, + Startercode: &config.Startercode{ + ToBranch: "main", + }, + } + + assertExitCode(t, 1, func() { + client.ProtectToBranch(cfg) + }) +} + +func TestNewClient_UsesPanicSeamOnInvalidBaseURL(t *testing.T) { + triggered := false + defer withPanicCapture(t, func(v interface{}) { + triggered = true + panic(v) + })() + viper.Reset() + defer viper.Reset() + viper.Set("gitlab.host", "://invalid-base-url") + viper.Set("gitlab.token", "token") + + defer func() { + r := recover() + if r == nil { + t.Fatal("expected panic on invalid GitLab base URL") + } + if !triggered { + t.Fatal("expected panic to pass through panicFunc seam") + } + }() + + _ = NewClient() +} diff --git a/gitlab/setaccess.go b/gitlab/setaccess.go index 3105a34..812fd49 100644 --- a/gitlab/setaccess.go +++ b/gitlab/setaccess.go @@ -2,7 +2,6 @@ package gitlab import ( "fmt" - "os" "time" "github.com/logrusorgru/aurora" @@ -16,7 +15,7 @@ func (c *Client) Setaccess(assignmentCfg *config.AssignmentConfig) { _, err := c.getGroupID(assignmentCfg) if err != nil { fmt.Printf("error: GitLab group for assignment does not exist, please create the group %s\n", assignmentCfg.URL) - os.Exit(1) + exitFunc(1) } switch per := assignmentCfg.Per; per { @@ -26,7 +25,7 @@ func (c *Client) Setaccess(assignmentCfg *config.AssignmentConfig) { c.setaccessPerStudent(assignmentCfg) default: fmt.Printf("it is only possible to set access levels for students oder groups, not for %v", per) - os.Exit(1) + exitFunc(1) } } diff --git a/gitlab/setaccess_contract_test.go b/gitlab/setaccess_contract_test.go new file mode 100644 index 0000000..0432e4d --- /dev/null +++ b/gitlab/setaccess_contract_test.go @@ -0,0 +1,271 @@ +package gitlab + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/obcode/glabs/config" +) + +// ---- Setaccess (top-level) -------------------------------------------------- + +func TestSetaccess_GroupNotFound_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + } + assertExitCode(t, 1, func() { client.Setaccess(cfg) }) +} + +func TestSetaccess_InvalidPer_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups" { + _, _ = w.Write([]byte(`[{"id":1,"full_path":"mpd/ss26/blatt-01"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + Per: config.PerFailed, + } + assertExitCode(t, 1, func() { client.Setaccess(cfg) }) +} + +// ---- setaccessPerStudent ---------------------------------------------------- + +func TestSetaccessPerStudent_NoStudents(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + Students: []*config.Student{}, + AccessLevel: config.Developer, + } + client.setaccessPerStudent(cfg) +} + +func TestSetaccessPerStudent_GetProjectFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + username := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + AccessLevel: config.Developer, + } + client.setaccessPerStudent(cfg) +} + +func TestSetaccessPerStudent_Success_NewMember(t *testing.T) { + pj := `{"id":10,"name":"mpd-blatt01-alice","path_with_namespace":"mpd/ss26/blatt-01/mpd-blatt01-alice","ssh_url_to_repo":"git@example.com:p.git"}` + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + // GetProject + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "mpd-blatt01-alice"): + _, _ = w.Write([]byte(pj)) + // ListUsers + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/users": + _ = json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 5, "username": "alice"}, + }) + // GetInheritedProjectMember + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/10/members/all/5": + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + // AddProjectMember + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/10/members": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 5, "username": "alice", "access_level": 30, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + username := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + AccessLevel: config.Developer, + } + client.setaccessPerStudent(cfg) +} + +// ---- setaccessPerGroup ------------------------------------------------------ + +func TestSetaccessPerGroup_NoGroups(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + Groups: []*config.Group{}, + AccessLevel: config.Developer, + } + client.setaccessPerGroup(cfg) +} + +func TestSetaccessPerGroup_Success(t *testing.T) { + pj := `{"id":11,"name":"mpd-blatt01-team1","path_with_namespace":"mpd/ss26/blatt-01/mpd-blatt01-team1","ssh_url_to_repo":"git@example.com:p.git"}` + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "mpd-blatt01-team1"): + _, _ = w.Write([]byte(pj)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/users": + _ = json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 6, "username": "alice"}, + }) + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/11/members/all/6": + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/11/members": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 6, "username": "alice", "access_level": 30, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + alice := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + UseCoursenameAsPrefix: true, + Groups: []*config.Group{ + {Name: "team1", Members: []*config.Student{{Username: &alice, Raw: "alice"}}}, + }, + AccessLevel: config.Developer, + } + client.setaccessPerGroup(cfg) +} + +// ---- inviteByEmail ---------------------------------------------------------- + +func TestInviteByEmail_Success(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/10/invitations" { + _ = json.NewEncoder(w).Encode(map[string]string{"status": "success"}) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{AccessLevel: config.Developer} + info, err := client.inviteByEmail(cfg, 10, "newuser@example.com") + if err != nil { + t.Fatalf("inviteByEmail() error = %v", err) + } + if info == "" { + t.Fatal("inviteByEmail() returned empty info") + } +} + +func TestInviteByEmail_APIError(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/10/invitations" { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"403 Forbidden"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{AccessLevel: config.Developer} + _, err := client.inviteByEmail(cfg, 10, "user@example.com") + if err == nil { + t.Fatal("inviteByEmail() expected error on 403, got nil") + } +} + +func TestInviteByEmail_StatusNotSuccess(t *testing.T) { + email := "user@example.com" + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/10/invitations" { + resp := fmt.Sprintf(`{"status":"error","message":{%q:"User already exists"}}`, email) + _, _ = w.Write([]byte(resp)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{AccessLevel: config.Developer} + _, err := client.inviteByEmail(cfg, 10, email) + if err == nil { + t.Fatal("inviteByEmail() expected error when status != success, got nil") + } +} + +// ---- setaccess (inviteByEmail path) ----------------------------------------- + +func TestSetaccessPerStudent_UserNotFound_InviteByEmail(t *testing.T) { + // When getUserID fails but student has email → try invite + pj := `{"id":12,"name":"mpd-blatt01-bob","path_with_namespace":"mpd/ss26/blatt-01/mpd-blatt01-bob","ssh_url_to_repo":"git@example.com:p.git"}` + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "mpd-blatt01-bob"): + _, _ = w.Write([]byte(pj)) + // ListUsers returns empty (user not found) + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/users": + _ = json.NewEncoder(w).Encode([]map[string]interface{}{}) + // Invite by email succeeds + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/12/invitations": + _ = json.NewEncoder(w).Encode(map[string]string{"status": "success"}) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + email := "bob@example.com" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Email: &email, Raw: "bob@example.com"}}, + AccessLevel: config.Developer, + } + client.setaccessPerStudent(cfg) +} diff --git a/gitlab/update.go b/gitlab/update.go index d84294d..8d6d610 100644 --- a/gitlab/update.go +++ b/gitlab/update.go @@ -2,7 +2,6 @@ package gitlab import ( "fmt" - "os" "time" "github.com/logrusorgru/aurora" @@ -17,7 +16,7 @@ func (c *Client) Update(assignmentCfg *config.AssignmentConfig) { _, err := c.getGroupID(assignmentCfg) if err != nil { fmt.Printf("error: GitLab group for assignment does not exist, please create the group %s\n", assignmentCfg.URL) - os.Exit(1) + exitFunc(1) } var starterrepo *git.Starterrepo @@ -27,7 +26,7 @@ func (c *Client) Update(assignmentCfg *config.AssignmentConfig) { if err != nil { fmt.Println(err) - os.Exit(1) + exitFunc(1) } } @@ -38,7 +37,7 @@ func (c *Client) Update(assignmentCfg *config.AssignmentConfig) { c.updatePerStudent(assignmentCfg, starterrepo) default: fmt.Printf("it is only possible to update for students oder groups, not for %v", per) - os.Exit(1) + exitFunc(1) } } diff --git a/gitlab/update_contract_test.go b/gitlab/update_contract_test.go new file mode 100644 index 0000000..92705f8 --- /dev/null +++ b/gitlab/update_contract_test.go @@ -0,0 +1,169 @@ +package gitlab + +import ( + "net/http" + "strings" + "testing" + + "github.com/obcode/glabs/config" +) + +// ---- Update (top-level) ----------------------------------------------------- + +func TestUpdate_GroupNotFound_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + } + assertExitCode(t, 1, func() { client.Update(cfg) }) +} + +func TestUpdate_InvalidPer_Exits(t *testing.T) { + defer withExitCapture(t)() + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/groups" { + _, _ = w.Write([]byte(`[{"id":1,"full_path":"mpd/ss26/blatt-01"}]`)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Path: "mpd/ss26/blatt-01", + Per: config.PerFailed, + } + assertExitCode(t, 1, func() { client.Update(cfg) }) +} + +// ---- updatePerStudent ------------------------------------------------------- + +func TestUpdatePerStudent_NoStudents(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + Students: []*config.Student{}, + } + client.updatePerStudent(cfg, nil) +} + +func TestUpdatePerStudent_GetProjectFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + username := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + } + // GetProject fails → prints error, returns + client.updatePerStudent(cfg, nil) +} + +func TestUpdatePerStudent_NoStartercode_Success(t *testing.T) { + // No starterrepo → update function just starts/stops spinner, no actual API calls + pj := `{"id":20,"name":"mpd-blatt01-alice","path_with_namespace":"mpd/ss26/blatt-01/mpd-blatt01-alice","ssh_url_to_repo":"git@example.com:p.git"}` + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "mpd-blatt01-alice") { + _, _ = w.Write([]byte(pj)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + username := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerStudent, + UseCoursenameAsPrefix: true, + Students: []*config.Student{{Username: &username, Raw: "alice"}}, + } + // starterrepo=nil → update() just logs, no push + client.updatePerStudent(cfg, nil) +} + +// ---- updatePerGroup --------------------------------------------------------- + +func TestUpdatePerGroup_NoGroups(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + Groups: []*config.Group{}, + } + client.updatePerGroup(cfg, nil) +} + +func TestUpdatePerGroup_GetProjectFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"404 Not Found"}`)) + }) + + alice := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + UseCoursenameAsPrefix: true, + Groups: []*config.Group{ + {Name: "team1", Members: []*config.Student{{Username: &alice, Raw: "alice"}}}, + }, + } + client.updatePerGroup(cfg, nil) +} + +func TestUpdatePerGroup_NoStartercode_Success(t *testing.T) { + pj := `{"id":21,"name":"mpd-blatt01-team1","path_with_namespace":"mpd/ss26/blatt-01/mpd-blatt01-team1","ssh_url_to_repo":"git@example.com:p.git"}` + + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "mpd-blatt01-team1") { + _, _ = w.Write([]byte(pj)) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + alice := "alice" + cfg := &config.AssignmentConfig{ + Course: "mpd", + Name: "blatt01", + Path: "mpd/ss26/blatt-01", + Per: config.PerGroup, + UseCoursenameAsPrefix: true, + Groups: []*config.Group{ + {Name: "team1", Members: []*config.Student{{Username: &alice, Raw: "alice"}}}, + }, + } + client.updatePerGroup(cfg, nil) +} diff --git a/gitlab/users_contract_test.go b/gitlab/users_contract_test.go new file mode 100644 index 0000000..fe89bfd --- /dev/null +++ b/gitlab/users_contract_test.go @@ -0,0 +1,270 @@ +package gitlab + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/obcode/glabs/config" +) + +// ---- getUser ---------------------------------------------------------------- + +func TestGetUser_ByID_Found(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/users/42" { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 42, "name": "Alice", "username": "alice", + }) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + id := 42 + user, err := client.getUser(&config.Student{Id: &id}) + if err != nil { + t.Fatalf("getUser(byID) error = %v", err) + } + if user == nil || user.ID != 42 { + t.Fatalf("user = %#v, want id 42", user) + } +} + +func TestGetUser_ByUsername_Found(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/users" { + _ = json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 1, "name": "Alice", "username": "alice"}, + }) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + username := "alice" + user, err := client.getUser(&config.Student{Username: &username}) + if err != nil { + t.Fatalf("getUser(byUsername) error = %v", err) + } + if user == nil || user.ID != 1 { + t.Fatalf("user = %#v, want id 1", user) + } +} + +func TestGetUser_ByEmail_Found(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/users" { + _ = json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 2, "name": "Bob", "username": "bob", "email": "bob@example.com"}, + }) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + email := "bob@example.com" + user, err := client.getUser(&config.Student{Email: &email}) + if err != nil { + t.Fatalf("getUser(byEmail) error = %v", err) + } + if user == nil || user.ID != 2 { + t.Fatalf("user = %#v, want id 2", user) + } +} + +func TestGetUser_NotFound(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/users" { + _ = json.NewEncoder(w).Encode([]map[string]interface{}{}) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + username := "ghost" + user, err := client.getUser(&config.Student{Username: &username}) + if err == nil { + t.Fatal("getUser() expected error for not found, got nil") + } + if user != nil { + t.Fatalf("user = %#v, want nil", user) + } +} + +func TestGetUser_MultipleFound(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/users" { + _ = json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 1, "username": "alice"}, + {"id": 2, "username": "alice2"}, + }) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + username := "alice" + user, err := client.getUser(&config.Student{Username: &username}) + if err == nil { + t.Fatal("getUser() expected error for multiple users, got nil") + } + if user != nil { + t.Fatalf("user = %#v, want nil", user) + } +} + +// ---- getUserID -------------------------------------------------------------- + +func TestGetUserID_Success(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/users" { + _ = json.NewEncoder(w).Encode([]map[string]interface{}{ + {"id": 5, "username": "alice"}, + }) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + username := "alice" + id, err := client.getUserID(&config.Student{Username: &username}) + if err != nil { + t.Fatalf("getUserID() error = %v", err) + } + if id != 5 { + t.Fatalf("getUserID() = %d, want 5", id) + } +} + +func TestGetUserID_Error(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/users" { + _ = json.NewEncoder(w).Encode([]map[string]interface{}{}) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + username := "ghost" + _, err := client.getUserID(&config.Student{Username: &username}) + if err == nil { + t.Fatal("getUserID() expected error, got nil") + } +} + +// ---- addMember -------------------------------------------------------------- + +func TestAddMember_NewMember_Success(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/10/members/all/5": + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "404 Not Found"}) + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/10/members": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 5, "username": "alice", "access_level": 30, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + cfg := &config.AssignmentConfig{AccessLevel: config.Developer} + info, err := client.addMember(cfg, 10, 5) + if err != nil { + t.Fatalf("addMember() error = %v", err) + } + if info == "" { + t.Fatal("addMember() returned empty info") + } +} + +func TestAddMember_AlreadyOwner(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/10/members/all/5" { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 5, "username": "alice", "access_level": 50, + }) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{AccessLevel: config.Developer} + info, err := client.addMember(cfg, 10, 5) + if err != nil { + t.Fatalf("addMember() error = %v", err) + } + if info != "already owner" { + t.Fatalf("addMember() = %q, want \"already owner\"", info) + } +} + +func TestAddMember_AlreadyMember_SameLevel(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/10/members/all/5" { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 5, "username": "alice", "access_level": 30, + }) + return + } + w.WriteHeader(http.StatusNotFound) + }) + + cfg := &config.AssignmentConfig{AccessLevel: config.Developer} + info, err := client.addMember(cfg, 10, 5) + if err != nil { + t.Fatalf("addMember() error = %v", err) + } + if info == "" { + t.Fatal("addMember() returned empty info for already-member case") + } +} + +func TestAddMember_AlreadyMember_DifferentLevel_Updated(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/10/members/all/5": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 5, "username": "alice", "access_level": 20, // reporter + }) + case r.Method == http.MethodPut && r.URL.Path == "/api/v4/projects/10/members/5": + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 5, "username": "alice", "access_level": 30, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + cfg := &config.AssignmentConfig{AccessLevel: config.Developer} + info, err := client.addMember(cfg, 10, 5) + if err != nil { + t.Fatalf("addMember() error = %v", err) + } + if info == "" { + t.Fatal("addMember() returned empty info for level-change case") + } +} + +func TestAddMember_AddFails(t *testing.T) { + client := newContractClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v4/projects/10/members/all/5": + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "404 Not Found"}) + case r.Method == http.MethodPost && r.URL.Path == "/api/v4/projects/10/members": + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{"message": "403 Forbidden"}) + default: + w.WriteHeader(http.StatusNotFound) + } + }) + + cfg := &config.AssignmentConfig{AccessLevel: config.Developer} + _, err := client.addMember(cfg, 10, 5) + if err == nil { + t.Fatal("addMember() expected error on forbidden, got nil") + } +} diff --git a/go.mod b/go.mod index 5843ed0..d483cf0 100644 --- a/go.mod +++ b/go.mod @@ -18,45 +18,92 @@ require ( ) require ( - dario.cat/mergo v1.0.0 // indirect + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/fatih/color v1.16.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.8.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-querystring v1.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/testcontainers/testcontainers-go v0.42.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.45.0 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.49.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.14.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 98de615..6da9da0 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= @@ -9,20 +13,44 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= @@ -37,15 +65,25 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E= github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA= @@ -64,6 +102,8 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -75,6 +115,10 @@ github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczG github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= github.com/logrusorgru/aurora/v4 v4.0.0 h1:sRjfPpun/63iADiSvGGjgA1cAYegEWMPCJdUpJYn9JA= github.com/logrusorgru/aurora/v4 v4.0.0/go.mod h1:lP0iIa2nrnT/qoFXcOZSrZQpJ1o6n2CUf/hyHi2Q4ZQ= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -83,8 +127,30 @@ github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= @@ -93,6 +159,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -104,7 +172,11 @@ github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDc github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= @@ -127,31 +199,56 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= github.com/theckman/yacspin v0.13.12 h1:CdZ57+n0U6JMuh2xqjnjRq5Haj6v1ner2djtLQRzJr4= github.com/theckman/yacspin v0.13.12/go.mod h1:Rd2+oG2LmQi5f3zC3yeZAOl245z8QOvrH4OPOJNZxLg= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= gitlab.com/gitlab-org/api/client-go/v2 v2.20.1 h1:srcxre2lb0+SyYE1p/gtp7hOonFYWzdDfwwMOxnI1Eo= gitlab.com/gitlab-org/api/client-go/v2 v2.20.1/go.mod h1:HpSOwHPuHwHjbpPCsdP5z34/S2LI3GFFj+p4twZOmwg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= @@ -165,6 +262,7 @@ golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=