481 lines
16 KiB
Go
481 lines
16 KiB
Go
package jira
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
ws "github.com/kandev/kandev/pkg/websocket"
|
|
)
|
|
|
|
// RegisterRoutes wires the Jira HTTP and WebSocket handlers.
|
|
func RegisterRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, svc *Service, log *logger.Logger) {
|
|
ctrl := &Controller{service: svc, logger: log}
|
|
ctrl.RegisterHTTPRoutes(router)
|
|
registerWSHandlers(dispatcher, svc)
|
|
}
|
|
|
|
// Controller holds HTTP route handlers for the Jira integration.
|
|
type Controller struct {
|
|
service *Service
|
|
logger *logger.Logger
|
|
}
|
|
|
|
// RegisterHTTPRoutes attaches the Jira HTTP endpoints to router.
|
|
func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
|
|
api := router.Group("/api/v1/jira")
|
|
api.GET("/config", c.httpGetConfig)
|
|
api.POST("/config", c.httpSetConfig)
|
|
api.DELETE("/config", c.httpDeleteConfig)
|
|
api.POST("/config/test", c.httpTestConfig)
|
|
api.GET("/projects", c.httpListProjects)
|
|
api.GET("/tickets", c.httpSearchTickets)
|
|
api.GET("/tickets/:key", c.httpGetTicket)
|
|
api.POST("/tickets/:key/transitions", c.httpDoTransition)
|
|
|
|
api.GET("/watches/issue", c.httpListIssueWatches)
|
|
api.POST("/watches/issue", c.httpCreateIssueWatch)
|
|
api.PATCH("/watches/issue/:id", c.httpUpdateIssueWatch)
|
|
api.DELETE("/watches/issue/:id", c.httpDeleteIssueWatch)
|
|
api.POST("/watches/issue/:id/trigger", c.httpTriggerIssueWatch)
|
|
api.GET("/watches/issue/:id/reset/preview", c.httpPreviewResetIssueWatch)
|
|
api.POST("/watches/issue/:id/reset", c.httpResetIssueWatch)
|
|
}
|
|
|
|
// --- HTTP handlers ---
|
|
|
|
func (c *Controller) httpGetConfig(ctx *gin.Context) {
|
|
cfg, err := c.service.GetConfig(ctx.Request.Context())
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if cfg == nil {
|
|
ctx.Status(http.StatusNoContent)
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, cfg)
|
|
}
|
|
|
|
func (c *Controller) httpSetConfig(ctx *gin.Context) {
|
|
var req SetConfigRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
cfg, err := c.service.SetConfig(ctx.Request.Context(), &req)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, ErrInvalidConfig) {
|
|
status = http.StatusBadRequest
|
|
}
|
|
ctx.JSON(status, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, cfg)
|
|
}
|
|
|
|
func (c *Controller) httpDeleteConfig(ctx *gin.Context) {
|
|
if err := c.service.DeleteConfig(ctx.Request.Context()); err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"deleted": true})
|
|
}
|
|
|
|
func (c *Controller) httpTestConfig(ctx *gin.Context) {
|
|
var req SetConfigRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
result, err := c.service.TestConnection(ctx.Request.Context(), &req)
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (c *Controller) httpListProjects(ctx *gin.Context) {
|
|
projects, err := c.service.ListProjects(ctx.Request.Context())
|
|
if err != nil {
|
|
c.writeClientError(ctx, err)
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"projects": projects})
|
|
}
|
|
|
|
func (c *Controller) httpSearchTickets(ctx *gin.Context) {
|
|
jql := ctx.Query("jql")
|
|
pageToken := ctx.Query("page_token")
|
|
maxResults, _ := strconv.Atoi(ctx.Query("max_results"))
|
|
result, err := c.service.SearchTickets(ctx.Request.Context(), jql, pageToken, maxResults)
|
|
if err != nil {
|
|
c.writeClientError(ctx, err)
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
func (c *Controller) httpGetTicket(ctx *gin.Context) {
|
|
key := ctx.Param("key")
|
|
ticket, err := c.service.GetTicket(ctx.Request.Context(), key)
|
|
if err != nil {
|
|
c.writeClientError(ctx, err)
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, ticket)
|
|
}
|
|
|
|
func (c *Controller) httpDoTransition(ctx *gin.Context) {
|
|
key := ctx.Param("key")
|
|
var req struct {
|
|
TransitionID string `json:"transitionId"`
|
|
}
|
|
if err := ctx.ShouldBindJSON(&req); err != nil || req.TransitionID == "" {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "transitionId required"})
|
|
return
|
|
}
|
|
if err := c.service.DoTransition(ctx.Request.Context(), key, req.TransitionID); err != nil {
|
|
c.writeClientError(ctx, err)
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"transitioned": true})
|
|
}
|
|
|
|
// --- Issue watch HTTP handlers ---
|
|
|
|
// httpListIssueWatches returns watches scoped to one workspace when
|
|
// `workspace_id` is supplied, or every watch across all workspaces when it
|
|
// is absent. The integration settings page uses the unscoped form so the
|
|
// table can render a Workspace column without requiring an upfront pick.
|
|
func (c *Controller) httpListIssueWatches(ctx *gin.Context) {
|
|
workspaceID := ctx.Query("workspace_id")
|
|
var (
|
|
watches []*IssueWatch
|
|
err error
|
|
)
|
|
if workspaceID == "" {
|
|
watches, err = c.service.ListAllIssueWatches(ctx.Request.Context())
|
|
} else {
|
|
watches, err = c.service.ListIssueWatches(ctx.Request.Context(), workspaceID)
|
|
}
|
|
if err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"watches": watches})
|
|
}
|
|
|
|
func (c *Controller) httpCreateIssueWatch(ctx *gin.Context) {
|
|
var req CreateIssueWatchRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
w, err := c.service.CreateIssueWatch(ctx.Request.Context(), &req)
|
|
if err != nil {
|
|
c.writeIssueWatchError(ctx, err)
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, w)
|
|
}
|
|
|
|
func (c *Controller) httpUpdateIssueWatch(ctx *gin.Context) {
|
|
id := ctx.Param("id")
|
|
if !c.assertWatchInWorkspace(ctx, id) {
|
|
return
|
|
}
|
|
var req UpdateIssueWatchRequest
|
|
if err := ctx.ShouldBindJSON(&req); err != nil {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
w, err := c.service.UpdateIssueWatch(ctx.Request.Context(), id, &req)
|
|
if err != nil {
|
|
c.writeIssueWatchError(ctx, err)
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, w)
|
|
}
|
|
|
|
func (c *Controller) httpDeleteIssueWatch(ctx *gin.Context) {
|
|
id := ctx.Param("id")
|
|
if !c.assertWatchInWorkspace(ctx, id) {
|
|
return
|
|
}
|
|
if err := c.service.DeleteIssueWatch(ctx.Request.Context(), id); err != nil {
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"deleted": true})
|
|
}
|
|
|
|
// httpTriggerIssueWatch runs a single immediate poll of the watch. Useful from
|
|
// the UI to verify a JQL change without waiting for the next 5-minute tick.
|
|
// Returns the count of newly-discovered tickets so the user gets feedback even
|
|
// when the matching tickets fan out asynchronously through the orchestrator.
|
|
func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) {
|
|
id := ctx.Param("id")
|
|
w, err := c.service.GetIssueWatch(ctx.Request.Context(), id)
|
|
if err != nil {
|
|
c.writeIssueWatchError(ctx, err)
|
|
return
|
|
}
|
|
if !workspaceMatches(ctx, w.WorkspaceID) {
|
|
// 404 not 403 — don't reveal whether the ID exists.
|
|
ctx.JSON(http.StatusNotFound, gin.H{"error": ErrIssueWatchNotFound.Error()})
|
|
return
|
|
}
|
|
tickets, err := c.service.CheckIssueWatch(ctx.Request.Context(), w)
|
|
if err != nil {
|
|
c.writeClientError(ctx, err)
|
|
return
|
|
}
|
|
for _, t := range tickets {
|
|
c.service.publishNewJiraIssueEvent(ctx.Request.Context(), w, t)
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"newIssues": len(tickets)})
|
|
}
|
|
|
|
// httpPreviewResetIssueWatch returns the count of tasks that a reset on the
|
|
// watch would cascade-delete. Used by the confirmation dialog so the user
|
|
// sees "delete N task(s)" before they commit.
|
|
func (c *Controller) httpPreviewResetIssueWatch(ctx *gin.Context) {
|
|
id := ctx.Param("id")
|
|
if !c.assertWatchInWorkspace(ctx, id) {
|
|
return
|
|
}
|
|
n, err := c.service.PreviewResetIssueWatch(ctx.Request.Context(), id)
|
|
if err != nil {
|
|
c.writeIssueWatchError(ctx, err)
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"taskCount": n})
|
|
}
|
|
|
|
// httpResetIssueWatch executes the destructive reset: cascade-deletes all
|
|
// tasks previously created by the watch (including archived), wipes its
|
|
// dedup table, and nulls last_polled_at so the next poll re-imports
|
|
// everything currently matching the JQL.
|
|
func (c *Controller) httpResetIssueWatch(ctx *gin.Context) {
|
|
id := ctx.Param("id")
|
|
if !c.assertWatchInWorkspace(ctx, id) {
|
|
return
|
|
}
|
|
n, err := c.service.ResetIssueWatch(ctx.Request.Context(), id)
|
|
if err != nil {
|
|
c.writeIssueWatchError(ctx, err)
|
|
return
|
|
}
|
|
ctx.JSON(http.StatusOK, gin.H{"tasksDeleted": n})
|
|
}
|
|
|
|
// assertWatchInWorkspace guards mutation/trigger endpoints against IDOR: the
|
|
// caller must supply `?workspace_id=...` matching the watch's workspace. The
|
|
// list/create endpoints already require this query; mirroring it here closes
|
|
// the gap where a known watch UUID from another workspace could be mutated.
|
|
// Writes the response and returns false on mismatch — caller bails out.
|
|
func (c *Controller) assertWatchInWorkspace(ctx *gin.Context, id string) bool {
|
|
w, err := c.service.GetIssueWatch(ctx.Request.Context(), id)
|
|
if err != nil {
|
|
c.writeIssueWatchError(ctx, err)
|
|
return false
|
|
}
|
|
if !workspaceMatches(ctx, w.WorkspaceID) {
|
|
// Use 404 so a probing client can't tell whether a watch ID exists in
|
|
// another workspace.
|
|
ctx.JSON(http.StatusNotFound, gin.H{"error": ErrIssueWatchNotFound.Error()})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// workspaceMatches returns true when the request's `workspace_id` query param
|
|
// matches the resource's stored workspace. Empty query is rejected so the
|
|
// caller can't bypass the check by omitting the parameter.
|
|
func workspaceMatches(ctx *gin.Context, resourceWorkspace string) bool {
|
|
q := ctx.Query("workspace_id")
|
|
return q != "" && q == resourceWorkspace
|
|
}
|
|
|
|
func (c *Controller) writeIssueWatchError(ctx *gin.Context, err error) {
|
|
if errors.Is(err, ErrIssueWatchNotFound) {
|
|
ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if errors.Is(err, ErrInvalidConfig) {
|
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.writeClientError(ctx, err)
|
|
}
|
|
|
|
// errCodeJiraNotConfigured is the wire-level code surfaced to the UI when
|
|
// Jira has no saved credentials. The same string is used by both the HTTP
|
|
// and WebSocket layers so the frontend can branch on a single value.
|
|
const errCodeJiraNotConfigured = "JIRA_NOT_CONFIGURED"
|
|
|
|
// writeClientError maps service-level errors to HTTP responses. ErrNotConfigured
|
|
// surfaces as 503 so the UI can prompt the user to configure Jira; upstream
|
|
// API errors propagate their status codes.
|
|
func (c *Controller) writeClientError(ctx *gin.Context, err error) {
|
|
if errors.Is(err, ErrNotConfigured) {
|
|
ctx.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"error": "Jira is not configured",
|
|
"code": errCodeJiraNotConfigured,
|
|
})
|
|
return
|
|
}
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) {
|
|
status := http.StatusInternalServerError
|
|
switch apiErr.StatusCode {
|
|
case http.StatusNotFound, http.StatusUnauthorized, http.StatusForbidden, http.StatusBadRequest:
|
|
status = apiErr.StatusCode
|
|
}
|
|
// 3xx from the upstream means Atlassian redirected to login (step-up
|
|
// auth, expired session, etc.); treat it as unauthorized for the UI.
|
|
if apiErr.StatusCode >= 300 && apiErr.StatusCode < 400 {
|
|
status = http.StatusUnauthorized
|
|
}
|
|
ctx.JSON(status, gin.H{"error": apiErr.Error()})
|
|
return
|
|
}
|
|
c.logger.Warn("jira handler error", zap.Error(err))
|
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
}
|
|
|
|
// --- WebSocket handlers ---
|
|
|
|
func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service) {
|
|
dispatcher.RegisterFunc(ws.ActionJiraConfigGet, wsGetConfig(svc))
|
|
dispatcher.RegisterFunc(ws.ActionJiraConfigSet, wsSetConfig(svc))
|
|
dispatcher.RegisterFunc(ws.ActionJiraConfigDelete, wsDeleteConfig(svc))
|
|
dispatcher.RegisterFunc(ws.ActionJiraConfigTest, wsTestConfig(svc))
|
|
dispatcher.RegisterFunc(ws.ActionJiraTicketGet, wsGetTicket(svc))
|
|
dispatcher.RegisterFunc(ws.ActionJiraTicketTransition, wsDoTransition(svc))
|
|
dispatcher.RegisterFunc(ws.ActionJiraProjectsList, wsListProjects(svc))
|
|
}
|
|
|
|
// wsReply wraps the common boilerplate: try to build a response, fall back to
|
|
// an internal-error envelope if marshaling fails.
|
|
func wsReply(msg *ws.Message, payload interface{}) (*ws.Message, error) {
|
|
resp, err := ws.NewResponse(msg.ID, msg.Action, payload)
|
|
if err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func wsFail(msg *ws.Message, err error) (*ws.Message, error) {
|
|
if errors.Is(err, ErrNotConfigured) {
|
|
return ws.NewError(msg.ID, msg.Action, errCodeJiraNotConfigured, err.Error(), nil)
|
|
}
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
|
|
}
|
|
|
|
func wsGetConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
cfg, err := svc.GetConfig(ctx)
|
|
if err != nil {
|
|
return wsFail(msg, err)
|
|
}
|
|
return wsReply(msg, gin.H{"config": cfg})
|
|
}
|
|
}
|
|
|
|
func wsSetConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
var req SetConfigRequest
|
|
if err := msg.ParsePayload(&req); err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil)
|
|
}
|
|
cfg, err := svc.SetConfig(ctx, &req)
|
|
if err != nil {
|
|
if errors.Is(err, ErrInvalidConfig) {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, err.Error(), nil)
|
|
}
|
|
return wsFail(msg, err)
|
|
}
|
|
return wsReply(msg, gin.H{"config": cfg})
|
|
}
|
|
}
|
|
|
|
func wsDeleteConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
if err := svc.DeleteConfig(ctx); err != nil {
|
|
return wsFail(msg, err)
|
|
}
|
|
return wsReply(msg, gin.H{"deleted": true})
|
|
}
|
|
}
|
|
|
|
func wsTestConfig(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
var req SetConfigRequest
|
|
if err := msg.ParsePayload(&req); err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil)
|
|
}
|
|
result, err := svc.TestConnection(ctx, &req)
|
|
if err != nil {
|
|
return wsFail(msg, err)
|
|
}
|
|
return wsReply(msg, result)
|
|
}
|
|
}
|
|
|
|
func wsGetTicket(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
var p struct {
|
|
TicketKey string `json:"ticketKey"`
|
|
}
|
|
if err := msg.ParsePayload(&p); err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil)
|
|
}
|
|
if p.TicketKey == "" {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "ticketKey required", nil)
|
|
}
|
|
ticket, err := svc.GetTicket(ctx, p.TicketKey)
|
|
if err != nil {
|
|
return wsFail(msg, err)
|
|
}
|
|
return wsReply(msg, ticket)
|
|
}
|
|
}
|
|
|
|
func wsDoTransition(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
var p struct {
|
|
TicketKey string `json:"ticketKey"`
|
|
TransitionID string `json:"transitionId"`
|
|
}
|
|
if err := msg.ParsePayload(&p); err != nil {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil)
|
|
}
|
|
if p.TicketKey == "" || p.TransitionID == "" {
|
|
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "ticketKey and transitionId required", nil)
|
|
}
|
|
if err := svc.DoTransition(ctx, p.TicketKey, p.TransitionID); err != nil {
|
|
return wsFail(msg, err)
|
|
}
|
|
return wsReply(msg, gin.H{"transitioned": true})
|
|
}
|
|
}
|
|
|
|
func wsListProjects(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
|
projects, err := svc.ListProjects(ctx)
|
|
if err != nil {
|
|
return wsFail(msg, err)
|
|
}
|
|
return wsReply(msg, gin.H{"projects": projects})
|
|
}
|
|
}
|