418 lines
12 KiB
Go
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})
|
|
}
|