feat(integrations): align linear parity
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -33,7 +35,7 @@ func (h *LinearIntegrationHandler) Delete(c *gin.Context) {
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "Linear integration deleted"})
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// GetTeams retrieves available Linear teams.
|
||||
@@ -47,10 +49,10 @@ func (h *LinearIntegrationHandler) GetTeams(c *gin.Context) {
|
||||
|
||||
teams, svcErr := h.svc.GetTeams(c.Request.Context(), accountID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
handleLinearServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
response.OK(c, teams)
|
||||
c.JSON(http.StatusOK, teams)
|
||||
}
|
||||
|
||||
// GetTeamEntities retrieves entities from a Linear team.
|
||||
@@ -62,12 +64,12 @@ func (h *LinearIntegrationHandler) GetTeamEntities(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
entities, svcErr := h.svc.GetTeamEntities(c.Request.Context(), accountID)
|
||||
entities, svcErr := h.svc.GetTeamEntities(c.Request.Context(), accountID, c.Query("team_id"))
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
handleLinearServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
response.OK(c, entities)
|
||||
c.JSON(http.StatusOK, entities)
|
||||
}
|
||||
|
||||
// CreateIssue creates a Linear issue from a conversation.
|
||||
@@ -80,17 +82,17 @@ func (h *LinearIntegrationHandler) CreateIssue(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req service.CreateIssueRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, svcErr := h.svc.CreateIssue(c.Request.Context(), accountID, req)
|
||||
result, svcErr := h.svc.CreateIssue(c.Request.Context(), accountID, req, getUserID(c))
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
handleLinearServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// LinkIssue links a Linear issue to a conversation.
|
||||
@@ -103,17 +105,17 @@ func (h *LinearIntegrationHandler) LinkIssue(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req service.LinkIssueRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, svcErr := h.svc.LinkIssue(c.Request.Context(), accountID, req)
|
||||
result, svcErr := h.svc.LinkIssue(c.Request.Context(), accountID, req, getUserID(c))
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
handleLinearServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// UnlinkIssue unlinks a Linear issue from a conversation.
|
||||
@@ -126,17 +128,17 @@ func (h *LinearIntegrationHandler) UnlinkIssue(c *gin.Context) {
|
||||
}
|
||||
|
||||
var req service.UnlinkIssueRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
result, svcErr := h.svc.UnlinkIssue(c.Request.Context(), accountID, req)
|
||||
result, svcErr := h.svc.UnlinkIssue(c.Request.Context(), accountID, req, getUserID(c))
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
handleLinearServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// SearchIssue searches Linear issues.
|
||||
@@ -149,12 +151,16 @@ func (h *LinearIntegrationHandler) SearchIssue(c *gin.Context) {
|
||||
}
|
||||
|
||||
query := c.Query("q")
|
||||
issues, svcErr := h.svc.SearchIssue(c.Request.Context(), accountID, query)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
if strings.TrimSpace(query) == "" {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "Specify search string with parameter q"})
|
||||
return
|
||||
}
|
||||
response.OK(c, issues)
|
||||
issues, svcErr := h.svc.SearchIssue(c.Request.Context(), accountID, query)
|
||||
if svcErr != nil {
|
||||
handleLinearServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, issues)
|
||||
}
|
||||
|
||||
// GetLinkedIssues retrieves Linear issues linked to a conversation.
|
||||
@@ -166,17 +172,22 @@ func (h *LinearIntegrationHandler) GetLinkedIssues(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
conversationID, _ := parseUintParam(c, "conversation_id")
|
||||
issues, svcErr := h.svc.GetLinkedIssues(c.Request.Context(), accountID, conversationID)
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
conversationID, err := parseOptionalUintQueryParam(c, "conversation_id")
|
||||
if err != nil || conversationID == 0 {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id")
|
||||
return
|
||||
}
|
||||
response.OK(c, issues)
|
||||
issues, svcErr := h.svc.GetLinkedIssues(c.Request.Context(), accountID, conversationID)
|
||||
if svcErr != nil {
|
||||
handleLinearServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, issues)
|
||||
}
|
||||
|
||||
// RegisterLinearIntegrationRoutes registers Linear integration routes.
|
||||
func RegisterLinearIntegrationRoutes(g *gin.RouterGroup, h *LinearIntegrationHandler) {
|
||||
g.DELETE("/linear", h.Delete)
|
||||
linear := g.Group("/linear")
|
||||
{
|
||||
linear.DELETE("/", h.Delete)
|
||||
@@ -189,3 +200,12 @@ func RegisterLinearIntegrationRoutes(g *gin.RouterGroup, h *LinearIntegrationHan
|
||||
linear.GET("/linked_issues", h.GetLinkedIssues)
|
||||
}
|
||||
}
|
||||
|
||||
func handleLinearServiceError(c *gin.Context, err error) {
|
||||
var providerErr *service.LinearProviderError
|
||||
if errors.As(err, &providerErr) {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": providerErr.Message})
|
||||
return
|
||||
}
|
||||
handleServiceError(c, err)
|
||||
}
|
||||
|
||||
@@ -181,6 +181,20 @@ func TestLinearIntegration_SearchIssue_BadAccountID(t *testing.T) {
|
||||
assert.Contains(t, errBody["message"], "invalid account_id")
|
||||
}
|
||||
|
||||
func TestLinearIntegration_SearchIssue_BlankQuery(t *testing.T) {
|
||||
r := setupLinearIntegrationRouter()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/integrations/linear/search_issue", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
assert.Equal(t, "Specify search string with parameter q", resp["error"])
|
||||
}
|
||||
|
||||
func TestLinearIntegration_GetLinkedIssues_BadAccountID(t *testing.T) {
|
||||
r := setupLinearIntegrationRouter()
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ type IntegrationHookRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// DB returns the underlying gorm.DB for services that need adjacent models.
|
||||
func (r *IntegrationHookRepo) DB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
// NewIntegrationHookRepo creates a new IntegrationHook repository.
|
||||
func NewIntegrationHookRepo(db *gorm.DB) *IntegrationHookRepo {
|
||||
return &IntegrationHookRepo{db: db}
|
||||
|
||||
@@ -1,35 +1,74 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
)
|
||||
|
||||
// LinearProviderError mirrors Chatwoot's `{ error: ... }`, 422 provider failures.
|
||||
type LinearProviderError struct {
|
||||
Message interface{}
|
||||
}
|
||||
|
||||
func (e *LinearProviderError) Error() string {
|
||||
if e == nil || e.Message == nil {
|
||||
return "linear provider error"
|
||||
}
|
||||
if msg, ok := e.Message.(string); ok {
|
||||
return msg
|
||||
}
|
||||
b, err := json.Marshal(e.Message)
|
||||
if err != nil {
|
||||
return fmt.Sprint(e.Message)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// LinearIntegrationService implements Linear integration business logic.
|
||||
// Reference: Chatwoot Integrations::LinearController
|
||||
// Linear integration creates and links issues from conversations.
|
||||
type LinearIntegrationService struct {
|
||||
hookRepo *repository.IntegrationHookRepo
|
||||
hookRepo *repository.IntegrationHookRepo
|
||||
conversationRepo *repository.ConversationRepo
|
||||
messageRepo *repository.MessageRepo
|
||||
userRepo *repository.UserRepo
|
||||
client *linearAPIClient
|
||||
}
|
||||
|
||||
// NewLinearIntegrationService creates a new LinearIntegrationService.
|
||||
func NewLinearIntegrationService(hookRepo *repository.IntegrationHookRepo) *LinearIntegrationService {
|
||||
return &LinearIntegrationService{hookRepo: hookRepo}
|
||||
svc := &LinearIntegrationService{hookRepo: hookRepo, client: newLinearAPIClientFromEnv()}
|
||||
if hookRepo != nil && hookRepo.DB() != nil {
|
||||
db := hookRepo.DB()
|
||||
svc.conversationRepo = repository.NewConversationRepo(db)
|
||||
svc.messageRepo = repository.NewMessageRepo(db)
|
||||
svc.userRepo = repository.NewUserRepo(db)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// Delete removes a Linear integration hook for an account.
|
||||
func (s *LinearIntegrationService) Delete(ctx context.Context, accountID uint) error {
|
||||
hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear)
|
||||
hooks, err := s.findLinearHooks(ctx, accountID)
|
||||
if err != nil || len(hooks) == 0 {
|
||||
return fmt.Errorf("Linear integration not found for account %d", accountID)
|
||||
}
|
||||
|
||||
for _, hook := range hooks {
|
||||
_ = s.client.revokeToken(ctx, hook.AccessToken, linearRefreshToken(hook.Settings))
|
||||
if err := s.hookRepo.Delete(ctx, hook.ID); err != nil {
|
||||
return fmt.Errorf("failed to delete Linear integration: %w", err)
|
||||
}
|
||||
@@ -42,123 +81,508 @@ func (s *LinearIntegrationService) Delete(ctx context.Context, accountID uint) e
|
||||
// GetTeams retrieves available Linear teams (proxy to Linear API).
|
||||
// GET /api/v1/accounts/:account_id/integrations/linear/teams
|
||||
func (s *LinearIntegrationService) GetTeams(ctx context.Context, accountID uint) ([]map[string]interface{}, error) {
|
||||
hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear)
|
||||
if err != nil || len(hooks) == 0 {
|
||||
return nil, fmt.Errorf("Linear integration not found for account %d", accountID)
|
||||
client, err := s.linearClient(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var settings model.LinearSettings
|
||||
if err := json.Unmarshal(hooks[0].Settings, &settings); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal Linear settings: %w", err)
|
||||
data, err := client.teams(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// In production, this would call the Linear API.
|
||||
applogger.L().Infof("Listing Linear teams for account=%d", accountID)
|
||||
return []map[string]interface{}{
|
||||
{"id": settings.TeamID, "name": settings.TeamName},
|
||||
}, nil
|
||||
return nodesFromPath(data, "teams"), nil
|
||||
}
|
||||
|
||||
// GetTeamEntities retrieves entities from a Linear team.
|
||||
// GET /api/v1/accounts/:account_id/integrations/linear/team_entities
|
||||
func (s *LinearIntegrationService) GetTeamEntities(ctx context.Context, accountID uint) ([]map[string]interface{}, error) {
|
||||
hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear)
|
||||
if err != nil || len(hooks) == 0 {
|
||||
return nil, fmt.Errorf("Linear integration not found for account %d", accountID)
|
||||
func (s *LinearIntegrationService) GetTeamEntities(ctx context.Context, accountID uint, teamID string) (map[string]interface{}, error) {
|
||||
client, err := s.linearClient(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := client.teamEntities(ctx, teamID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
applogger.L().Infof("Listing Linear team entities for account=%d", accountID)
|
||||
return []map[string]interface{}{}, nil
|
||||
return map[string]interface{}{
|
||||
"users": nodesFromPath(data, "users"),
|
||||
"projects": nodesFromPath(data, "projects"),
|
||||
"states": nodesFromPath(data, "workflowStates"),
|
||||
"labels": nodesFromPath(data, "issueLabels"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateIssueRequest is the DTO for creating a Linear issue.
|
||||
type CreateIssueRequest struct {
|
||||
Title string `json:"title" validate:"required"`
|
||||
Description string `json:"description,omitempty"`
|
||||
TeamID string `json:"team_id,omitempty"`
|
||||
Title string `json:"title" form:"title"`
|
||||
Description string `json:"description,omitempty" form:"description"`
|
||||
TeamID string `json:"team_id,omitempty" form:"team_id"`
|
||||
ProjectID string `json:"project_id,omitempty" form:"project_id"`
|
||||
ConversationID uint `json:"conversation_id,omitempty" form:"conversation_id"`
|
||||
AssigneeID string `json:"assignee_id,omitempty" form:"assignee_id"`
|
||||
Priority interface{} `json:"priority,omitempty" form:"priority"`
|
||||
StateID string `json:"state_id,omitempty" form:"state_id"`
|
||||
LabelIDs []string `json:"label_ids,omitempty" form:"label_ids[]"`
|
||||
}
|
||||
|
||||
// CreateIssue creates a Linear issue from a conversation.
|
||||
// POST /api/v1/accounts/:account_id/integrations/linear/create_issue
|
||||
func (s *LinearIntegrationService) CreateIssue(ctx context.Context, accountID uint, req CreateIssueRequest) (map[string]interface{}, error) {
|
||||
hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear)
|
||||
if err != nil || len(hooks) == 0 {
|
||||
return nil, fmt.Errorf("Linear integration not found for account %d", accountID)
|
||||
func (s *LinearIntegrationService) CreateIssue(ctx context.Context, accountID uint, req CreateIssueRequest, userID ...uint) (map[string]interface{}, error) {
|
||||
conversation, err := s.findConversation(ctx, accountID, req.ConversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := s.linearClient(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := s.findUser(ctx, optionalUint(userID))
|
||||
data, err := client.createIssue(ctx, req, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
issue := map[string]interface{}{
|
||||
"id": nestedString(data, "issueCreate", "issue", "id"),
|
||||
"title": nestedString(data, "issueCreate", "issue", "title"),
|
||||
"identifier": nestedString(data, "issueCreate", "issue", "identifier"),
|
||||
}
|
||||
s.createLinearActivity(ctx, conversation, user, "created", fmt.Sprint(issue["identifier"]))
|
||||
applogger.L().Infof("Creating Linear issue for account=%d: title=%s", accountID, req.Title)
|
||||
// In production, this would call the Linear API to create an issue.
|
||||
return map[string]interface{}{
|
||||
"status": "created",
|
||||
"title": req.Title,
|
||||
"team_id": req.TeamID,
|
||||
}, nil
|
||||
return issue, nil
|
||||
}
|
||||
|
||||
// LinkIssueRequest is the DTO for linking a Linear issue to a conversation.
|
||||
type LinkIssueRequest struct {
|
||||
IssueID string `json:"issue_id" validate:"required"`
|
||||
ConversationID uint `json:"conversation_id,omitempty"`
|
||||
IssueID string `json:"issue_id" form:"issue_id"`
|
||||
ConversationID uint `json:"conversation_id,omitempty" form:"conversation_id"`
|
||||
Title string `json:"title,omitempty" form:"title"`
|
||||
}
|
||||
|
||||
// LinkIssue links a Linear issue to a conversation.
|
||||
// POST /api/v1/accounts/:account_id/integrations/linear/link_issue
|
||||
func (s *LinearIntegrationService) LinkIssue(ctx context.Context, accountID uint, req LinkIssueRequest) (map[string]interface{}, error) {
|
||||
hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear)
|
||||
if err != nil || len(hooks) == 0 {
|
||||
return nil, fmt.Errorf("Linear integration not found for account %d", accountID)
|
||||
func (s *LinearIntegrationService) LinkIssue(ctx context.Context, accountID uint, req LinkIssueRequest, userID ...uint) (map[string]interface{}, error) {
|
||||
conversation, err := s.findConversation(ctx, accountID, req.ConversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := s.linearClient(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := s.findUser(ctx, optionalUint(userID))
|
||||
link := linearConversationLink(accountID, conversation)
|
||||
data, err := client.linkIssue(ctx, link, req.IssueID, req.Title, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"id": req.IssueID,
|
||||
"link": link,
|
||||
"link_id": nestedString(data, "attachmentLinkURL", "attachment", "id"),
|
||||
}
|
||||
s.createLinearActivity(ctx, conversation, user, "linked", req.IssueID)
|
||||
applogger.L().Infof("Linking Linear issue %s for account=%d", req.IssueID, accountID)
|
||||
return map[string]interface{}{
|
||||
"status": "linked",
|
||||
"issue_id": req.IssueID,
|
||||
}, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UnlinkIssueRequest is the DTO for unlinking a Linear issue from a conversation.
|
||||
type UnlinkIssueRequest struct {
|
||||
IssueID string `json:"issue_id" validate:"required"`
|
||||
ConversationID uint `json:"conversation_id,omitempty"`
|
||||
IssueID string `json:"issue_id" form:"issue_id"`
|
||||
LinkID string `json:"link_id" form:"link_id"`
|
||||
ConversationID uint `json:"conversation_id,omitempty" form:"conversation_id"`
|
||||
}
|
||||
|
||||
// UnlinkIssue unlinks a Linear issue from a conversation.
|
||||
// POST /api/v1/accounts/:account_id/integrations/linear/unlink_issue
|
||||
func (s *LinearIntegrationService) UnlinkIssue(ctx context.Context, accountID uint, req UnlinkIssueRequest) (map[string]interface{}, error) {
|
||||
hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear)
|
||||
if err != nil || len(hooks) == 0 {
|
||||
return nil, fmt.Errorf("Linear integration not found for account %d", accountID)
|
||||
func (s *LinearIntegrationService) UnlinkIssue(ctx context.Context, accountID uint, req UnlinkIssueRequest, userID ...uint) (map[string]interface{}, error) {
|
||||
conversation, err := s.findConversation(ctx, accountID, req.ConversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := s.linearClient(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := client.unlinkIssue(ctx, req.LinkID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user := s.findUser(ctx, optionalUint(userID))
|
||||
s.createLinearActivity(ctx, conversation, user, "unlinked", req.IssueID)
|
||||
applogger.L().Infof("Unlinking Linear issue %s for account=%d", req.IssueID, accountID)
|
||||
return map[string]interface{}{
|
||||
"status": "unlinked",
|
||||
"issue_id": req.IssueID,
|
||||
"link_id": req.LinkID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SearchIssue searches Linear issues.
|
||||
// GET /api/v1/accounts/:account_id/integrations/linear/search_issue
|
||||
func (s *LinearIntegrationService) SearchIssue(ctx context.Context, accountID uint, query string) ([]map[string]interface{}, error) {
|
||||
hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear)
|
||||
if err != nil || len(hooks) == 0 {
|
||||
return nil, fmt.Errorf("Linear integration not found for account %d", accountID)
|
||||
client, err := s.linearClient(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := client.searchIssue(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
applogger.L().Infof("Searching Linear issues for account=%d, query=%s", accountID, query)
|
||||
return []map[string]interface{}{}, nil
|
||||
return nodesFromPath(data, "searchIssues"), nil
|
||||
}
|
||||
|
||||
// GetLinkedIssues retrieves Linear issues linked to a conversation.
|
||||
// GET /api/v1/accounts/:account_id/integrations/linear/linked_issues
|
||||
func (s *LinearIntegrationService) GetLinkedIssues(ctx context.Context, accountID uint, conversationID uint) ([]map[string]interface{}, error) {
|
||||
hooks, err := s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear)
|
||||
conversation, err := s.findConversation(ctx, accountID, conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := s.linearClient(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := client.linkedIssues(ctx, linearConversationLink(accountID, conversation))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applogger.L().Infof("Listing linked Linear issues for account=%d, conversation=%d", accountID, conversationID)
|
||||
return nodesFromPath(data, "attachmentsForURL"), nil
|
||||
}
|
||||
|
||||
func (s *LinearIntegrationService) findLinearHooks(ctx context.Context, accountID uint) ([]model.IntegrationHook, error) {
|
||||
hooks, err := s.hookRepo.FindByAccountAndApp(ctx, accountID, "linear")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(hooks) > 0 {
|
||||
return hooks, nil
|
||||
}
|
||||
return s.hookRepo.FindByAccountAndType(ctx, accountID, model.HookTypeLinear)
|
||||
}
|
||||
|
||||
func (s *LinearIntegrationService) linearClient(ctx context.Context, accountID uint) (*linearBoundClient, error) {
|
||||
hooks, err := s.findLinearHooks(ctx, accountID)
|
||||
if err != nil || len(hooks) == 0 {
|
||||
return nil, fmt.Errorf("Linear integration not found for account %d", accountID)
|
||||
}
|
||||
token := strings.TrimSpace(hooks[0].AccessToken)
|
||||
if token == "" {
|
||||
var settings model.LinearSettings
|
||||
_ = json.Unmarshal(hooks[0].Settings, &settings)
|
||||
token = strings.TrimSpace(settings.AccessToken)
|
||||
}
|
||||
if token == "" {
|
||||
return nil, &LinearProviderError{Message: "Missing Credentials"}
|
||||
}
|
||||
return &linearBoundClient{client: s.client, token: token}, nil
|
||||
}
|
||||
|
||||
applogger.L().Infof("Listing linked Linear issues for account=%d, conversation=%d", accountID, conversationID)
|
||||
return []map[string]interface{}{}, nil
|
||||
func (s *LinearIntegrationService) findConversation(ctx context.Context, accountID, displayID uint) (*model.Conversation, error) {
|
||||
if s.conversationRepo == nil || displayID == 0 {
|
||||
return nil, fmt.Errorf("conversation not found")
|
||||
}
|
||||
return s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, displayID)
|
||||
}
|
||||
|
||||
func (s *LinearIntegrationService) findUser(ctx context.Context, userID uint) *model.User {
|
||||
if s.userRepo == nil || userID == 0 {
|
||||
return nil
|
||||
}
|
||||
user, err := s.userRepo.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func (s *LinearIntegrationService) createLinearActivity(ctx context.Context, conversation *model.Conversation, user *model.User, action, issueID string) {
|
||||
if s.messageRepo == nil || conversation == nil || user == nil || issueID == "" {
|
||||
return
|
||||
}
|
||||
content := fmt.Sprintf("Linear issue %s was %s by %s", issueID, action, user.Name)
|
||||
message := &model.Message{
|
||||
ConversationID: conversation.ID,
|
||||
AccountID: conversation.AccountID,
|
||||
InboxID: conversation.InboxID,
|
||||
MessageType: "activity",
|
||||
ContentType: "text",
|
||||
Status: "sent",
|
||||
Content: content,
|
||||
}
|
||||
if err := s.messageRepo.Create(ctx, message); err != nil {
|
||||
applogger.L().Warnf("failed to create Linear activity message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func linearConversationLink(accountID uint, conversation *model.Conversation) string {
|
||||
displayID := conversation.ID
|
||||
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
|
||||
displayID = *conversation.DisplayID
|
||||
}
|
||||
base := strings.TrimRight(os.Getenv("FRONTEND_URL"), "/")
|
||||
return fmt.Sprintf("%s/app/accounts/%d/conversations/%d", base, accountID, displayID)
|
||||
}
|
||||
|
||||
func optionalUint(values []uint) uint {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
func linearRefreshToken(settingsJSON []byte) string {
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(settingsJSON, &settings); err != nil {
|
||||
return ""
|
||||
}
|
||||
if token, ok := settings["refresh_token"].(string); ok {
|
||||
return token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type linearAPIClient struct {
|
||||
graphqlURL string
|
||||
revokeURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type linearBoundClient struct {
|
||||
client *linearAPIClient
|
||||
token string
|
||||
}
|
||||
|
||||
func newLinearAPIClientFromEnv() *linearAPIClient {
|
||||
baseURL := strings.TrimRight(os.Getenv("LINEAR_API_BASE"), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.linear.app"
|
||||
}
|
||||
return &linearAPIClient{
|
||||
graphqlURL: baseURL + "/graphql",
|
||||
revokeURL: baseURL + "/oauth/revoke",
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *linearBoundClient) teams(ctx context.Context) (map[string]interface{}, error) {
|
||||
return c.postGraphQL(ctx, `query { teams { nodes { id name } } }`)
|
||||
}
|
||||
|
||||
func (c *linearBoundClient) teamEntities(ctx context.Context, teamID string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(teamID) == "" {
|
||||
return nil, &LinearProviderError{Message: "Missing team id"}
|
||||
}
|
||||
query := fmt.Sprintf(`query { users { nodes { id name } } projects { nodes { id name } } workflowStates(filter: { team: { id: { eq: %s } } }) { nodes { id name } } issueLabels(filter: { team: { id: { eq: %s } } }) { nodes { id name } } }`, graphqlValue(teamID), graphqlValue(teamID))
|
||||
return c.postGraphQL(ctx, query)
|
||||
}
|
||||
|
||||
func (c *linearBoundClient) createIssue(ctx context.Context, req CreateIssueRequest, user *model.User) (map[string]interface{}, error) {
|
||||
input := map[string]interface{}{
|
||||
"title": req.Title,
|
||||
"teamId": req.TeamID,
|
||||
"description": req.Description,
|
||||
"assigneeId": req.AssigneeID,
|
||||
"priority": req.Priority,
|
||||
"labelIds": req.LabelIDs,
|
||||
"projectId": req.ProjectID,
|
||||
"stateId": req.StateID,
|
||||
}
|
||||
if user != nil && user.Name != "" {
|
||||
input["createAsUser"] = user.Name
|
||||
if user.AvatarURL != "" {
|
||||
input["displayIconUrl"] = user.AvatarURL
|
||||
}
|
||||
}
|
||||
query := fmt.Sprintf(`mutation { issueCreate(input: { %s }) { success issue { id title identifier } } }`, graphqlInput(input))
|
||||
return c.postGraphQL(ctx, query)
|
||||
}
|
||||
|
||||
func (c *linearBoundClient) linkIssue(ctx context.Context, link, issueID, title string, user *model.User) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(link) == "" {
|
||||
return nil, &LinearProviderError{Message: "Missing link"}
|
||||
}
|
||||
if strings.TrimSpace(issueID) == "" {
|
||||
return nil, &LinearProviderError{Message: "Missing issue id"}
|
||||
}
|
||||
parts := []string{
|
||||
"url: " + graphqlValue(link),
|
||||
"issueId: " + graphqlValue(issueID),
|
||||
"title: " + graphqlValue(title),
|
||||
}
|
||||
if user != nil && user.Name != "" {
|
||||
parts = append(parts, "createAsUser: "+graphqlValue(user.Name))
|
||||
if user.AvatarURL != "" {
|
||||
parts = append(parts, "displayIconUrl: "+graphqlValue(user.AvatarURL))
|
||||
}
|
||||
}
|
||||
query := fmt.Sprintf(`mutation { attachmentLinkURL(%s) { success attachment { id } } }`, strings.Join(parts, ", "))
|
||||
return c.postGraphQL(ctx, query)
|
||||
}
|
||||
|
||||
func (c *linearBoundClient) unlinkIssue(ctx context.Context, linkID string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(linkID) == "" {
|
||||
return nil, &LinearProviderError{Message: "Missing link id"}
|
||||
}
|
||||
query := fmt.Sprintf(`mutation { attachmentDelete(id: %s) { success } }`, graphqlValue(linkID))
|
||||
return c.postGraphQL(ctx, query)
|
||||
}
|
||||
|
||||
func (c *linearBoundClient) searchIssue(ctx context.Context, term string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(term) == "" {
|
||||
return nil, &LinearProviderError{Message: "Missing search term"}
|
||||
}
|
||||
query := fmt.Sprintf(`query { searchIssues(term: %s) { nodes { id title description identifier url state { name color } } } }`, graphqlValue(term))
|
||||
return c.postGraphQL(ctx, query)
|
||||
}
|
||||
|
||||
func (c *linearBoundClient) linkedIssues(ctx context.Context, link string) (map[string]interface{}, error) {
|
||||
if strings.TrimSpace(link) == "" {
|
||||
return nil, &LinearProviderError{Message: "Missing link"}
|
||||
}
|
||||
query := fmt.Sprintf(`query { attachmentsForURL(url: %s) { nodes { id title issue { id identifier title description priority createdAt url assignee { name avatarUrl } state { name color } labels { nodes { id name color description } } } } } }`, graphqlValue(link))
|
||||
return c.postGraphQL(ctx, query)
|
||||
}
|
||||
|
||||
func (c *linearBoundClient) postGraphQL(ctx context.Context, query string) (map[string]interface{}, error) {
|
||||
return c.client.postGraphQL(ctx, c.token, query)
|
||||
}
|
||||
|
||||
func (c *linearAPIClient) postGraphQL(ctx context.Context, token, query string) (map[string]interface{}, error) {
|
||||
body, err := json.Marshal(map[string]string{"query": query})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.graphqlURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 || payload["data"] == nil {
|
||||
if len(payload) > 0 {
|
||||
return nil, &LinearProviderError{Message: payload}
|
||||
}
|
||||
return nil, &LinearProviderError{Message: strings.TrimSpace(string(raw))}
|
||||
}
|
||||
data, ok := payload["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, &LinearProviderError{Message: payload}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (c *linearAPIClient) revokeToken(ctx context.Context, accessToken, refreshToken string) error {
|
||||
token := strings.TrimSpace(refreshToken)
|
||||
tokenType := "refresh_token"
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(accessToken)
|
||||
tokenType = "access_token"
|
||||
}
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("token", token)
|
||||
form.Set("token_type_hint", tokenType)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.revokeURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return errors.New("linear revoke failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nodesFromPath(data map[string]interface{}, key string) []map[string]interface{} {
|
||||
container, _ := data[key].(map[string]interface{})
|
||||
rawNodes, _ := container["nodes"].([]interface{})
|
||||
nodes := make([]map[string]interface{}, 0, len(rawNodes))
|
||||
for _, raw := range rawNodes {
|
||||
if item, ok := raw.(map[string]interface{}); ok {
|
||||
nodes = append(nodes, item)
|
||||
}
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
func nestedString(data map[string]interface{}, keys ...string) string {
|
||||
var current interface{} = data
|
||||
for _, key := range keys {
|
||||
m, ok := current.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
current = m[key]
|
||||
}
|
||||
if s, ok := current.(string); ok {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func graphqlInput(input map[string]interface{}) string {
|
||||
ordered := []string{"title", "teamId", "description", "assigneeId", "priority", "labelIds", "projectId", "stateId", "createAsUser", "displayIconUrl"}
|
||||
parts := make([]string, 0, len(input))
|
||||
for _, key := range ordered {
|
||||
value, ok := input[key]
|
||||
if !ok || isBlankGraphQLValue(value) {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%s: %s", key, graphqlValue(value)))
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func isBlankGraphQLValue(value interface{}) bool {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case string:
|
||||
return v == ""
|
||||
case []string:
|
||||
return len(v) == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func graphqlValue(value interface{}) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
case []string:
|
||||
items := make([]string, 0, len(v))
|
||||
for _, item := range v {
|
||||
items = append(items, graphqlValue(item))
|
||||
}
|
||||
return "[" + strings.Join(items, ", ") + "]"
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Notion Integration Service ----
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -29,6 +33,11 @@ func setupLinearNotionTestDB(t *testing.T) *gorm.DB {
|
||||
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&model.Account{},
|
||||
&model.User{},
|
||||
&model.Contact{},
|
||||
&model.Inbox{},
|
||||
&model.Conversation{},
|
||||
&model.Message{},
|
||||
&model.IntegrationHook{},
|
||||
), "failed to auto-migrate models")
|
||||
|
||||
@@ -59,6 +68,7 @@ func seedLinearHook(db *gorm.DB, t *testing.T, accountID uint) uint {
|
||||
|
||||
hook := &model.IntegrationHook{
|
||||
AccountID: accountID,
|
||||
AppID: "linear",
|
||||
HookType: model.HookTypeLinear,
|
||||
Status: model.HookStatusActive,
|
||||
AccessToken: "lin_token_123",
|
||||
@@ -68,6 +78,67 @@ func seedLinearHook(db *gorm.DB, t *testing.T, accountID uint) uint {
|
||||
return hook.ID
|
||||
}
|
||||
|
||||
func seedLinearConversation(db *gorm.DB, t *testing.T, accountID uint) *model.Conversation {
|
||||
t.Helper()
|
||||
inbox := &model.Inbox{AccountID: accountID, Name: "Support", ChannelType: "web_widget"}
|
||||
require.NoError(t, db.Create(inbox).Error)
|
||||
contact := &model.Contact{AccountID: accountID, Name: "Jane"}
|
||||
require.NoError(t, db.Create(contact).Error)
|
||||
displayID := uint(42)
|
||||
conversation := &model.Conversation{AccountID: accountID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, ChannelType: "web_widget", Channel: "web_widget"}
|
||||
require.NoError(t, db.Create(conversation).Error)
|
||||
return conversation
|
||||
}
|
||||
|
||||
func seedLinearUser(db *gorm.DB, t *testing.T, accountID uint) *model.User {
|
||||
t.Helper()
|
||||
user := &model.User{AccountID: accountID, Name: "Agent Smith", Email: "agent@example.test", Password: "secret"}
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
return user
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
||||
|
||||
func fakeLinearHTTPClient(t *testing.T) *http.Client {
|
||||
t.Helper()
|
||||
return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/oauth/revoke" {
|
||||
return fakeHTTPResponse(http.StatusOK, `{}`), nil
|
||||
}
|
||||
var payload map[string]string
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&payload))
|
||||
query := payload["query"]
|
||||
switch {
|
||||
case strings.Contains(query, "teams") && !strings.Contains(query, "workflowStates"):
|
||||
return fakeHTTPResponse(http.StatusOK, `{"data":{"teams":{"nodes":[{"id":"team-1","name":"Engineering"}]}}}`), nil
|
||||
case strings.Contains(query, "workflowStates"):
|
||||
return fakeHTTPResponse(http.StatusOK, `{"data":{"users":{"nodes":[{"id":"user-1","name":"User One"}]},"projects":{"nodes":[{"id":"project-1","name":"Project One"}]},"workflowStates":{"nodes":[{"id":"state-1","name":"Started"}]},"issueLabels":{"nodes":[{"id":"label-1","name":"Bug"}]}}}`), nil
|
||||
case strings.Contains(query, "issueCreate"):
|
||||
return fakeHTTPResponse(http.StatusOK, `{"data":{"issueCreate":{"issue":{"id":"issue-1","title":"Bug in login flow","identifier":"ENG-123"}}}}`), nil
|
||||
case strings.Contains(query, "attachmentLinkURL"):
|
||||
return fakeHTTPResponse(http.StatusOK, `{"data":{"attachmentLinkURL":{"attachment":{"id":"attachment-1"}}}}`), nil
|
||||
case strings.Contains(query, "attachmentDelete"):
|
||||
return fakeHTTPResponse(http.StatusOK, `{"data":{"attachmentDelete":{"success":true}}}`), nil
|
||||
case strings.Contains(query, "searchIssues"):
|
||||
return fakeHTTPResponse(http.StatusOK, `{"data":{"searchIssues":{"nodes":[{"id":"issue-1","title":"Sample Issue","identifier":"ENG-123"}]}}}`), nil
|
||||
case strings.Contains(query, "attachmentsForURL"):
|
||||
return fakeHTTPResponse(http.StatusOK, `{"data":{"attachmentsForURL":{"nodes":[{"id":"attachment-1","title":"Sample Issue","issue":{"id":"issue-1","identifier":"ENG-123"}}]}}}`), nil
|
||||
default:
|
||||
return fakeHTTPResponse(http.StatusUnprocessableEntity, `{"errors":[{"message":"unknown query"}]}`), nil
|
||||
}
|
||||
})}
|
||||
}
|
||||
|
||||
func fakeHTTPResponse(status int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(body)),
|
||||
}
|
||||
}
|
||||
|
||||
func seedNotionHook(db *gorm.DB, t *testing.T, accountID uint) uint {
|
||||
t.Helper()
|
||||
settings := model.NotionSettings{
|
||||
@@ -93,6 +164,7 @@ func setupLinearService(t *testing.T) (*LinearIntegrationService, *gorm.DB) {
|
||||
db := setupLinearNotionTestDB(t)
|
||||
hookRepo := repository.NewIntegrationHookRepo(db)
|
||||
svc := NewLinearIntegrationService(hookRepo)
|
||||
svc.client = &linearAPIClient{graphqlURL: "https://linear.example.test/graphql", revokeURL: "https://linear.example.test/oauth/revoke", httpClient: fakeLinearHTTPClient(t)}
|
||||
return svc, db
|
||||
}
|
||||
|
||||
@@ -155,31 +227,54 @@ func TestLinearIntegrationService_GetTeams_NotFound(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "Linear integration not found")
|
||||
}
|
||||
|
||||
func TestLinearIntegrationService_CreateIssue_Success(t *testing.T) {
|
||||
func TestLinearIntegrationService_GetTeamEntities_Success(t *testing.T) {
|
||||
svc, db := setupLinearService(t)
|
||||
accountID := seedLinearNotionAccount(db, t)
|
||||
seedLinearHook(db, t, accountID)
|
||||
|
||||
entities, err := svc.GetTeamEntities(context.Background(), accountID, "team-1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "User One", entities["users"].([]map[string]interface{})[0]["name"])
|
||||
assert.Equal(t, "Project One", entities["projects"].([]map[string]interface{})[0]["name"])
|
||||
assert.Equal(t, "Started", entities["states"].([]map[string]interface{})[0]["name"])
|
||||
assert.Equal(t, "Bug", entities["labels"].([]map[string]interface{})[0]["name"])
|
||||
}
|
||||
|
||||
func TestLinearIntegrationService_CreateIssue_Success(t *testing.T) {
|
||||
svc, db := setupLinearService(t)
|
||||
accountID := seedLinearNotionAccount(db, t)
|
||||
seedLinearHook(db, t, accountID)
|
||||
conversation := seedLinearConversation(db, t, accountID)
|
||||
user := seedLinearUser(db, t, accountID)
|
||||
|
||||
req := CreateIssueRequest{
|
||||
Title: "Bug in login flow",
|
||||
Description: "Users cannot log in after password reset",
|
||||
TeamID: "team-1",
|
||||
Title: "Bug in login flow",
|
||||
Description: "Users cannot log in after password reset",
|
||||
TeamID: "team-1",
|
||||
ConversationID: *conversation.DisplayID,
|
||||
}
|
||||
|
||||
result, err := svc.CreateIssue(context.Background(), accountID, req)
|
||||
result, err := svc.CreateIssue(context.Background(), accountID, req, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "Bug in login flow", result["title"])
|
||||
assert.Equal(t, "ENG-123", result["identifier"])
|
||||
|
||||
var message model.Message
|
||||
require.NoError(t, db.Where("conversation_id = ? AND message_type = ?", conversation.ID, "activity").First(&message).Error)
|
||||
assert.Equal(t, "Linear issue ENG-123 was created by Agent Smith", message.Content)
|
||||
}
|
||||
|
||||
func TestLinearIntegrationService_CreateIssue_NotFound(t *testing.T) {
|
||||
svc, db := setupLinearService(t)
|
||||
accountID := seedLinearNotionAccount(db, t)
|
||||
conversation := seedLinearConversation(db, t, accountID)
|
||||
// No Linear hook seeded
|
||||
|
||||
req := CreateIssueRequest{
|
||||
Title: "Some issue",
|
||||
TeamID: "team-1",
|
||||
Title: "Some issue",
|
||||
TeamID: "team-1",
|
||||
ConversationID: *conversation.DisplayID,
|
||||
}
|
||||
|
||||
result, err := svc.CreateIssue(context.Background(), accountID, req)
|
||||
@@ -192,27 +287,37 @@ func TestLinearIntegrationService_LinkIssue_Success(t *testing.T) {
|
||||
svc, db := setupLinearService(t)
|
||||
accountID := seedLinearNotionAccount(db, t)
|
||||
seedLinearHook(db, t, accountID)
|
||||
conversation := seedLinearConversation(db, t, accountID)
|
||||
user := seedLinearUser(db, t, accountID)
|
||||
t.Setenv("FRONTEND_URL", "https://app.example.test")
|
||||
|
||||
req := LinkIssueRequest{
|
||||
IssueID: "LIN-42",
|
||||
ConversationID: 10,
|
||||
ConversationID: *conversation.DisplayID,
|
||||
Title: "Sample Issue",
|
||||
}
|
||||
|
||||
result, err := svc.LinkIssue(context.Background(), accountID, req)
|
||||
result, err := svc.LinkIssue(context.Background(), accountID, req, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, "linked", result["status"])
|
||||
assert.Equal(t, "LIN-42", result["issue_id"])
|
||||
assert.Equal(t, "LIN-42", result["id"])
|
||||
assert.Equal(t, "attachment-1", result["link_id"])
|
||||
assert.Equal(t, "https://app.example.test/app/accounts/1/conversations/42", result["link"])
|
||||
|
||||
var message model.Message
|
||||
require.NoError(t, db.Where("conversation_id = ? AND message_type = ?", conversation.ID, "activity").First(&message).Error)
|
||||
assert.Equal(t, "Linear issue LIN-42 was linked by Agent Smith", message.Content)
|
||||
}
|
||||
|
||||
func TestLinearIntegrationService_LinkIssue_NotFound(t *testing.T) {
|
||||
svc, db := setupLinearService(t)
|
||||
accountID := seedLinearNotionAccount(db, t)
|
||||
conversation := seedLinearConversation(db, t, accountID)
|
||||
// No Linear hook seeded
|
||||
|
||||
req := LinkIssueRequest{
|
||||
IssueID: "LIN-42",
|
||||
ConversationID: 10,
|
||||
ConversationID: *conversation.DisplayID,
|
||||
}
|
||||
|
||||
result, err := svc.LinkIssue(context.Background(), accountID, req)
|
||||
@@ -221,6 +326,46 @@ func TestLinearIntegrationService_LinkIssue_NotFound(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "Linear integration not found")
|
||||
}
|
||||
|
||||
func TestLinearIntegrationService_UnlinkIssue_Success(t *testing.T) {
|
||||
svc, db := setupLinearService(t)
|
||||
accountID := seedLinearNotionAccount(db, t)
|
||||
seedLinearHook(db, t, accountID)
|
||||
conversation := seedLinearConversation(db, t, accountID)
|
||||
user := seedLinearUser(db, t, accountID)
|
||||
|
||||
result, err := svc.UnlinkIssue(context.Background(), accountID, UnlinkIssueRequest{IssueID: "ENG-123", LinkID: "attachment-1", ConversationID: *conversation.DisplayID}, user.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "attachment-1", result["link_id"])
|
||||
|
||||
var message model.Message
|
||||
require.NoError(t, db.Where("conversation_id = ? AND message_type = ?", conversation.ID, "activity").First(&message).Error)
|
||||
assert.Equal(t, "Linear issue ENG-123 was unlinked by Agent Smith", message.Content)
|
||||
}
|
||||
|
||||
func TestLinearIntegrationService_SearchIssue_Success(t *testing.T) {
|
||||
svc, db := setupLinearService(t)
|
||||
accountID := seedLinearNotionAccount(db, t)
|
||||
seedLinearHook(db, t, accountID)
|
||||
|
||||
issues, err := svc.SearchIssue(context.Background(), accountID, "Sample")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, issues, 1)
|
||||
assert.Equal(t, "Sample Issue", issues[0]["title"])
|
||||
}
|
||||
|
||||
func TestLinearIntegrationService_GetLinkedIssues_UsesConversationDisplayID(t *testing.T) {
|
||||
svc, db := setupLinearService(t)
|
||||
accountID := seedLinearNotionAccount(db, t)
|
||||
seedLinearHook(db, t, accountID)
|
||||
conversation := seedLinearConversation(db, t, accountID)
|
||||
t.Setenv("FRONTEND_URL", "https://app.example.test")
|
||||
|
||||
issues, err := svc.GetLinkedIssues(context.Background(), accountID, *conversation.DisplayID)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, issues, 1)
|
||||
assert.Equal(t, "Sample Issue", issues[0]["title"])
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// NotionIntegrationService tests
|
||||
// ========================================
|
||||
@@ -247,4 +392,4 @@ func TestNotionIntegrationService_Delete_NotFound(t *testing.T) {
|
||||
err := svc.Delete(context.Background(), accountID)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Notion integration not found")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user