package linear import ( "context" "errors" "fmt" "math" "net/http" "strconv" "strings" "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 Linear 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 Linear integration. type Controller struct { service *Service logger *logger.Logger } // RegisterHTTPRoutes attaches the Linear HTTP endpoints to router. func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) { api := router.Group("/api/v1/linear") api.GET("/config", c.httpGetConfig) api.POST("/config", c.httpSetConfig) api.DELETE("/config", c.httpDeleteConfig) api.POST("/config/test", c.httpTestConfig) api.GET("/teams", c.httpListTeams) api.GET("/states", c.httpListStates) api.GET("/labels", c.httpListLabels) api.GET("/users", c.httpListUsers) api.GET("/issues", c.httpSearchIssues) api.GET("/issues/:id", c.httpGetIssue) api.POST("/issues/:id/state", c.httpSetIssueState) 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) httpListTeams(ctx *gin.Context) { teams, err := c.service.ListTeams(ctx.Request.Context()) if err != nil { c.writeClientError(ctx, err) return } ctx.JSON(http.StatusOK, gin.H{"teams": teams}) } func (c *Controller) httpListStates(ctx *gin.Context) { teamKey := ctx.Query(teamKeyParam) if teamKey == "" { ctx.JSON(http.StatusBadRequest, gin.H{"error": teamKeyRequired}) return } states, err := c.service.ListStates(ctx.Request.Context(), teamKey) if err != nil { c.writeClientError(ctx, err) return } ctx.JSON(http.StatusOK, gin.H{"states": states}) } func (c *Controller) httpListLabels(ctx *gin.Context) { teamKey := ctx.Query(teamKeyParam) if teamKey == "" { ctx.JSON(http.StatusBadRequest, gin.H{"error": teamKeyRequired}) return } labels, err := c.service.ListLabels(ctx.Request.Context(), teamKey) if err != nil { c.writeClientError(ctx, err) return } ctx.JSON(http.StatusOK, gin.H{"labels": labels}) } func (c *Controller) httpListUsers(ctx *gin.Context) { teamKey := ctx.Query(teamKeyParam) if teamKey == "" { ctx.JSON(http.StatusBadRequest, gin.H{"error": teamKeyRequired}) return } users, err := c.service.ListUsers(ctx.Request.Context(), teamKey) if err != nil { c.writeClientError(ctx, err) return } ctx.JSON(http.StatusOK, gin.H{"users": users}) } func (c *Controller) httpSearchIssues(ctx *gin.Context) { filter := SearchFilter{ Query: ctx.Query("query"), TeamKey: ctx.Query(teamKeyParam), Assigned: ctx.Query("assigned"), CreatorID: ctx.Query("creator_id"), } if states := ctx.Query("state_ids"); states != "" { filter.StateIDs = splitCSV(states) } if labels := ctx.Query("label_ids"); labels != "" { filter.LabelIDs = splitCSV(labels) } if err := parseSearchNumericFilters(ctx, &filter); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } pageToken := ctx.Query("page_token") maxResults, _ := strconv.Atoi(ctx.Query("max_results")) result, err := c.service.SearchIssues(ctx.Request.Context(), filter, pageToken, maxResults) if err != nil { c.writeClientError(ctx, err) return } ctx.JSON(http.StatusOK, result) } // parseSearchNumericFilters parses priority + estimate query params onto the // filter and returns a 400-worthy error when any are malformed. Invalid input // is rejected rather than silently dropped so callers don't accidentally widen // the search by typoing a number. func parseSearchNumericFilters(ctx *gin.Context, filter *SearchFilter) error { priorities, err := parseSearchPriorities(ctx.Query("priorities")) if err != nil { return err } filter.Priorities = priorities if filter.EstimateMin, err = parseEstimateBound(ctx.Query("estimate_min"), "estimate_min"); err != nil { return err } if filter.EstimateMax, err = parseEstimateBound(ctx.Query("estimate_max"), "estimate_max"); err != nil { return err } if filter.EstimateMin != nil && filter.EstimateMax != nil && *filter.EstimateMin > *filter.EstimateMax { return fmt.Errorf("estimate_min cannot be greater than estimate_max") } return nil } func parseSearchPriorities(raw string) ([]int, error) { if raw == "" { return nil, nil } parts := splitCSV(raw) priorities := make([]int, 0, len(parts)) for _, p := range parts { v, err := strconv.Atoi(p) if err != nil || v < 0 || v > 4 { return nil, fmt.Errorf("priorities must be integers between 0 and 4") } priorities = append(priorities, v) } return priorities, nil } // parseEstimateBound parses a non-negative float query param. strconv.ParseFloat // accepts "NaN", "Inf", "-Inf" without error, but json.Marshal rejects them // later (turning a 400 into a 500), so we filter them out here. func parseEstimateBound(raw, name string) (*float64, error) { if raw == "" { return nil, nil } f, err := strconv.ParseFloat(raw, 64) if err != nil || math.IsNaN(f) || math.IsInf(f, 0) || f < 0 { return nil, fmt.Errorf("%s must be a non-negative number", name) } return &f, nil } func (c *Controller) httpGetIssue(ctx *gin.Context) { id := ctx.Param("id") issue, err := c.service.GetIssue(ctx.Request.Context(), id) if err != nil { c.writeClientError(ctx, err) return } ctx.JSON(http.StatusOK, issue) } func (c *Controller) httpSetIssueState(ctx *gin.Context) { id := ctx.Param("id") var req struct { StateID string `json:"stateId"` } if err := ctx.ShouldBindJSON(&req); err != nil || req.StateID == "" { ctx.JSON(http.StatusBadRequest, gin.H{"error": "stateId required"}) return } if err := c.service.SetIssueState(ctx.Request.Context(), id, req.StateID); err != nil { c.writeClientError(ctx, err) return } ctx.JSON(http.StatusOK, gin.H{"transitioned": true}) } // errCodeLinearNotConfigured is the wire-level code surfaced to the UI when // Linear has no saved credentials. const errCodeLinearNotConfigured = "LINEAR_NOT_CONFIGURED" // Wire-format constants for the team_key query parameter that scopes // per-team endpoints (states / labels / users / search). const ( teamKeyParam = "team_key" teamKeyRequired = "team_key required" ) // writeClientError maps service-level errors to HTTP responses. func (c *Controller) writeClientError(ctx *gin.Context, err error) { if errors.Is(err, ErrNotConfigured) { ctx.JSON(http.StatusServiceUnavailable, gin.H{ "error": "Linear is not configured", "code": errCodeLinearNotConfigured, }) 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 } ctx.JSON(status, gin.H{"error": apiErr.Error()}) return } c.logger.Warn("linear handler error", zap.Error(err)) ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) } func splitCSV(raw string) []string { parts := strings.Split(raw, ",") out := parts[:0] for _, p := range parts { p = strings.TrimSpace(p) if p != "" { out = append(out, p) } } return out } // --- WebSocket handlers --- func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service) { dispatcher.RegisterFunc(ws.ActionLinearConfigGet, wsGetConfig(svc)) dispatcher.RegisterFunc(ws.ActionLinearConfigSet, wsSetConfig(svc)) dispatcher.RegisterFunc(ws.ActionLinearConfigDelete, wsDeleteConfig(svc)) dispatcher.RegisterFunc(ws.ActionLinearConfigTest, wsTestConfig(svc)) dispatcher.RegisterFunc(ws.ActionLinearIssueGet, wsGetIssue(svc)) dispatcher.RegisterFunc(ws.ActionLinearIssueTransition, wsSetIssueState(svc)) dispatcher.RegisterFunc(ws.ActionLinearTeamsList, wsListTeams(svc)) } 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, errCodeLinearNotConfigured, 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 wsGetIssue(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 { Identifier string `json:"identifier"` } if err := msg.ParsePayload(&p); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) } if p.Identifier == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "identifier required", nil) } issue, err := svc.GetIssue(ctx, p.Identifier) if err != nil { return wsFail(msg, err) } return wsReply(msg, issue) } } func wsSetIssueState(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 { IssueID string `json:"issueId"` StateID string `json:"stateId"` } if err := msg.ParsePayload(&p); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) } if p.IssueID == "" || p.StateID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "issueId and stateId required", nil) } if err := svc.SetIssueState(ctx, p.IssueID, p.StateID); err != nil { return wsFail(msg, err) } return wsReply(msg, gin.H{"transitioned": true}) } } func wsListTeams(svc *Service) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { teams, err := svc.ListTeams(ctx) if err != nil { return wsFail(msg, err) } return wsReply(msg, gin.H{"teams": teams}) } }