Files
2026-08-02 17:08:22 +01:00

657 lines
21 KiB
Go

package azuredevops
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/common/logger"
)
const notConfiguredCode = "azure_devops_not_configured"
type Controller struct {
service *Service
log *logger.Logger
}
func RegisterRoutes(router *gin.Engine, service *Service, log *logger.Logger) {
controller := &Controller{service: service, log: log}
api := router.Group("/api/v1/azure-devops")
api.GET("/config", controller.getConfig)
api.POST("/config", controller.setConfig)
api.DELETE("/config", controller.deleteConfig)
api.POST("/config/test", controller.testConfig)
api.POST("/config/copy", controller.copyConfig)
api.GET("/views", controller.getSavedViews)
api.PUT("/views", controller.setSavedViews)
api.GET("/workspace-settings", controller.getWorkspaceSettings)
api.PATCH("/workspace-settings", controller.updateWorkspaceSettings)
api.GET("/projects", controller.listProjects)
api.GET("/teams", controller.listTeams)
api.GET("/boards", controller.listBoards)
api.GET("/boards/:boardId", controller.getBoardSnapshot)
api.PATCH("/boards/:boardId/work-items/:id", controller.updateBoardWorkItem)
api.GET("/repositories", controller.listRepositories)
api.GET("/branches", controller.listBranches)
api.POST("/work-items/search", controller.searchWorkItems)
api.GET("/work-items/:id/comments", controller.listWorkItemComments)
api.PATCH("/work-items/:id", controller.updateWorkItem)
api.GET("/work-items/:id", controller.getWorkItem)
api.GET("/identity", controller.getIdentity)
api.GET("/watches/work-items", controller.listWorkItemWatches)
api.POST("/watches/work-items", controller.createWorkItemWatch)
api.PATCH("/watches/work-items/:id", controller.updateWorkItemWatch)
api.DELETE("/watches/work-items/:id", controller.deleteWorkItemWatch)
api.POST("/watches/work-items/:id/trigger", controller.triggerWorkItemWatch)
api.GET("/watches/work-items/:id/reset/preview", controller.previewResetWorkItemWatch)
api.POST("/watches/work-items/:id/reset", controller.resetWorkItemWatch)
api.GET("/watches/pull-requests", controller.listPullRequestWatches)
api.POST("/watches/pull-requests", controller.createPullRequestWatch)
api.PATCH("/watches/pull-requests/:id", controller.updatePullRequestWatch)
api.DELETE("/watches/pull-requests/:id", controller.deletePullRequestWatch)
api.POST("/watches/pull-requests/:id/trigger", controller.triggerPullRequestWatch)
api.GET("/watches/pull-requests/:id/reset/preview", controller.previewResetPullRequestWatch)
api.POST("/watches/pull-requests/:id/reset", controller.resetPullRequestWatch)
api.GET("/workspaces/:workspaceId/task-work-items", controller.listWorkspaceTaskWorkItems)
api.POST("/tasks/:taskId/work-items", controller.associateTaskWorkItem)
api.GET("/pull-requests", controller.listPullRequests)
api.GET("/pull-requests/:projectId/:repositoryId/:pullRequestId", controller.getPullRequest)
api.GET("/pull-requests/:projectId/:repositoryId/:pullRequestId/feedback", controller.getPullRequestFeedback)
api.GET("/workspaces/:workspaceId/task-prs", controller.listWorkspaceTaskPRs)
api.POST("/tasks/:taskId/pull-requests", controller.associateTaskPR)
api.POST("/tasks/:taskId/pull-requests/sync", controller.syncTaskPR)
}
func (c *Controller) getWorkspaceSettings(ctx *gin.Context) {
settings, err := c.service.GetWorkspaceSettings(ctx.Request.Context(), workspaceID(ctx))
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, settings)
}
func (c *Controller) updateWorkspaceSettings(ctx *gin.Context) {
var request UpdateWorkspaceSettingsRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
request.WorkspaceID = workspaceID(ctx)
settings, err := c.service.UpdateWorkspaceSettings(ctx.Request.Context(), &request)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, settings)
}
func (c *Controller) getSavedViews(ctx *gin.Context) {
views, err := c.service.GetSavedViewsForWorkspace(ctx.Request.Context(), workspaceID(ctx))
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"views": views})
}
func (c *Controller) setSavedViews(ctx *gin.Context) {
var request struct {
Views []SavedView `json:"views"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
views, err := c.service.SetSavedViewsForWorkspace(
ctx.Request.Context(), workspaceID(ctx), request.Views,
)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"views": views})
}
func (c *Controller) getConfig(ctx *gin.Context) {
cfg, err := c.service.GetConfigForWorkspace(ctx.Request.Context(), workspaceID(ctx))
if err != nil {
c.writeError(ctx, err)
return
}
if cfg == nil {
ctx.Status(http.StatusNoContent)
return
}
ctx.JSON(http.StatusOK, cfg)
}
func (c *Controller) setConfig(ctx *gin.Context) {
var request SetConfigRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
cfg, err := c.service.SetConfigForWorkspace(ctx.Request.Context(), workspaceID(ctx), &request)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, cfg)
}
func (c *Controller) deleteConfig(ctx *gin.Context) {
if err := c.service.DeleteConfigForWorkspace(ctx.Request.Context(), workspaceID(ctx)); err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"deleted": true})
}
func (c *Controller) testConfig(ctx *gin.Context) {
var request SetConfigRequest
if ctx.Request.ContentLength != 0 {
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
}
result, err := c.service.TestConnectionForWorkspace(ctx.Request.Context(), workspaceID(ctx), &request)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, result)
}
func (c *Controller) copyConfig(ctx *gin.Context) {
var request struct {
TargetWorkspaceID string `json:"targetWorkspaceId"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
cfg, err := c.service.CopyConfigToWorkspace(ctx.Request.Context(), workspaceID(ctx), request.TargetWorkspaceID)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, cfg)
}
func (c *Controller) listProjects(ctx *gin.Context) {
projects, err := c.service.ListProjectsForWorkspace(ctx.Request.Context(), workspaceID(ctx))
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"projects": projects})
}
func (c *Controller) listTeams(ctx *gin.Context) {
teams, err := c.service.ListTeamsForWorkspace(ctx.Request.Context(), workspaceID(ctx), ctx.Query("project"))
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"teams": teams})
}
func (c *Controller) listBoards(ctx *gin.Context) {
boards, err := c.service.ListBoardsForWorkspace(ctx.Request.Context(), workspaceID(ctx), ctx.Query("project"), ctx.Query("team"))
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"boards": boards})
}
func (c *Controller) getBoardSnapshot(ctx *gin.Context) {
snapshot, err := c.service.GetBoardSnapshotForWorkspace(ctx.Request.Context(), workspaceID(ctx), ctx.Query("project"), ctx.Query("team"), ctx.Param("boardId"))
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, snapshot)
}
func (c *Controller) updateBoardWorkItem(ctx *gin.Context) {
id, err := strconv.Atoi(ctx.Param("id"))
if err != nil || id <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid work item id"})
return
}
var request BoardWorkItemUpdateRequest
decoder := json.NewDecoder(ctx.Request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
item, err := c.service.UpdateBoardWorkItemForWorkspace(ctx.Request.Context(), workspaceID(ctx), ctx.Query("project"), ctx.Query("team"), ctx.Param("boardId"), id, request)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, item)
}
func (c *Controller) listRepositories(ctx *gin.Context) {
repositories, err := c.service.ListRepositoriesForWorkspace(
ctx.Request.Context(), workspaceID(ctx), ctx.Query("project"),
)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"repositories": repositories})
}
func (c *Controller) listBranches(ctx *gin.Context) {
branches, err := c.service.ListBranchesForWorkspace(
ctx.Request.Context(), workspaceID(ctx), ctx.Query("organization"), ctx.Query("project"),
ctx.Query("repository"),
)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"branches": branches})
}
func (c *Controller) searchWorkItems(ctx *gin.Context) {
var request struct {
Project string `json:"project"`
WIQL string `json:"wiql"`
Top int `json:"top"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
result, err := c.service.SearchWorkItemsForWorkspace(
ctx.Request.Context(), workspaceID(ctx), request.Project, request.WIQL, request.Top,
)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, result)
}
func (c *Controller) getWorkItem(ctx *gin.Context) {
id, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid work item id"})
return
}
item, err := c.service.GetWorkItemDetailForWorkspace(
ctx.Request.Context(), workspaceID(ctx), ctx.Query("project"), id,
)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, item)
}
func (c *Controller) updateWorkItem(ctx *gin.Context) {
id, err := strconv.Atoi(ctx.Param("id"))
if err != nil || id <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid work item id"})
return
}
var request WorkItemAssignmentRequest
decoder := json.NewDecoder(ctx.Request.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
item, err := c.service.UpdateWorkItemForWorkspace(ctx.Request.Context(), workspaceID(ctx), ctx.Query("project"), id, request)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, item)
}
func (c *Controller) listWorkItemComments(ctx *gin.Context) {
id, err := strconv.Atoi(ctx.Param("id"))
if err != nil || id <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid work item id"})
return
}
page, err := c.service.ListWorkItemCommentsForWorkspace(
ctx.Request.Context(), workspaceID(ctx), ctx.Query("project"), id, ctx.Query("continuation_token"),
)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, page)
}
func (c *Controller) getIdentity(ctx *gin.Context) {
identity, err := c.service.GetCurrentIdentityForWorkspace(ctx.Request.Context(), workspaceID(ctx))
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, identity)
}
func (c *Controller) listWorkItemWatches(ctx *gin.Context) {
watches, err := c.service.ListWorkItemWatches(ctx.Request.Context(), workspaceID(ctx))
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"watches": watches})
}
func (c *Controller) createWorkItemWatch(ctx *gin.Context) {
var request CreateWorkItemWatchRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
request.WorkspaceID = workspaceID(ctx)
watch, err := c.service.CreateWorkItemWatch(ctx.Request.Context(), &request)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, watch)
}
func (c *Controller) updateWorkItemWatch(ctx *gin.Context) {
var request UpdateWorkItemWatchRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
watch, err := c.service.UpdateWorkItemWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id"), &request)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, watch)
}
func (c *Controller) deleteWorkItemWatch(ctx *gin.Context) {
if err := c.service.DeleteWorkItemWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id")); err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"deleted": true})
}
func (c *Controller) triggerWorkItemWatch(ctx *gin.Context) {
result, err := c.service.TriggerWorkItemWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id"))
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, result)
}
func (c *Controller) previewResetWorkItemWatch(ctx *gin.Context) {
count, err := c.service.PreviewResetWorkItemWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id"))
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"taskCount": count})
}
func (c *Controller) resetWorkItemWatch(ctx *gin.Context) {
result, err := c.service.ResetWorkItemWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id"))
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"generation": result.Generation, "taskCount": len(result.TaskIDs)})
}
func (c *Controller) listPullRequestWatches(ctx *gin.Context) {
watches, err := c.service.ListPullRequestWatches(ctx.Request.Context(), workspaceID(ctx))
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"watches": watches})
}
func (c *Controller) createPullRequestWatch(ctx *gin.Context) {
var request CreatePullRequestWatchRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
request.WorkspaceID = workspaceID(ctx)
watch, err := c.service.CreatePullRequestWatch(ctx.Request.Context(), &request)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, watch)
}
func (c *Controller) updatePullRequestWatch(ctx *gin.Context) {
var request UpdatePullRequestWatchRequest
if err := ctx.ShouldBindJSON(&request); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
watch, err := c.service.UpdatePullRequestWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id"), &request)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, watch)
}
func (c *Controller) deletePullRequestWatch(ctx *gin.Context) {
if err := c.service.DeletePullRequestWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id")); err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"deleted": true})
}
func (c *Controller) triggerPullRequestWatch(ctx *gin.Context) {
result, err := c.service.TriggerPullRequestWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id"))
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, result)
}
func (c *Controller) previewResetPullRequestWatch(ctx *gin.Context) {
count, err := c.service.PreviewResetPullRequestWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id"))
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"taskCount": count})
}
func (c *Controller) resetPullRequestWatch(ctx *gin.Context) {
result, err := c.service.ResetPullRequestWatch(ctx.Request.Context(), workspaceID(ctx), ctx.Param("id"))
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, gin.H{"generation": result.Generation, "taskCount": len(result.TaskIDs)})
}
func (c *Controller) listPullRequests(ctx *gin.Context) {
skip, _ := strconv.Atoi(ctx.Query("skip"))
top, _ := strconv.Atoi(ctx.Query("top"))
result, err := c.service.ListPullRequestsForWorkspace(ctx.Request.Context(), workspaceID(ctx), PullRequestFilter{
ProjectID: ctx.Query("project"), RepositoryID: ctx.Query("repository"), Status: ctx.Query("status"),
CreatorID: ctx.Query("creator"), ReviewerID: ctx.Query("reviewer"),
SourceBranch: ctx.Query("source_branch"), TargetBranch: ctx.Query("target_branch"), Skip: skip, Top: top,
})
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, result)
}
func (c *Controller) getPullRequest(ctx *gin.Context) {
id, ok := pullRequestID(ctx)
if !ok {
return
}
pr, err := c.service.GetPullRequestForWorkspace(ctx.Request.Context(), workspaceID(ctx), ctx.Param("projectId"), ctx.Param("repositoryId"), id)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, pr)
}
func (c *Controller) getPullRequestFeedback(ctx *gin.Context) {
id, ok := pullRequestID(ctx)
if !ok {
return
}
feedback, err := c.service.GetPullRequestFeedbackForWorkspace(ctx.Request.Context(), workspaceID(ctx), ctx.Param("projectId"), ctx.Param("repositoryId"), id)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, feedback)
}
type taskPRRequest struct {
RepositoryID string `json:"repositoryId" binding:"required"`
PullRequestID int `json:"pullRequestId" binding:"required"`
}
type taskWorkItemRequest struct {
ProjectID string `json:"projectId" binding:"required"`
WorkItemID int `json:"workItemId" binding:"required"`
}
func (c *Controller) listWorkspaceTaskWorkItems(ctx *gin.Context) {
rows, err := c.service.ListTaskWorkItemsByWorkspace(ctx.Request.Context(), ctx.Param("workspaceId"))
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, TaskWorkItemsResponse{TaskWorkItems: rows})
}
func (c *Controller) associateTaskWorkItem(ctx *gin.Context) {
var request taskWorkItemRequest
if err := ctx.ShouldBindJSON(&request); err != nil || request.WorkItemID <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "projectId and positive workItemId are required"})
return
}
row, err := c.service.AssociateTaskWorkItem(ctx.Request.Context(), workspaceID(ctx), ctx.Param("taskId"), request.ProjectID, request.WorkItemID)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, row)
}
func (c *Controller) listWorkspaceTaskPRs(ctx *gin.Context) {
rows, err := c.service.ListTaskPRsByWorkspace(ctx.Request.Context(), ctx.Param("workspaceId"))
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, TaskPRsResponse{TaskPRs: rows})
}
func (c *Controller) associateTaskPR(ctx *gin.Context) {
c.writeTaskPRSync(ctx, c.service.AssociateTaskPR)
}
func (c *Controller) syncTaskPR(ctx *gin.Context) {
c.writeTaskPRSync(ctx, c.service.SyncTaskPR)
}
func (c *Controller) writeTaskPRSync(
ctx *gin.Context,
sync func(context.Context, string, string, string, int) (*TaskPR, error),
) {
var request taskPRRequest
if err := ctx.ShouldBindJSON(&request); err != nil || request.PullRequestID <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "repositoryId and positive pullRequestId are required"})
return
}
row, err := sync(
ctx.Request.Context(), workspaceID(ctx), ctx.Param("taskId"),
request.RepositoryID, request.PullRequestID,
)
if err != nil {
c.writeError(ctx, err)
return
}
ctx.JSON(http.StatusOK, row)
}
func (c *Controller) writeError(ctx *gin.Context, err error) {
status := http.StatusInternalServerError
code := ""
switch {
case errors.Is(err, ErrInvalidWorkspaceID), errors.Is(err, ErrInvalidConfig),
errors.Is(err, ErrInvalidTaskPRAssociation):
status = http.StatusBadRequest
case errors.Is(err, ErrWatchNotFound), errors.Is(err, ErrReservationNotFound):
status = http.StatusNotFound
case errors.Is(err, ErrWatchOwnershipLost):
status = http.StatusConflict
case errors.Is(err, ErrNotConfigured):
status, code = http.StatusServiceUnavailable, notConfiguredCode
default:
var apiErr *APIError
if errors.As(err, &apiErr) {
if apiErr.StatusCode == http.StatusConflict {
status, code = http.StatusConflict, "azure_devops_revision_conflict"
} else {
status = upstreamStatus(apiErr.StatusCode)
}
}
}
body := gin.H{"error": err.Error()}
if code != "" {
body["code"] = code
}
ctx.JSON(status, body)
}
func workspaceID(ctx *gin.Context) string { return strings.TrimSpace(ctx.Query("workspace_id")) }
func pullRequestID(ctx *gin.Context) (int, bool) {
id, err := strconv.Atoi(ctx.Param("pullRequestId"))
if err != nil || id <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid pull request id"})
return 0, false
}
return id, true
}
func upstreamStatus(status int) int {
switch status {
case http.StatusUnauthorized, http.StatusForbidden, http.StatusTooManyRequests:
return status
default:
if status >= 500 {
return http.StatusBadGateway
}
return http.StatusBadRequest
}
}