From dcddca8a215767405de166673d1b0b5e94af4df2 Mon Sep 17 00:00:00 2001 From: Shoaibdev7 Date: Sat, 15 Feb 2025 20:16:21 +0500 Subject: [PATCH] feat: Add send route for ticket plan with enhanced Stakwork integration --- db/features.go | 14 ++ db/interface.go | 2 + db/structs.go | 5 + db/ticket_plan.go | 34 +++- handlers/ticket_plan_handler.go | 328 +++++++++++++++++++++++++++++++- mocks/Database.go | 101 ++++++++++ routes/ticket_routes.go | 1 + websocket/client.go | 14 ++ websocket/pool.go | 20 ++ 9 files changed, 517 insertions(+), 2 deletions(-) diff --git a/db/features.go b/db/features.go index f34a790e1..ebaf50618 100644 --- a/db/features.go +++ b/db/features.go @@ -401,6 +401,20 @@ func (db database) GetFeatureArchitecture(featureUuid string) (string, error) { return featureArchitecture, nil } +func (db database) GetPhaseDesign(phaseUUID string) (string, error) { + phase := FeaturePhase{} + result := db.db.Model(&FeaturePhase{}).Where("uuid = ?", phaseUUID).First(&phase) + if result.Error != nil { + return "", fmt.Errorf("error getting phase: %v", result.Error) + } + + phaseDesign := fmt.Sprintf("Phase: %s. Design: %s", + phase.Name, + phase.PhaseDesign) + + return phaseDesign, nil +} + func (db database) UpdateFeatureStatus(uuid string, status FeatureStatus) (WorkspaceFeatures, error) { var feature WorkspaceFeatures diff --git a/db/interface.go b/db/interface.go index 7cb2148de..5f326cbdb 100644 --- a/db/interface.go +++ b/db/interface.go @@ -205,6 +205,7 @@ type Database interface { GetProductBrief(workspaceUuid string) (string, error) GetFeatureBrief(featureUuid string) (string, error) GetFeatureArchitecture(featureUuid string) (string, error) + GetPhaseDesign(phaseUUID string) (string, error) GetTicketsByPhaseUUID(featureUUID string, phaseUUID string) ([]Tickets, error) AddChat(chat *Chat) (Chat, error) UpdateChat(chat *Chat) (Chat, error) @@ -305,5 +306,6 @@ type Database interface { GetLatestActivityByThread(threadID string) (*Activity, error) CreateActivityThread(sourceID string, activity *Activity) (*Activity, error) DeleteActivity(id string) error + BuildTicketArray(groupIDs []string) []TicketArrayItem GetBountiesByWorkspaceAndTimeRange(workspaceId string, startDate time.Time, endDate time.Time) ([]NewBounty, error) } diff --git a/db/structs.go b/db/structs.go index 4199013b3..b636e289f 100644 --- a/db/structs.go +++ b/db/structs.go @@ -1051,6 +1051,11 @@ type Tickets struct { UpdatedAt time.Time `gorm:"type:timestamp;default:current_timestamp" json:"updated_at"` } +type TicketArrayItem struct { + TicketName string `json:"ticket_name"` + TicketDescription string `json:"ticket_description"` +} + type BroadcastType string const ( diff --git a/db/ticket_plan.go b/db/ticket_plan.go index 2abd8008f..b270daf3d 100644 --- a/db/ticket_plan.go +++ b/db/ticket_plan.go @@ -97,4 +97,36 @@ func (db database) GetTicketPlansByWorkspace(workspaceUUID string) ([]TicketPlan return nil, fmt.Errorf("failed to fetch ticket plans by workspace: %w", err) } return plans, nil -} \ No newline at end of file +} + +func (db database) BuildTicketArray(groupIDs []string) []TicketArrayItem { + var ticketArray []TicketArrayItem + + for _, groupID := range groupIDs { + var tickets []Tickets + result := db.db.Where("ticket_group = ?", groupID).Find(&tickets) + if result.Error != nil { + continue + } + + var latestVersion int + var latestName, latestDescription string + + for _, ticket := range tickets { + if ticket.Version > latestVersion { + latestVersion = ticket.Version + latestName = ticket.Name + latestDescription = ticket.Description + } + } + + if latestVersion > 0 { + ticketArray = append(ticketArray, TicketArrayItem{ + TicketName: latestName, + TicketDescription: latestDescription, + }) + } + } + + return ticketArray +} diff --git a/handlers/ticket_plan_handler.go b/handlers/ticket_plan_handler.go index 8214c68c3..198987548 100644 --- a/handlers/ticket_plan_handler.go +++ b/handlers/ticket_plan_handler.go @@ -1,9 +1,13 @@ package handlers import ( + "bytes" "encoding/json" "fmt" + "io" + "log" "net/http" + "os" "github.com/go-chi/chi" "github.com/google/uuid" @@ -34,6 +38,21 @@ type TicketArrayItem struct { TicketDescription string `json:"ticket_description"` } +type SendTicketPlanRequest struct { + FeatureID string `json:"feature_id"` + PhaseID string `json:"phase_id"` + TicketGroupIDs []string `json:"ticket_group_ids"` + SourceWebsocket string `json:"source_websocket"` + RequestUUID string `json:"request_uuid"` +} + +type SendTicketPlanResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + RequestUUID string `json:"request_uuid"` + Errors []string `json:"errors,omitempty"` +} + func (th *ticketHandler) CreateTicketPlan(w http.ResponseWriter, r *http.Request) { ctx := r.Context() pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) @@ -284,4 +303,311 @@ func (th *ticketHandler) GetTicketPlansByWorkspace(w http.ResponseWriter, r *htt w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(plans) -} \ No newline at end of file +} + +func (th *ticketHandler) SendTicketPlanToStakwork(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + pubKeyFromAuth, _ := ctx.Value(auth.ContextKey).(string) + + if pubKeyFromAuth == "" { + logger.Log.Info("[ticket plan] no pubkey from auth") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "Unauthorized"}) + return + } + + user := th.db.GetPersonByPubkey(pubKeyFromAuth) + if user.OwnerPubKey != pubKeyFromAuth { + logger.Log.Info("Person not exists") + w.WriteHeader(http.StatusBadRequest) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Validation failed", + Errors: []string{"Error reading request body"}, + }) + return + } + defer r.Body.Close() + + var planRequest SendTicketPlanRequest + if err := json.Unmarshal(body, &planRequest); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Validation failed", + Errors: []string{"Error parsing request body: " + err.Error()}, + }) + return + } + + if planRequest.FeatureID == "" || planRequest.PhaseID == "" || len(planRequest.TicketGroupIDs) == 0 { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Validation failed", + Errors: []string{"feature_id, phase_id, and ticket_group_ids are required"}, + }) + return + } + + var ( + productBrief, featureBrief, phaseDesign, codeGraphURL, codeGraphAlias string + feature db.WorkspaceFeatures + ) + + feature = th.db.GetFeatureByUuid(planRequest.FeatureID) + if feature.Uuid == "" { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Error retrieving feature details", + Errors: []string{"Feature not found with the provided UUID"}, + }) + return + } + + productBrief, err = th.db.GetProductBrief(feature.WorkspaceUuid) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Error retrieving product brief", + Errors: []string{err.Error()}, + }) + return + } + + featureBrief, err = th.db.GetFeatureBrief(planRequest.FeatureID) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Error retrieving feature brief", + Errors: []string{err.Error()}, + }) + return + } + + phaseDesign, err = th.db.GetPhaseDesign(planRequest.PhaseID) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Error retrieving phase design", + Errors: []string{err.Error()}, + }) + return + } + + host := os.Getenv("HOST") + if host == "" { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "HOST environment variable not set", + }) + return + } + + webhookURL := fmt.Sprintf("%s/bounties/ticket-plan/review", host) + + var schematicURL string + if feature.WorkspaceUuid != "" { + workspace := th.db.GetWorkspaceByUuid(feature.WorkspaceUuid) + if workspace.Uuid == "" { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Workspace not found", + }) + return + } + + schematicURL = workspace.SchematicUrl + + codeGraph, err := th.db.GetCodeGraphByWorkspaceUuid(feature.WorkspaceUuid) + if err == nil { + codeGraphURL = codeGraph.Url + codeGraphAlias = codeGraph.SecretAlias + } else { + codeGraphURL = "" + codeGraphAlias = "" + } + } + + phase, err := th.db.GetPhaseByUuid(planRequest.PhaseID) + if err != nil { + w.WriteHeader(http.StatusNotFound) + return + } + + ticketArray := th.db.BuildTicketArray(planRequest.TicketGroupIDs) + + stakworkPayload := map[string]interface{}{ + "name": "Ticket Plan Builder", + "workflow_id": 42472, + "workflow_params": map[string]interface{}{ + "set_var": map[string]interface{}{ + "attributes": map[string]interface{}{ + "vars": map[string]interface{}{ + "featureUUID": planRequest.FeatureID, + "phaseUUID": planRequest.PhaseID, + "ticketPlanUUID": uuid.New().String(), + "phaseOutcome": phase.PhaseOutcome, + "phasePurpose": phase.PhasePurpose, + "phaseScope": phase.PhaseScope, + "phaseDesign": phaseDesign, + "ticketArray": ticketArray, + "productBrief": productBrief, + "featureBrief": featureBrief, + "sourceWebsocket": planRequest.SourceWebsocket, + "webhook_url": webhookURL, + "phaseSchematic": schematicURL, + "codeGraph": codeGraphURL, + "alias": user.OwnerAlias, + "requestUUID": planRequest.RequestUUID, + "codeGraphAlias": codeGraphAlias, + }, + }, + }, + }, + } + + stakworkPayloadJSON, err := json.Marshal(stakworkPayload) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Error encoding payload", + Errors: []string{err.Error()}, + }) + return + } + + apiKey := os.Getenv("SWWFKEY") + if apiKey == "" { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "API key not set in environment", + }) + return + } + + req, err := http.NewRequest(http.MethodPost, "https://api.stakwork.com/api/v1/projects", bytes.NewBuffer(stakworkPayloadJSON)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Error creating request", + Errors: []string{err.Error()}, + }) + return + } + + req.Header.Set("Authorization", "Token token="+apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := th.httpClient.Do(req) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Error sending request to Stakwork", + Errors: []string{err.Error()}, + }) + return + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Error reading response from Stakwork", + Errors: []string{err.Error()}, + }) + return + } + + var stakworkResp StakworkResponse + if err := json.Unmarshal(respBody, &stakworkResp); err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: "Error parsing Stakwork response", + Errors: []string{err.Error()}, + }) + return + } + + if resp.StatusCode != http.StatusOK || !stakworkResp.Success { + w.WriteHeader(resp.StatusCode) + json.NewEncoder(w).Encode(TicketPlanResponse{ + Success: false, + Message: string(respBody), + Errors: []string{fmt.Sprintf("Stakwork API returned status code: %d", resp.StatusCode)}, + }) + return + } + + if planRequest.SourceWebsocket != "" { + ticketMsg := websocket.TicketPlanMessage{ + BroadcastType: "direct", + SourceSessionID: planRequest.SourceWebsocket, + Message: "Processing ticket plan generation", + Action: "TICKET_PLAN_PROCESSING", + PlanDetails: websocket.TicketPlanDetails{ + RequestUUID: planRequest.RequestUUID, + FeatureUUID: planRequest.FeatureID, + PhaseUUID: planRequest.PhaseID, + }, + } + + if err := websocket.WebsocketPool.SendTicketPlanMessage(ticketMsg); err != nil { + log.Printf("Failed to send websocket message: %v", err) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "plan": planRequest, + "websocket_error": err.Error(), + }) + return + } + + projectMsg := websocket.TicketPlanMessage{ + BroadcastType: "direct", + SourceSessionID: planRequest.SourceWebsocket, + Message: fmt.Sprintf("https://jobs.stakwork.com/admin/projects/%d", stakworkResp.Data.ProjectID), + Action: "swrun", + PlanDetails: websocket.TicketPlanDetails{ + RequestUUID: planRequest.RequestUUID, + FeatureUUID: planRequest.FeatureID, + PhaseUUID: planRequest.PhaseID, + }, + } + + if err := websocket.WebsocketPool.SendTicketPlanMessage(projectMsg); err != nil { + log.Printf("Failed to send project ID websocket message: %v", err) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "plan": planRequest, + "websocket_error": err.Error(), + }) + return + } + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(SendTicketPlanResponse{ + Success: true, + Message: string(respBody), + RequestUUID: planRequest.RequestUUID, + }) +} diff --git a/mocks/Database.go b/mocks/Database.go index 510e7f39b..94ff418f9 100644 --- a/mocks/Database.go +++ b/mocks/Database.go @@ -15055,3 +15055,104 @@ func NewDatabase(t interface { return mock } + + +// GetPhaseDesign provides a mock function with given fields: phaseUUID +func (_m *Database) GetPhaseDesign(phaseUUID string) (string, error) { + ret := _m.Called(phaseUUID) + + if len(ret) == 0 { + panic("no return value specified for GetPhaseDesign") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(string) (string, error)); ok { + return rf(phaseUUID) + } + if rf, ok := ret.Get(0).(func(string) string); ok { + r0 = rf(phaseUUID) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(phaseUUID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type Database_GetPhaseDesign_Call struct { + *mock.Call +} + +func (_e *Database_Expecter) GetPhaseDesign(phaseUUID interface{}) *Database_GetPhaseDesign_Call { + return &Database_GetPhaseDesign_Call{Call: _e.mock.On("GetPhaseDesign", phaseUUID)} +} + +func (_c *Database_GetPhaseDesign_Call) Run(run func(phaseUUID string)) *Database_GetPhaseDesign_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(string)) + }) + return _c +} + +func (_c *Database_GetPhaseDesign_Call) Return(_a0 string, _a1 error) *Database_GetPhaseDesign_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Database_GetPhaseDesign_Call) RunAndReturn(run func(string) (string, error)) *Database_GetPhaseDesign_Call { + _c.Call.Return(run) + return _c +} + + + +// BuildTicketArray provides a mock function with given fields: groupIDs +func (_m *Database) BuildTicketArray(groupIDs []string) []db.TicketArrayItem { + ret := _m.Called(groupIDs) + + if len(ret) == 0 { + panic("no return value specified for BuildTicketArray") + } + + var r0 []db.TicketArrayItem + if rf, ok := ret.Get(0).(func([]string) []db.TicketArrayItem); ok { + r0 = rf(groupIDs) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]db.TicketArrayItem) + } + } + + return r0 +} + +type Database_BuildTicketArray_Call struct { + *mock.Call +} + +func (_e *Database_Expecter) BuildTicketArray(groupIDs interface{}) *Database_BuildTicketArray_Call { + return &Database_BuildTicketArray_Call{Call: _e.mock.On("BuildTicketArray", groupIDs)} +} + +func (_c *Database_BuildTicketArray_Call) Run(run func(groupIDs []string)) *Database_BuildTicketArray_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].([]string)) + }) + return _c +} + +func (_c *Database_BuildTicketArray_Call) Return(_a0 []db.TicketArrayItem) *Database_BuildTicketArray_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Database_BuildTicketArray_Call) RunAndReturn(run func([]string) []db.TicketArrayItem) *Database_BuildTicketArray_Call { + _c.Call.Return(run) + return _c +} \ No newline at end of file diff --git a/routes/ticket_routes.go b/routes/ticket_routes.go index a2e43388e..d6257f974 100644 --- a/routes/ticket_routes.go +++ b/routes/ticket_routes.go @@ -35,6 +35,7 @@ func TicketRoutes() chi.Router { r.Delete("/workspace/{workspace_uuid}/draft/{uuid}", ticketHandler.DeleteWorkspaceDraftTicket) r.Post("/plan", ticketHandler.CreateTicketPlan) + r.Post("/plan/send", ticketHandler.SendTicketPlanToStakwork) r.Get("/plan/{uuid}", ticketHandler.GetTicketPlan) r.Delete("/plan/{uuid}", ticketHandler.DeleteTicketPlan) r.Get("/plan/feature/{feature_uuid}", ticketHandler.GetTicketPlansByFeature) diff --git a/websocket/client.go b/websocket/client.go index c4cce3f51..2a0ecc5fa 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -44,6 +44,20 @@ type TicketData struct { TicketName string `json:"ticketName,omitempty"` } +type TicketPlanMessage struct { + BroadcastType string `json:"broadcast_type"` + SourceSessionID string `json:"source_session_id"` + Message string `json:"message"` + Action string `json:"action"` + PlanDetails TicketPlanDetails `json:"plan_details"` +} + +type TicketPlanDetails struct { + RequestUUID string `json:"request_uuid"` + FeatureUUID string `json:"feature_uuid"` + PhaseUUID string `json:"phase_uuid"` +} + func (c *Client) Read() { defer func() { // ceck to acoid nil pointer diff --git a/websocket/pool.go b/websocket/pool.go index a6a1f2b2f..8c393e812 100644 --- a/websocket/pool.go +++ b/websocket/pool.go @@ -88,3 +88,23 @@ func (pool *Pool) SendTicketMessage(message TicketMessage) error { return nil } + + +func (pool *Pool) SendTicketPlanMessage(message TicketPlanMessage) error { + if pool == nil { + return fmt.Errorf("pool is nil") + } + + if message.BroadcastType == "direct" { + if message.SourceSessionID == "" { + return fmt.Errorf("client not found") + } + + if client, ok := pool.Clients[message.SourceSessionID]; ok { + return client.Client.Conn.WriteJSON(message) + } + return fmt.Errorf("client not found: %s", message.SourceSessionID) + } + + return nil +} \ No newline at end of file