ae31fa85a9
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
353 lines
12 KiB
Go
353 lines
12 KiB
Go
package gitlab
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
const responseErrorKey = "error"
|
|
|
|
// Controller handles HTTP endpoints for GitLab integration.
|
|
type Controller struct {
|
|
service *Service
|
|
logger *logger.Logger
|
|
}
|
|
|
|
// NewController creates a new GitLab controller.
|
|
func NewController(svc *Service, log *logger.Logger) *Controller {
|
|
return &Controller{service: svc, logger: log}
|
|
}
|
|
|
|
// RegisterHTTPRoutes registers the v1 HTTP surface.
|
|
func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
|
|
api := router.Group("/api/v1/gitlab")
|
|
c.registerConfigRoutes(api)
|
|
c.RegisterTaskMRHTTPRoutes(api)
|
|
c.RegisterMRAutomationHTTPRoutes(api)
|
|
c.registerMemberSubscriptionRoutes(api)
|
|
api.GET("/status", c.httpGetStatus)
|
|
|
|
api.GET("/mrs/feedback", c.httpGetMRFeedback)
|
|
api.POST("/mrs/discussions/notes", c.httpCreateDiscussionNote)
|
|
api.POST("/mrs/discussions/resolve", c.httpResolveDiscussion)
|
|
|
|
api.GET("/workspaces/:workspaceID/task-mrs", c.httpListWorkspaceTaskMRs)
|
|
api.GET("/tasks/:taskID/mrs", c.httpListTaskMRs)
|
|
|
|
api.GET("/user/mrs", c.httpSearchUserMRs)
|
|
api.GET("/user/issues", c.httpSearchUserIssues)
|
|
|
|
c.RegisterWatchHTTPRoutes(router)
|
|
}
|
|
|
|
// RegisterRoutes is the package-level entrypoint mirroring github.RegisterRoutes.
|
|
func RegisterRoutes(router *gin.Engine, svc *Service, log *logger.Logger) {
|
|
NewController(svc, log).RegisterHTTPRoutes(router)
|
|
}
|
|
|
|
func (c *Controller) httpGetStatus(ctx *gin.Context) {
|
|
if c.workspaceID(ctx) == "" {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "workspace_id query parameter required"})
|
|
return
|
|
}
|
|
status, err := c.service.GetStatusForWorkspace(ctx.Request.Context(), c.workspaceID(ctx))
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "GitLab status unavailable"})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, status)
|
|
}
|
|
|
|
func (c *Controller) httpConfigureToken(ctx *gin.Context) {
|
|
var req ConfigureTokenRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "invalid payload: token is required"})
|
|
return
|
|
}
|
|
if err := c.service.ConfigureToken(ctx.Request.Context(), req.Token); err != nil {
|
|
// errors.Is against the package-level sentinel — durable across
|
|
// future rewording / wrapping of the ConfigureToken error message.
|
|
if errors.Is(err, ErrInvalidToken) {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"configured": true})
|
|
}
|
|
|
|
func (c *Controller) httpClearToken(ctx *gin.Context) {
|
|
if err := c.service.ClearToken(ctx.Request.Context()); err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"cleared": true})
|
|
}
|
|
|
|
func (c *Controller) httpConfigureHost(ctx *gin.Context) {
|
|
var req ConfigureHostRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "invalid payload: host is required"})
|
|
return
|
|
}
|
|
if err := c.service.ConfigureHost(ctx.Request.Context(), req.Host); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"configured": true, "host": c.service.Host()})
|
|
}
|
|
|
|
func (c *Controller) httpGetMRFeedback(ctx *gin.Context) {
|
|
projectPath, iid, err := parseProjectAndIID(ctx)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
var feedback *MRFeedback
|
|
err = c.runWorkspaceClientAction(ctx, func(client Client) error {
|
|
var actionErr error
|
|
feedback, actionErr = client.GetMRFeedback(ctx.Request.Context(), projectPath, iid)
|
|
return actionErr
|
|
})
|
|
if err != nil {
|
|
writeWorkspaceClientActionError(ctx, err, "merge request feedback")
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, feedback)
|
|
}
|
|
|
|
func (c *Controller) httpCreateDiscussionNote(ctx *gin.Context) {
|
|
var req struct {
|
|
Project string `json:"project" binding:"required"`
|
|
IID int `json:"iid" binding:"required"`
|
|
DiscussionID string `json:"discussion_id" binding:"required"`
|
|
Body string `json:"body" binding:"required"`
|
|
}
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "invalid payload"})
|
|
return
|
|
}
|
|
var note *MRNote
|
|
err := c.runWorkspaceClientAction(ctx, func(client Client) error {
|
|
var actionErr error
|
|
note, actionErr = client.CreateMRDiscussionNote(ctx.Request.Context(), req.Project, req.IID, req.DiscussionID, req.Body)
|
|
return actionErr
|
|
})
|
|
if err != nil {
|
|
writeWorkspaceClientActionError(ctx, err, "discussion reply")
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, note)
|
|
}
|
|
|
|
func (c *Controller) httpResolveDiscussion(ctx *gin.Context) {
|
|
var req struct {
|
|
Project string `json:"project" binding:"required"`
|
|
IID int `json:"iid" binding:"required"`
|
|
DiscussionID string `json:"discussion_id" binding:"required"`
|
|
}
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "invalid payload"})
|
|
return
|
|
}
|
|
err := c.runWorkspaceClientAction(ctx, func(client Client) error {
|
|
return client.ResolveMRDiscussion(ctx.Request.Context(), req.Project, req.IID, req.DiscussionID)
|
|
})
|
|
if err != nil {
|
|
writeWorkspaceClientActionError(ctx, err, "discussion resolve")
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"resolved": true})
|
|
}
|
|
|
|
// parseProjectAndIID reads ?project=<path>&iid=<n> query params and validates
|
|
// them. project is the namespace/path slug (URL-decoded by Gin); iid must be
|
|
// a positive integer.
|
|
func parseProjectAndIID(ctx *gin.Context) (string, int, error) {
|
|
project := ctx.Query("project")
|
|
if project == "" {
|
|
return "", 0, errors.New("project query param required")
|
|
}
|
|
iidStr := ctx.Query("iid")
|
|
if iidStr == "" {
|
|
return "", 0, errors.New("iid query param required")
|
|
}
|
|
iid, err := strconv.Atoi(iidStr)
|
|
if err != nil || iid <= 0 {
|
|
return "", 0, errors.New("iid must be a positive integer")
|
|
}
|
|
return project, iid, nil
|
|
}
|
|
|
|
// SyncTaskMRRequest is the JSON body for POST /tasks/:taskID/mrs/sync.
|
|
// project_path is "namespace/path"; iid is the MR's per-project sequential id;
|
|
// repository_id is the kandev repository UUID (empty for single-repo tasks).
|
|
type SyncTaskMRRequest struct {
|
|
ProjectPath string `json:"project_path" binding:"required"`
|
|
IID int `json:"iid" binding:"required"`
|
|
RepositoryID string `json:"repository_id"`
|
|
}
|
|
|
|
func (c *Controller) httpListWorkspaceTaskMRs(ctx *gin.Context) {
|
|
wsID := ctx.Param("workspaceID")
|
|
if wsID == "" {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "workspaceID required"})
|
|
return
|
|
}
|
|
taskMRs, err := c.service.ListTaskMRsByWorkspace(ctx.Request.Context(), wsID)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "GitLab task merge requests unavailable"})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, TaskMRsResponse{TaskMRs: taskMRs})
|
|
}
|
|
|
|
func (c *Controller) httpListTaskMRs(ctx *gin.Context) {
|
|
taskID := ctx.Param("taskID")
|
|
if taskID == "" {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "taskID required"})
|
|
return
|
|
}
|
|
mrs, err := c.service.ListTaskMRsByTask(ctx.Request.Context(), taskID)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: "GitLab task merge requests unavailable"})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"task_mrs": mrs})
|
|
}
|
|
|
|
func (c *Controller) httpSyncTaskMR(ctx *gin.Context) {
|
|
taskID := ctx.Param("taskID")
|
|
if taskID == "" {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "taskID required"})
|
|
return
|
|
}
|
|
var req SyncTaskMRRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "invalid payload: project_path and iid required"})
|
|
return
|
|
}
|
|
row, err := c.service.SyncTaskMR(ctx.Request.Context(), taskID, req.RepositoryID, req.ProjectPath, req.IID)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, row)
|
|
}
|
|
|
|
// httpSearchUserMRs surfaces the configured user's MR queue. filter is one
|
|
// of "assigned_to_me", "created_by_me", "review_requested" (the /gitlab
|
|
// page's tab values), or a raw GitLab API filter in `key=value` form;
|
|
// custom_query passes through verbatim for power users and disables tab
|
|
// translation entirely.
|
|
func (c *Controller) httpSearchUserMRs(ctx *gin.Context) {
|
|
page, perPage := paginationFromQuery(ctx)
|
|
filter := ctx.Query("filter")
|
|
customQuery := ctx.Query("custom_query")
|
|
if customQuery == "" {
|
|
translated, err := c.translateMRFilter(ctx, filter)
|
|
if err != nil {
|
|
// translateMRFilter has already written the HTTP error.
|
|
return
|
|
}
|
|
if translated != "" {
|
|
filter = translated
|
|
}
|
|
}
|
|
client, ok := c.workspaceClient(ctx)
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := client.SearchMRsPaged(
|
|
ctx.Request.Context(), filter, customQuery, page, perPage,
|
|
)
|
|
if err != nil {
|
|
writeWorkspaceClientActionError(ctx, err, "merge request search")
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// httpSearchUserIssues surfaces the configured user's issue queue. filter
|
|
// supports "assigned_to_me" and "created_by_me" (the /gitlab page's issue
|
|
// tabs). "review_requested" is explicitly rejected with 400 — GitLab has no
|
|
// reviewer-assigned concept for issues, and silently serving the global
|
|
// unscoped listing would re-introduce the bug this translator layer was
|
|
// added to prevent.
|
|
func (c *Controller) httpSearchUserIssues(ctx *gin.Context) {
|
|
page, perPage := paginationFromQuery(ctx)
|
|
filter := ctx.Query("filter")
|
|
customQuery := ctx.Query("custom_query")
|
|
if customQuery == "" {
|
|
if filter == filterTokenReviewRequested {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{responseErrorKey: "review_requested is not supported for issues"})
|
|
return
|
|
}
|
|
if translated := translateUserSearchFilter(filter, ""); translated != "" {
|
|
filter = translated
|
|
}
|
|
}
|
|
client, ok := c.workspaceClient(ctx)
|
|
if !ok {
|
|
return
|
|
}
|
|
result, err := client.ListIssuesPaged(
|
|
ctx.Request.Context(), filter, customQuery, page, perPage,
|
|
)
|
|
if err != nil {
|
|
writeWorkspaceClientActionError(ctx, err, "issue search")
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// translateMRFilter resolves the authenticated user (only when the
|
|
// review_requested tab needs it) and runs the filter through
|
|
// translateUserSearchFilter. On a username-lookup failure — including a
|
|
// successful call that returns no username (NoopClient, an unexpected
|
|
// GitLab response) — it writes a 500 to ctx and returns an error so the
|
|
// caller can short-circuit. Silently falling back to an unfiltered listing
|
|
// would re-introduce the very bug this translation layer was added to
|
|
// prevent.
|
|
func (c *Controller) translateMRFilter(ctx *gin.Context, filter string) (string, error) {
|
|
var username string
|
|
if filter == filterTokenReviewRequested {
|
|
client, ok := c.workspaceClient(ctx)
|
|
if !ok {
|
|
return "", ErrWorkspaceRequired
|
|
}
|
|
u, err := client.GetAuthenticatedUser(ctx.Request.Context())
|
|
if err != nil {
|
|
writeWorkspaceClientActionError(ctx, err, "authenticated user lookup")
|
|
return "", err
|
|
}
|
|
if u == "" {
|
|
err := errors.New("cannot resolve authenticated GitLab user")
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{responseErrorKey: err.Error()})
|
|
return "", err
|
|
}
|
|
username = u
|
|
}
|
|
return translateUserSearchFilter(filter, username), nil
|
|
}
|
|
|
|
// paginationFromQuery reads ?page=&per_page= with the same clamps SearchMRsPaged
|
|
// applies internally — surfaced here so 400s on bad input are uniform.
|
|
func paginationFromQuery(ctx *gin.Context) (int, int) {
|
|
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
perPage, _ := strconv.Atoi(ctx.DefaultQuery("per_page", "50"))
|
|
if perPage <= 0 {
|
|
perPage = 50
|
|
}
|
|
return page, perPage
|
|
}
|