Files
kandev/apps/backend/internal/gitlab/mock_controller.go
T

418 lines
12 KiB
Go

package gitlab
import (
"errors"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/common/logger"
)
const mockWorkspaceTokenPrefix = "e2e-gitlab:"
// MockController exposes HTTP endpoints to seed the in-memory MockClient.
// Activated only when KANDEV_MOCK_GITLAB=true.
type MockController struct {
mock *MockClient
service *Service
logger *logger.Logger
}
// NewMockController creates a new MockController.
func NewMockController(mock *MockClient, svc *Service, log *logger.Logger) *MockController {
return &MockController{mock: mock, service: svc, logger: log}
}
// RegisterRoutes wires the mock control endpoints under /api/v1/gitlab/mock.
func (c *MockController) RegisterRoutes(router *gin.Engine) {
api := router.Group("/api/v1/gitlab/mock")
api.PUT("/user", c.setUser)
api.POST("/mrs", c.seedMRs)
api.POST("/issues", c.seedIssues)
api.POST("/pipelines", c.seedPipelines)
api.POST("/pipeline-jobs", c.seedPipelineJobs)
api.POST("/discussions", c.seedDiscussions)
api.POST("/approvals", c.seedApprovals)
api.POST("/branches", c.seedBranches)
api.POST("/members", c.seedMembers)
api.POST("/files", c.seedFiles)
api.POST("/repo-files", c.seedRepoFiles)
api.POST("/commits", c.seedCommits)
api.DELETE("/reset", c.reset)
// The agentctl GitLab creator uses the upstream REST v4 shape. Keeping
// this route in the mock controller lets Playwright exercise that real
// code path without an external GitLab instance.
router.Any("/api/v4/projects/*path", c.gitLabAPI)
}
type mockCreateMRRequest struct {
SourceBranch string `json:"source_branch"`
TargetBranch string `json:"target_branch"`
Title string `json:"title"`
Description string `json:"description"`
}
func (c *MockController) gitLabAPI(ctx *gin.Context) {
workspaceID := strings.TrimPrefix(ctx.GetHeader("PRIVATE-TOKEN"), mockWorkspaceTokenPrefix)
if workspaceID == "" || workspaceID == ctx.GetHeader("PRIVATE-TOKEN") {
ctx.JSON(http.StatusUnauthorized, gin.H{"message": "invalid mock GitLab token"})
return
}
client, err := c.service.ClientForWorkspace(ctx.Request.Context(), workspaceID)
if err != nil {
ctx.JSON(http.StatusUnauthorized, gin.H{"message": "unknown mock GitLab workspace"})
return
}
mock, ok := client.(*MockClient)
if !ok {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"message": "mock GitLab client unavailable"})
return
}
rawPath := strings.TrimPrefix(ctx.Param("path"), "/")
decodedPath, decodeErr := url.PathUnescape(rawPath)
if decodeErr != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"message": "invalid project path"})
return
}
const mergeRequestsSuffix = "/merge_requests"
if !strings.HasSuffix(decodedPath, mergeRequestsSuffix) {
if ctx.Request.Method == http.MethodGet {
ctx.JSON(http.StatusOK, gin.H{"default_branch": "main"})
return
}
ctx.JSON(http.StatusNotFound, gin.H{"message": "mock GitLab endpoint not found"})
return
}
projectPath := strings.TrimSuffix(decodedPath, mergeRequestsSuffix)
if strings.TrimSpace(projectPath) == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"message": "project path required"})
return
}
switch ctx.Request.Method {
case http.MethodGet:
c.listCreatedMRs(ctx, mock, projectPath)
case http.MethodPost:
c.createMR(ctx, mock, projectPath)
default:
ctx.Status(http.StatusMethodNotAllowed)
}
}
func (c *MockController) listCreatedMRs(ctx *gin.Context, mock *MockClient, projectPath string) {
mrs, _ := mock.SearchMRs(ctx.Request.Context(), "", "")
sourceBranch := ctx.Query("source_branch")
targetBranch := ctx.Query("target_branch")
rows := make([]gin.H, 0)
for _, mr := range mrs {
if mr.ProjectPath != projectPath || (sourceBranch != "" && mr.HeadBranch != sourceBranch) ||
(targetBranch != "" && mr.BaseBranch != targetBranch) {
continue
}
rows = append(rows, gin.H{
"web_url": mr.WebURL, "source_branch": mr.HeadBranch, "target_branch": mr.BaseBranch,
})
}
ctx.JSON(http.StatusOK, rows)
}
func (c *MockController) createMR(ctx *gin.Context, mock *MockClient, projectPath string) {
var req mockCreateMRRequest
if err := ctx.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.SourceBranch) == "" ||
strings.TrimSpace(req.TargetBranch) == "" || strings.TrimSpace(req.Title) == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"message": "source_branch, target_branch, and title required"})
return
}
mr, err := mock.CreateMR(ctx.Request.Context(), projectPath, req.SourceBranch, req.TargetBranch,
req.Title, req.Description, strings.HasPrefix(strings.ToLower(strings.TrimSpace(req.Title)), "draft:"))
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
return
}
ctx.JSON(http.StatusCreated, gin.H{
"id": mr.ID, "iid": mr.IID, "web_url": mr.WebURL,
"source_branch": mr.HeadBranch, "target_branch": mr.BaseBranch,
})
}
type mockMRRequest struct {
Project string `json:"project"`
MRs []MR `json:"mrs"`
}
type mockIssueRequest struct {
Project string `json:"project"`
Issues []Issue `json:"issues"`
}
type mockPipelineRequest struct {
Project string `json:"project"`
Pipelines []Pipeline `json:"pipelines"`
}
type mockPipelineJobsRequest struct {
PipelineID int64 `json:"pipeline_id"`
Jobs []PipelineJob `json:"jobs"`
}
type mockDiscussionRequest struct {
Project string `json:"project"`
IID int `json:"iid"`
Discussions []MRDiscussion `json:"discussions"`
}
type mockApprovalRequest struct {
Project string `json:"project"`
IID int `json:"iid"`
Approvals []MRApproval `json:"approvals"`
Required int `json:"required"`
}
type mockBranchesRequest struct {
Project string `json:"project"`
Branches []RepoBranch `json:"branches"`
}
type mockMembersRequest struct {
Project string `json:"project"`
Members []ProjectMember `json:"members"`
}
type mockFilesRequest struct {
Project string `json:"project"`
IID int `json:"iid"`
Files []MRFile `json:"files"`
}
// mockRepoFilesRequest seeds repository content (as opposed to mockFilesRequest,
// which seeds files changed in a merge request). Ref is required — repo file
// content is keyed by branch, unlike MR files which are keyed by IID.
type mockRepoFilesRequest struct {
Project string `json:"project"`
Ref string `json:"ref"`
Files []mockRepoFileItem `json:"files"`
}
type mockRepoFileItem struct {
Path string `json:"path"`
Content string `json:"content"`
}
type mockCommitsRequest struct {
Project string `json:"project"`
IID int `json:"iid"`
Commits []MRCommitInfo `json:"commits"`
}
func (c *MockController) mockClient(ctx *gin.Context) (*MockClient, bool) {
workspaceID := strings.TrimSpace(ctx.Query("workspace_id"))
if workspaceID == "" {
return c.mock, true
}
client, err := c.service.ClientForWorkspace(ctx.Request.Context(), workspaceID)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, ErrNotConfigured) {
status = http.StatusServiceUnavailable
}
ctx.JSON(status, gin.H{"error": "mock GitLab workspace client unavailable"})
return nil, false
}
mock, ok := client.(*MockClient)
if !ok {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "mock GitLab client unavailable"})
return nil, false
}
return mock, true
}
func (c *MockController) setUser(ctx *gin.Context) {
var req struct {
Username string `json:"username"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
mock, ok := c.mockClient(ctx)
if !ok {
return
}
mock.SetUser(req.Username)
ctx.JSON(http.StatusOK, gin.H{"username": req.Username})
}
func (c *MockController) seedMRs(ctx *gin.Context) {
var req mockMRRequest
if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "project and mrs required"})
return
}
mock, ok := c.mockClient(ctx)
if !ok {
return
}
for i := range req.MRs {
mock.SeedMR(req.Project, &req.MRs[i])
}
ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.MRs)})
}
func (c *MockController) seedIssues(ctx *gin.Context) {
var req mockIssueRequest
if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "project and issues required"})
return
}
mock, ok := c.mockClient(ctx)
if !ok {
return
}
for i := range req.Issues {
mock.SeedIssue(req.Project, &req.Issues[i])
}
ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Issues)})
}
func (c *MockController) seedPipelines(ctx *gin.Context) {
var req mockPipelineRequest
if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "project and pipelines required"})
return
}
mock, ok := c.mockClient(ctx)
if !ok {
return
}
mock.SeedPipelines(req.Project, req.Pipelines)
ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Pipelines)})
}
func (c *MockController) seedPipelineJobs(ctx *gin.Context) {
var req mockPipelineJobsRequest
if err := ctx.ShouldBindJSON(&req); err != nil || req.PipelineID == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "pipeline_id and jobs required"})
return
}
mock, ok := c.mockClient(ctx)
if !ok {
return
}
mock.SeedPipelineJobs(req.PipelineID, req.Jobs)
ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Jobs)})
}
func (c *MockController) seedDiscussions(ctx *gin.Context) {
var req mockDiscussionRequest
if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" || req.IID <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "project, iid, discussions required"})
return
}
mock, ok := c.mockClient(ctx)
if !ok {
return
}
mock.SeedDiscussions(req.Project, req.IID, req.Discussions)
ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Discussions)})
}
func (c *MockController) seedApprovals(ctx *gin.Context) {
var req mockApprovalRequest
if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" || req.IID <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "project and iid required"})
return
}
mock, ok := c.mockClient(ctx)
if !ok {
return
}
mock.SeedApprovals(req.Project, req.IID, req.Approvals, req.Required)
ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Approvals), "required": req.Required})
}
func (c *MockController) seedBranches(ctx *gin.Context) {
var req mockBranchesRequest
if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "project and branches required"})
return
}
mock, ok := c.mockClient(ctx)
if !ok {
return
}
mock.SeedBranches(req.Project, req.Branches)
ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Branches)})
}
func (c *MockController) seedMembers(ctx *gin.Context) {
var req mockMembersRequest
if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "project and members required"})
return
}
mock, ok := c.mockClient(ctx)
if !ok {
return
}
mock.SeedProjectMembers(req.Project, req.Members)
ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Members)})
}
func (c *MockController) seedFiles(ctx *gin.Context) {
var req mockFilesRequest
if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" || req.IID <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "project, iid, and files required"})
return
}
mock, ok := c.mockClient(ctx)
if !ok {
return
}
mock.SeedFiles(req.Project, req.IID, req.Files)
ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Files)})
}
func (c *MockController) seedRepoFiles(ctx *gin.Context) {
var req mockRepoFilesRequest
if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" || req.Ref == "" || len(req.Files) == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "project, ref, and files required"})
return
}
mock, ok := c.mockClient(ctx)
if !ok {
return
}
for _, f := range req.Files {
mock.SeedRepoFile(req.Project, req.Ref, f.Path, []byte(f.Content))
}
ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Files)})
}
func (c *MockController) seedCommits(ctx *gin.Context) {
var req mockCommitsRequest
if err := ctx.ShouldBindJSON(&req); err != nil || req.Project == "" || req.IID <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "project, iid, and commits required"})
return
}
mock, ok := c.mockClient(ctx)
if !ok {
return
}
mock.SeedCommits(req.Project, req.IID, req.Commits)
ctx.JSON(http.StatusOK, gin.H{"seeded": len(req.Commits)})
}
func (c *MockController) reset(ctx *gin.Context) {
mock, ok := c.mockClient(ctx)
if !ok {
return
}
mock.Reset()
ctx.JSON(http.StatusOK, gin.H{"reset": true})
}