diff --git a/cron.go b/cron.go index 95718c6..383f9ba 100644 --- a/cron.go +++ b/cron.go @@ -4,9 +4,12 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" + "net/url" "os" "regexp" + "strconv" "strings" "time" @@ -208,3 +211,292 @@ func getStatusIcon(status string) string { return "📋" // Icône par défaut pour les statuts inconnus } } + +// syncEventsToSlack synchronise les événements du tracker vers Slack +// Cette fonction récupère les événements du jour sans source Slack et sans slackId +// puis poste les messages Slack correspondants et met à jour le slackId +func syncEventsToSlack() { + logger.Info("Starting sync events to Slack") + + // Récupérer les événements à synchroniser + events, err := fetchEventsToSync() + if err != nil { + logger.Error("Failed to fetch events to sync", slog.Any("error", err)) + return + } + + if len(events) == 0 { + logger.Debug("No events to sync") + return + } + + logger.Info("Found events to sync", slog.Int("count", len(events))) + + api := slack.New(botToken) + + // Traiter chaque événement + for _, event := range events { + err := syncEventToSlack(api, event) + if err != nil { + logger.Error("Failed to sync event", + slog.String("event_id", event.Metadata.Id), + slog.String("title", event.Title), + slog.Any("error", err)) + continue + } + logger.Info("Event synced successfully", + slog.String("event_id", event.Metadata.Id), + slog.String("title", event.Title)) + } + + logger.Info("Sync events to Slack completed") +} + +// EventToSync représente un événement à synchroniser +type EventToSync struct { + Attributes struct { + Message string `json:"message"` + Priority string `json:"priority"` + Service string `json:"service"` + Source string `json:"source"` + Status string `json:"status"` + Type string `json:"type"` // Type est une string dans l'API + Environment string `json:"environment"` + Impact bool `json:"impact"` + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` + Owner string `json:"owner"` + StakeHolders []string `json:"stakeHolders"` + Notifications []string `json:"notifications"` + } `json:"attributes"` + Links struct { + PullRequestLink string `json:"pullRequestLink"` + Ticket string `json:"ticket"` + } `json:"links"` + Metadata struct { + Id string `json:"id"` + SlackId string `json:"slackId"` + } `json:"metadata"` + Title string `json:"title"` +} + +// GetTypeAsInt convertit le type string en int +func (e *EventToSync) GetTypeAsInt() (int, error) { + typeMap := map[string]int{ + "deployment": 1, + "operation": 2, + "drift": 3, + "incident": 4, + "rpa_usage": 5, + } + + if typeInt, ok := typeMap[strings.ToLower(e.Attributes.Type)]; ok { + return typeInt, nil + } + + // Si c'est déjà un nombre en string, le convertir + if typeInt, err := strconv.Atoi(e.Attributes.Type); err == nil { + return typeInt, nil + } + + return 0, fmt.Errorf("unknown event type: %s", e.Attributes.Type) +} + +type EventsToSyncResponse struct { + Events []EventToSync `json:"events"` + TotalCount int `json:"totalcount"` +} + +// fetchEventsToSync récupère les événements du jour sans source Slack et sans slackId +func fetchEventsToSync() ([]EventToSync, error) { + // Calculer les dates de début et fin du jour (00:00:00 à 23:59:59) + location, err := time.LoadLocation(os.Getenv("TRACKER_TIMEZONE")) + if err != nil { + location = time.UTC + logger.Warn("Failed to load timezone, using UTC", slog.Any("error", err)) + } + + now := time.Now().In(location) + startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, location) + endOfDay := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 999999999, location) + + // Formater les dates en RFC3339 et encoder pour l'URL + startDate := startOfDay.Format(time.RFC3339) + endDate := endOfDay.Format(time.RFC3339) + + // Construire l'URL avec les paramètres de recherche encodés + baseURL := os.Getenv("TRACKER_HOST") + "/api/v1alpha1/events/search" + params := url.Values{} + params.Add("start_date", startDate) + params.Add("end_date", endDate) + fullURL := fmt.Sprintf("%s?%s", baseURL, params.Encode()) + + logger.Debug("Fetching events to sync", + slog.String("start_date", startDate), + slog.String("end_date", endDate), + slog.String("url", fullURL)) + + resp, err := http.Get(fullURL) + if err != nil { + return nil, fmt.Errorf("API call failed: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + logger.Error("Failed to close response body", slog.Any("error", err)) + } + }() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var response EventsToSyncResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + + // Filtrer les événements : source != "slack" ET slackId vide + var filteredEvents []EventToSync + for _, event := range response.Events { + if event.Attributes.Source != "slack" && event.Metadata.SlackId == "" { + filteredEvents = append(filteredEvents, event) + } + } + + logger.Debug("Events filtered for sync", + slog.Int("total", len(response.Events)), + slog.Int("to_sync", len(filteredEvents))) + + return filteredEvents, nil +} + +// syncEventToSlack poste un événement sur Slack et met à jour le slackId +func syncEventToSlack(api *slack.Client, event EventToSync) error { + // Convertir le type string en int + eventType, err := event.GetTypeAsInt() + if err != nil { + return fmt.Errorf("failed to get event type: %w", err) + } + + // Convertir l'événement en tracker pour utiliser les fonctions existantes + tracker := convertEventToTracker(event) + + // Déterminer le channel et les blocks selon le type + var channelID string + var blocks []slack.Block + + switch eventType { + case 1: // Deployment + channelID = os.Getenv("TRACKER_DEPLOYMENT_CHANNEL") + blocks = blockDeploymentMessage(tracker) + case 2: // Operation + channelID = os.Getenv("TRACKER_OPERATION_CHANNEL") + blocks = blockOperationMessage(tracker) + case 3: // Drift + channelID = os.Getenv("TRACKER_DRIFT_CHANNEL") + blocks = blockDriftMessage(tracker) + case 4: // Incident + channelID = os.Getenv("TRACKER_INCIDENT_CHANNEL") + blocks = blockIncidentMessage(tracker) + case 5: // RPA Usage + channelID = os.Getenv("TRACKER_RPA_USAGE_CHANNEL") + blocks = blockRPAUsageMessage(tracker) + default: + return fmt.Errorf("unknown event type: %d", eventType) + } + + // Poster le message sur Slack + _, slackTimestamp, err := api.PostMessage(channelID, + slack.MsgOptionBlocks(blocks...), + ) + if err != nil { + return fmt.Errorf("failed to post message: %w", err) + } + + logger.Info("Message posted to Slack", + slog.String("channel", channelID), + slog.String("timestamp", slackTimestamp)) + + // Mettre à jour le slackId dans le tracker + err = updateTrackerEventSlackId(event.Metadata.Id, slackTimestamp) + if err != nil { + return fmt.Errorf("failed to update slackId: %w", err) + } + + return nil +} + +// convertEventToTracker convertit un EventToSync en tracker +func convertEventToTracker(event EventToSync) tracker { + // Parser les dates + startDate, _ := time.Parse(time.RFC3339, event.Attributes.StartDate) + endDate, _ := time.Parse(time.RFC3339, event.Attributes.EndDate) + + // Convertir le type string en int + eventType, err := event.GetTypeAsInt() + if err != nil { + logger.Warn("Failed to convert event type, using default", + slog.String("type", event.Attributes.Type), + slog.Any("error", err)) + eventType = 1 // Default to deployment + } + + // Convertir impact bool en string + impact := "No" + if event.Attributes.Impact { + impact = "Yes" + } + + // Convertir notifications en release/support team + releaseTeam := "No" + supportTeam := "No" + for _, notif := range event.Attributes.Notifications { + if strings.EqualFold(notif, "release") { + releaseTeam = "Yes" + } + if strings.EqualFold(notif, "support") { + supportTeam = "Yes" + } + } + + return tracker{ + Type: eventType, + Datetime: startDate.Unix(), + Summary: event.Title, + Project: event.Attributes.Service, + Priority: event.Attributes.Priority, + Environment: mapEnvironmentToCode(event.Attributes.Environment), + Impact: impact, + Ticket: event.Links.Ticket, + PullRequest: event.Links.PullRequestLink, + Description: event.Attributes.Message, + Owner: event.Attributes.Owner, + Stakeholders: event.Attributes.StakeHolders, + EndDate: endDate.Unix(), + ReleaseTeam: releaseTeam, + SupportTeam: supportTeam, + SlackId: event.Metadata.SlackId, + } +} + +// mapEnvironmentToCode convertit le nom d'environnement en code +func mapEnvironmentToCode(env string) string { + switch env { + case "production": + return "PROD" + case "preproduction": + return "PREP" + case "UAT": + return "UAT" + case "development": + return "DEV" + default: + return "PROD" + } +} diff --git a/main.go b/main.go index b342bd6..0946c03 100644 --- a/main.go +++ b/main.go @@ -50,6 +50,17 @@ func run() (err error) { logger.Warn("Could not schedule cache refresh task", slog.Any("error", err)) } + // Add task for syncing events to Slack (every 5 minutes) + _, err = c.AddFunc("* * * * *", func() { + logger.Debug("Starting sync events to Slack cron") + syncEventsToSlack() + }) + if err != nil { + logger.Error("Error adding sync events task", slog.Any("error", err)) + return err + } + logger.Info("Sync events to Slack task scheduled (every 5 minutes)") + // Start Task c.Start() logger.Info("Task planner started") diff --git a/slack.go b/slack.go index e50e15f..71c38a9 100644 --- a/slack.go +++ b/slack.go @@ -436,7 +436,7 @@ func handleEditRPAUsageModal(w http.ResponseWriter, i slack.InteractionCallback) // Post tracker event tracker.SlackId = string(messageTimestamp) - tracker.Type = 2 + tracker.Type = 5 // Type RPA Usage go editTrackerEvent(tracker) // Post changelog entry to Tracker @@ -591,7 +591,7 @@ func handleCreateRPAUsageModal(w http.ResponseWriter, i slack.InteractionCallbac // Post tracker event tracker.SlackId = string(slackTimestamp) - tracker.Type = 2 + tracker.Type = 5 // Type RPA Usage tracker.Datetime = time.Now().Unix() go postTrackerEvent(tracker) @@ -764,18 +764,24 @@ func handleBlockActions(w http.ResponseWriter, callback slack.InteractionCallbac switch action.SelectedOption.Value { case "in_progress": event := getTrackerEvent(callback.Message.Timestamp) - // Update Tracker status to in_progress as well - updateTrackerEvent(event.Event, 12, 1) + // Determine event type from the event + eventType := 1 // default to deployment + switch event.Event.Attributes.Type { + case "deployment": + eventType = 1 + case "operation": + eventType = 2 + case "drift": + eventType = 3 + case "incident": + eventType = 4 + } + // Update Tracker status to in_progress (12) + updateTrackerEvent(event.Event, 12, eventType) postThreadAction("in_progress", callback.Channel.ID, callback.Message.Timestamp, callback.User.Name) go postTrackerChangeLog(event.Event, "in_progress", "", callback.User.Name) w.WriteHeader(http.StatusOK) - case "drift_in_progress": - event := getTrackerEvent(callback.Message.Timestamp) - postThreadAction("drift_in_progress", callback.Channel.ID, callback.Message.Timestamp, callback.User.Name) - go postTrackerChangeLog(event.Event, "drift_in_progress", "", callback.User.Name) - w.WriteHeader(http.StatusOK) - case "pause": event := getTrackerEvent(callback.Message.Timestamp) postThreadAction("pause", callback.Channel.ID, callback.Message.Timestamp, callback.User.Name) @@ -784,7 +790,20 @@ func handleBlockActions(w http.ResponseWriter, callback slack.InteractionCallbac case "cancelled": event := getTrackerEvent(callback.Message.Timestamp) - updateTrackerEvent(event.Event, 2, 1) + // Determine event type + eventType := 1 + switch event.Event.Attributes.Type { + case "deployment": + eventType = 1 + case "operation": + eventType = 2 + case "drift": + eventType = 3 + case "incident": + eventType = 4 + } + // Update Tracker status to failure (2) + updateTrackerEvent(event.Event, 2, eventType) postThreadAction("cancelled", callback.Channel.ID, callback.Message.Timestamp, callback.User.Name) go postTrackerChangeLog(event.Event, "cancelled", "", callback.User.Name) w.WriteHeader(http.StatusOK) @@ -797,14 +816,44 @@ func handleBlockActions(w http.ResponseWriter, callback slack.InteractionCallbac case "done": event := getTrackerEvent(callback.Message.Timestamp) - updateTrackerEvent(event.Event, 3, 1) + // Determine event type + eventType := 1 + switch event.Event.Attributes.Type { + case "deployment": + eventType = 1 + case "operation": + eventType = 2 + case "drift": + eventType = 3 + case "incident": + eventType = 4 + } + // Update Tracker status to done (11) for drift/incident or success (3) for deployment/operation/rpa + status := 11 + if eventType == 1 || eventType == 2 { + status = 3 // success for deployment/operation/rpa + } + updateTrackerEvent(event.Event, status, eventType) postThreadAction("done", callback.Channel.ID, callback.Message.Timestamp, callback.User.Name) go postTrackerChangeLog(event.Event, "done", "", callback.User.Name) w.WriteHeader(http.StatusOK) case "close": event := getTrackerEvent(callback.Message.Timestamp) - updateTrackerEvent(event.Event, 10, 3) + // Determine event type + eventType := 3 + switch event.Event.Attributes.Type { + case "deployment": + eventType = 1 + case "operation": + eventType = 2 + case "drift": + eventType = 3 + case "incident": + eventType = 4 + } + // Update Tracker status to close (10) + updateTrackerEvent(event.Event, 10, eventType) postThreadAction("close", callback.Channel.ID, callback.Message.Timestamp, callback.User.Name) go postTrackerChangeLog(event.Event, "close", "", callback.User.Name) w.WriteHeader(http.StatusOK) @@ -830,16 +879,11 @@ func postTrackerChangeLog(event EventReponse, action string, note string, user s changeType = "approved" case "rejected": changeType = "rejected" - case "in_progress", "drift_in_progress", "done", "close", "pause", "post_poned", "cancelled": + case "in_progress", "done", "close", "pause", "post_poned", "cancelled": changeType = "status_changed" field = "status" oldValue = event.Attributes.Status - switch action { - case "drift_in_progress": - newValue = "in_progress" - default: - newValue = action - } + newValue = action } entry := map[string]interface{}{ @@ -908,9 +952,6 @@ func postThreadAction(action string, channelID string, messageTs string, user st case "in_progress": message = fmt.Sprintf(":loading: In progress by <@%s>", user) reaction = "loading" - case "drift_in_progress": - message = fmt.Sprintf(":warning: Drift In progress by <@%s>", user) - reaction = "warning" case "pause": message = fmt.Sprintf(":double_vertical_bar: Paused by <@%s>", user) reaction = "double_vertical_bar" @@ -984,6 +1025,7 @@ func messageReaction(api *slack.Client, channelID string, messageTs string, reac } type Payload struct { + Id string `json:"id,omitempty"` // ID de l'événement pour les mises à jour Attributes struct { Message string `json:"message"` Priority int `json:"priority"` @@ -1064,7 +1106,24 @@ func postTrackerEvent(tracker tracker) { data.Attributes.Priority = getPriorityWithDefault(tracker.Priority) data.Attributes.Service = tracker.Project data.Attributes.Source = "slack" - data.Attributes.Status = 1 + + // Set status based on event type + // Deployment (1), Operation (2), RPA Usage (2) -> planned (13) + // Drift (3) -> open (9) + // Incident (4) -> open (9) + switch tracker.Type { + case 1: // Deployment + data.Attributes.Status = 13 // planned + case 2: // Operation or RPA Usage + data.Attributes.Status = 13 // planned + case 3: // Drift + data.Attributes.Status = 9 // open + case 4: // Incident + data.Attributes.Status = 9 // open + default: + data.Attributes.Status = 1 // default + } + data.Attributes.Type = tracker.Type data.Attributes.Environment = environment[tracker.Environment] if tracker.Impact == "Yes" { @@ -1314,3 +1373,57 @@ func handleOptionLoadEndpoint(w http.ResponseWriter, r *http.Request) { fmt.Printf("Error encoding option load response: %v\n", err) } } + +// updateTrackerEventSlackId met à jour le slackId d'un événement dans le tracker +func updateTrackerEventSlackId(eventId string, slackId string) error { + if eventId == "" { + return fmt.Errorf("eventId is required") + } + if slackId == "" { + return fmt.Errorf("slackId is required") + } + + // Créer le payload avec le slackId + payload := map[string]string{ + "slack_id": slackId, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + // Construire l'URL de l'API avec le nouvel endpoint + urlStr := fmt.Sprintf("%s/api/v1alpha1/event/%s/slack", os.Getenv("TRACKER_HOST"), eventId) + + // Créer la requête POST + req, err := http.NewRequest("POST", urlStr, bytes.NewReader(payloadBytes)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + // Exécuter la requête + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + logger.Error("Failed to close response body", slog.Any("error", err)) + } + }() + + // Vérifier le statut de la réponse + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + logger.Debug("SlackId updated successfully", + slog.String("event_id", eventId), + slog.String("slack_id", slackId)) + + return nil +} diff --git a/slack_modal.go b/slack_modal.go index 670947b..9cb02ab 100644 --- a/slack_modal.go +++ b/slack_modal.go @@ -404,8 +404,8 @@ func blockDriftMessage(tracker tracker) []slack.Block { "static_select", slack.NewTextBlockObject("plain_text", "Select status", true, false), "action", - slack.NewOptionBlockObject("drift_in_progress", slack.NewTextBlockObject("plain_text", ":warning: Drift InProgress", true, false), nil), - slack.NewOptionBlockObject("cancelled", slack.NewTextBlockObject("plain_text", ":x: Cancelled", true, false), nil), + slack.NewOptionBlockObject("in_progress", slack.NewTextBlockObject("plain_text", ":loading: InProgress", true, false), nil), + slack.NewOptionBlockObject("done", slack.NewTextBlockObject("plain_text", ":white_check_mark: Done", true, false), nil), slack.NewOptionBlockObject("close", slack.NewTextBlockObject("plain_text", ":white_check_mark: Close", true, false), nil), ), ), @@ -476,6 +476,17 @@ func blockIncidentMessage(tracker tracker) []slack.Block { slack.NewTextBlockObject("plain_text", ":white_check_mark: Close", true, false), ), ), + slack.NewActionBlock( + "status", + slack.NewOptionsSelectBlockElement( + "static_select", + slack.NewTextBlockObject("plain_text", "Select status", true, false), + "action", + slack.NewOptionBlockObject("in_progress", slack.NewTextBlockObject("plain_text", ":loading: InProgress", true, false), nil), + slack.NewOptionBlockObject("done", slack.NewTextBlockObject("plain_text", ":white_check_mark: Done", true, false), nil), + slack.NewOptionBlockObject("close", slack.NewTextBlockObject("plain_text", ":white_check_mark: Close", true, false), nil), + ), + ), } return blocks @@ -564,7 +575,7 @@ func blockOperationMessage(tracker tracker) []slack.Block { } summary := fmt.Sprintf("⚙️ *Operation: %s* \n \n", tracker.Summary) - project := fmt.Sprintf("� *Prroject:* %s \n", tracker.Project) + project := fmt.Sprintf("🚀 *Project:* %s \n", tracker.Project) date := fmt.Sprintf("📅 *Start Date:* %s %s \n", formattedTime, location.String()) environment := fmt.Sprintf("%s *Environment:* %s \n", priorityEnv[tracker.Environment], tracker.Environment) priority := fmt.Sprintf("🎯 *Priority:* %s \n", tracker.Priority)