feat: add Linear integration (#736)
* feat: add Linear integration
Mirrors the existing Jira integration so a workspace can connect to Linear
with a personal API key, browse and search issues, and start a Kandev task
from any issue. Backend uses Linear's GraphQL API with a workspace-scoped
config + secret store and a 90s auth-health poller; the frontend adds a
settings page, an issue dialog with state transitions, link/import buttons
in the task UI, and a paginated /linear browse page with a Start task flow.
* fix(linear): address PR review feedback
- Route httpListStates through Service.ListStates instead of clientFor
- Extract repeated workspace_id required string into a constant
- Log GetConfig errors during credential resolution instead of dropping them
- Clear popover error state on close in import + link buttons
- Pick the first workflow with steps in the quick task launcher (don't hard-pin to workflows[0])
- Reset useLinearEnabled state on workspace change via tag-based pattern
- Add cursor-pointer to anchor + Link elements per project convention
- Add tests: handlers_test.go, linear-api.test.ts, use-linear-enabled.test.ts, linear-issue-common.test.tsx
- Document internal/jira and internal/linear in CLAUDE.md/AGENTS.md
* fix(linear): address second-round PR review feedback
- Round Linear's fractional state position instead of truncating it via
int(math.Round(s.Position)) so adjacent fractional positions don't collapse
- Force LinearSettings to remount on workspace change with key={id} so the
previous workspace's config / form / testResult / teams cannot bleed into
the next workspace before the new fetches settle
- Broaden the legacy-value useLinearEnabled test to cover "true", "1", "yes",
and "legacy" so a regression to strict value === "true" would fail
* fix(linear): address third-round PR review feedback
- Propagate context.Canceled / context.DeadlineExceeded from ListStates in
GetIssue instead of swallowing them — a caller cancellation must not return
a partial-success response
- Drop the one-off contains() test helper in handlers_test.go and use
strings.Contains directly so the assertion is self-documenting
- Add getLinearIssue URL-shape test that locks identifier-as-path-segment
vs workspaceId-as-query-param so accidentally swapping them would fail
* test(linear): extract repeated literals in linear-api.test.ts
Pulls "ws-1", the API base URL, and the auth-method literal into module-
level constants so the assertions read as the route shape they care about
and the duplicate-strings ESLint rule (currently warn, threshold 4)
stays quiet as the file grows.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
package linear
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
// errMsgWorkspaceIDRequired is the response body returned when a handler is
|
||||
// invoked without the workspace_id query parameter. Centralised because the
|
||||
// linter flags repeated string literals at three or more occurrences.
|
||||
const errMsgWorkspaceIDRequired = "workspace_id required"
|
||||
|
||||
// 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("/issues", c.httpSearchIssues)
|
||||
api.GET("/issues/:id", c.httpGetIssue)
|
||||
api.POST("/issues/:id/state", c.httpSetIssueState)
|
||||
}
|
||||
|
||||
// --- HTTP handlers ---
|
||||
|
||||
func (c *Controller) httpGetConfig(ctx *gin.Context) {
|
||||
workspaceID := ctx.Query("workspace_id")
|
||||
if workspaceID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired})
|
||||
return
|
||||
}
|
||||
cfg, err := c.service.GetConfig(ctx.Request.Context(), workspaceID)
|
||||
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) {
|
||||
workspaceID := ctx.Query("workspace_id")
|
||||
if workspaceID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired})
|
||||
return
|
||||
}
|
||||
if err := c.service.DeleteConfig(ctx.Request.Context(), workspaceID); 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) {
|
||||
workspaceID := ctx.Query("workspace_id")
|
||||
if workspaceID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired})
|
||||
return
|
||||
}
|
||||
teams, err := c.service.ListTeams(ctx.Request.Context(), workspaceID)
|
||||
if err != nil {
|
||||
c.writeClientError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"teams": teams})
|
||||
}
|
||||
|
||||
func (c *Controller) httpListStates(ctx *gin.Context) {
|
||||
workspaceID := ctx.Query("workspace_id")
|
||||
teamKey := ctx.Query("team_key")
|
||||
if workspaceID == "" || teamKey == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id and team_key required"})
|
||||
return
|
||||
}
|
||||
states, err := c.service.ListStates(ctx.Request.Context(), workspaceID, teamKey)
|
||||
if err != nil {
|
||||
c.writeClientError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, gin.H{"states": states})
|
||||
}
|
||||
|
||||
func (c *Controller) httpSearchIssues(ctx *gin.Context) {
|
||||
workspaceID := ctx.Query("workspace_id")
|
||||
if workspaceID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired})
|
||||
return
|
||||
}
|
||||
filter := SearchFilter{
|
||||
Query: ctx.Query("query"),
|
||||
TeamKey: ctx.Query("team_key"),
|
||||
Assigned: ctx.Query("assigned"),
|
||||
}
|
||||
if states := ctx.Query("state_ids"); states != "" {
|
||||
filter.StateIDs = splitCSV(states)
|
||||
}
|
||||
pageToken := ctx.Query("page_token")
|
||||
maxResults, _ := strconv.Atoi(ctx.Query("max_results"))
|
||||
result, err := c.service.SearchIssues(ctx.Request.Context(), workspaceID, filter, pageToken, maxResults)
|
||||
if err != nil {
|
||||
c.writeClientError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (c *Controller) httpGetIssue(ctx *gin.Context) {
|
||||
workspaceID := ctx.Query("workspace_id")
|
||||
if workspaceID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired})
|
||||
return
|
||||
}
|
||||
id := ctx.Param("id")
|
||||
issue, err := c.service.GetIssue(ctx.Request.Context(), workspaceID, id)
|
||||
if err != nil {
|
||||
c.writeClientError(ctx, err)
|
||||
return
|
||||
}
|
||||
ctx.JSON(http.StatusOK, issue)
|
||||
}
|
||||
|
||||
func (c *Controller) httpSetIssueState(ctx *gin.Context) {
|
||||
workspaceID := ctx.Query("workspace_id")
|
||||
if workspaceID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": errMsgWorkspaceIDRequired})
|
||||
return
|
||||
}
|
||||
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(), workspaceID, 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
|
||||
// the workspace has no saved Linear credentials.
|
||||
const errCodeLinearNotConfigured = "LINEAR_NOT_CONFIGURED"
|
||||
|
||||
// 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 for this workspace",
|
||||
"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) {
|
||||
var p struct {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
}
|
||||
if err := msg.ParsePayload(&p); err != nil || p.WorkspaceID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId required", nil)
|
||||
}
|
||||
cfg, err := svc.GetConfig(ctx, p.WorkspaceID)
|
||||
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) {
|
||||
var p struct {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
}
|
||||
if err := msg.ParsePayload(&p); err != nil || p.WorkspaceID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId required", nil)
|
||||
}
|
||||
if err := svc.DeleteConfig(ctx, p.WorkspaceID); 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 {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
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.WorkspaceID == "" || p.Identifier == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId and identifier required", nil)
|
||||
}
|
||||
issue, err := svc.GetIssue(ctx, p.WorkspaceID, 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 {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
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.WorkspaceID == "" || p.IssueID == "" || p.StateID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId, issueId, stateId required", nil)
|
||||
}
|
||||
if err := svc.SetIssueState(ctx, p.WorkspaceID, 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) {
|
||||
var p struct {
|
||||
WorkspaceID string `json:"workspaceId"`
|
||||
}
|
||||
if err := msg.ParsePayload(&p); err != nil || p.WorkspaceID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspaceId required", nil)
|
||||
}
|
||||
teams, err := svc.ListTeams(ctx, p.WorkspaceID)
|
||||
if err != nil {
|
||||
return wsFail(msg, err)
|
||||
}
|
||||
return wsReply(msg, gin.H{"teams": teams})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user