diff --git a/cmd/slack-bot/main.go b/cmd/slack-bot/main.go index d17e6b98536..6cceb7a3f3f 100644 --- a/cmd/slack-bot/main.go +++ b/cmd/slack-bot/main.go @@ -34,6 +34,7 @@ import ( userv1 "github.com/openshift/api/user/v1" + "github.com/openshift/ci-tools/pkg/chaibot" "github.com/openshift/ci-tools/pkg/jira" "github.com/openshift/ci-tools/pkg/pagerdutyutil" eventhandler "github.com/openshift/ci-tools/pkg/slack/events" @@ -66,6 +67,9 @@ type options struct { requireWorkflowsInForum bool supportRequestChannelID string supportRequestThreshold int + + enableTriage bool + triageConfigPath string } func (o *options) Validate() error { @@ -117,6 +121,8 @@ func gatherOptions(fs *flag.FlagSet, args ...string) options { fs.BoolVar(&o.requireWorkflowsInForum, "require-workflows-in-forum", true, "Require the use of workflows in the designated forum channel") fs.StringVar(&o.supportRequestChannelID, "support-request-channel-id", "CBN38N3MW", "Channel ID where support request mode watches long threads (defaults to #forum-ocp-testplatform)") fs.IntVar(&o.supportRequestThreshold, "support-request-threshold", 12, "Create a support-request Jira when a thread has more than this many messages (total count includes the root message)") + fs.BoolVar(&o.enableTriage, "enable-triage", false, "Enable Chaibot automatic test failure triage") + fs.StringVar(&o.triageConfigPath, "triage-config-path", "", "Path to triage configuration file") if err := fs.Parse(args); err != nil { logrus.WithError(err).Fatal("Could not parse args.") @@ -198,6 +204,57 @@ func main() { } } + // Initialize Chaibot if enabled + var chaibotAnalyzer *chaibot.Analyzer + var chaibotChannels []string + if o.enableTriage { + // Fail fast if required config is missing + if o.triageConfigPath == "" { + logrus.Fatal("--enable-triage requires --triage-config-path to be set") + } + + mcpURL := os.Getenv("SHIP_HELP_MCP_URL") + mcpToken := os.Getenv("SHIP_HELP_MCP_TOKEN") + + // Fail fast if required env vars are missing + if mcpURL == "" || mcpToken == "" { + logrus.Fatal("--enable-triage requires both SHIP_HELP_MCP_URL and SHIP_HELP_MCP_TOKEN environment variables") + } + + type triageConfig struct { + Enabled bool `yaml:"enabled"` + MonitoredChannels []struct { + Name string `yaml:"name"` + ChannelID string `yaml:"channel_id"` + } `yaml:"monitored_channels"` + Analysis struct { + AIProvider string `yaml:"ai_provider"` + PromptTemplate string `yaml:"prompt_template"` + } `yaml:"analysis"` + } + + configData, err := os.ReadFile(o.triageConfigPath) + if err != nil { + logrus.WithError(err).Fatal("Failed to read triage config") + } + + var cfg triageConfig + if err := yaml.Unmarshal(configData, &cfg); err != nil { + logrus.WithError(err).Fatal("Failed to parse triage config") + } + + chaibotAnalyzer = chaibot.NewAnalyzer(mcpURL, mcpToken, cfg.Analysis.PromptTemplate) + + for _, ch := range cfg.MonitoredChannels { + chaibotChannels = append(chaibotChannels, ch.ChannelID) + } + + logrus.WithFields(logrus.Fields{ + "channels": len(chaibotChannels), + "provider": cfg.Analysis.AIProvider, + }).Info("Chaibot triage enabled") + } + metrics.ExposeMetrics("slack-bot", config.PushGateway{}, o.instrumentationOptions.MetricsPort) simplifier := simplifypath.NewSimplifier(l("", // shadow element mimicing the root l(""), // for black-box health checks @@ -215,7 +272,7 @@ func main() { // handle the root to allow for a simple uptime probe mux.Handle("/", handler(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(http.StatusOK) }))) mux.Handle("/slack/interactive-endpoint", handler(handleInteraction(secret.GetTokenGenerator(o.slackSigningSecretPath), interactionrouter.ForModals(issueFiler, slackClient)))) - mux.Handle("/slack/events-endpoint", handler(handleEvent(secret.GetTokenGenerator(o.slackSigningSecretPath), eventrouter.ForEvents(slackClient, issueFiler, kubeClient, configAgent.Config, gcsClient, keywordsConfig, o.helpdeskAlias, o.forumChannelId, o.reviewRequestWorkflowID, o.namespace, o.supportRequestChannelID, o.supportRequestThreshold, o.requireWorkflowsInForum)))) + mux.Handle("/slack/events-endpoint", handler(handleEvent(secret.GetTokenGenerator(o.slackSigningSecretPath), eventrouter.ForEvents(slackClient, issueFiler, kubeClient, configAgent.Config, gcsClient, keywordsConfig, o.helpdeskAlias, o.forumChannelId, o.reviewRequestWorkflowID, o.namespace, o.supportRequestChannelID, o.supportRequestThreshold, o.requireWorkflowsInForum, chaibotAnalyzer, chaibotChannels)))) server := &http.Server{Addr: ":" + strconv.Itoa(o.port), Handler: mux} health.ServeReady() diff --git a/pkg/chaibot/README.md b/pkg/chaibot/README.md new file mode 100644 index 00000000000..871c6be6f3b --- /dev/null +++ b/pkg/chaibot/README.md @@ -0,0 +1,182 @@ +# Chaibot - Ship-Help MCP Integration + +This package provides test failure analysis using Chai Bot (ship-help MCP) for automatic Slack triage. + +## Overview + +Chaibot monitors Slack channels for Prow CI job failure URLs and automatically posts analysis using the Chai Bot service via ship-help MCP. + +## Files in This PR + +- `pkg/chaibot/analyzer.go` - Ship-help MCP client implementation +- `pkg/chaibot/analyzer_test.go` - Unit tests +- `pkg/slack/events/chaibot/handler.go` - Slack event handler (monitors for Prow URLs) +- `pkg/slack/events/chaibot/handler_test.go` - Event handler tests +- `pkg/slack/events/router/router.go` - Updated to register Chaibot handler +- `cmd/slack-bot/main.go` - Updated with Chaibot initialization + +**This PR provides the complete implementation.** The integration is ready to use once deployed. + +## How It Works + +### 1. Event Handler Pattern (Already Implemented) + +Chaibot uses the existing event handler pattern in `openshift/ci-tools`: + +**Implementation files:** +- `pkg/slack/events/chaibot/handler.go` - Monitors Slack messages for Prow URLs +- Registered in `pkg/slack/events/router/router.go` +- Initialized in `cmd/slack-bot/main.go` + +**What the handler does:** +1. Monitors configured Slack channels (e.g., `#opp-discussion`) +2. Detects Prow CI job URLs in messages +3. Calls `analyzer.AnalyzeFailure()` asynchronously +4. Posts analysis results in a thread + +### 2. Initialization in cmd/slack-bot/main.go + +**Already implemented in this PR:** + +```go +// Command-line flags (added) +--enable-triage // Enable Chaibot +--triage-config-path // Path to triage-config.yaml + +// Initialization (added to main()) +if o.enableTriage && o.triageConfigPath != "" { + mcpURL := os.Getenv("SHIP_HELP_MCP_URL") + mcpToken := os.Getenv("SHIP_HELP_MCP_TOKEN") + + // Create analyzer + chaibotAnalyzer = chaibot.NewAnalyzer(mcpURL, mcpToken, promptTemplate) + + // Handler is registered in router.ForEvents() +} +``` + +### 3. Event Router Registration + +**Already implemented in pkg/slack/events/router/router.go:** + +```go +func ForEvents(client *slack.Client, chaibotAnalyzer *chaibot.Analyzer, chaibotChannels []string, ...) { + // ... existing handlers ... + + if chaibotAnalyzer != nil && len(chaibotChannels) > 0 { + handlers = append(handlers, chaibothandler.Handler(client, chaibotAnalyzer, chaibotChannels)) + } +} +``` + +## NOT in This PR (Requires openshift/release Configuration) + +The following configuration files are in **openshift/release#80559**, not this PR: + +- `core-services/ci-chat-bot/triage-config.yaml` - Chaibot configuration +- `clusters/app.ci/ci-chat-bot/chaibot-configmap.yaml` - Kubernetes ConfigMap +- `clusters/app.ci/ci-chat-bot/ci-chat-bot.yaml` - Deployment with environment variables +- `core-services/ci-secret-bootstrap/chaibot-secret-config.yaml` - Ship-help token secret + +## Usage + +Once both PRs are merged and deployed: + +1. **Post a Prow URL in a monitored channel:** + ``` + Job failed: https://prow.ci.openshift.org/view/gs/test-platform-results/logs/periodic-ci-stolostron-policy-collection-main-ocp4.22-interop-opp-aws/2066255424226594816 + ``` + +2. **Chaibot responds in a thread within 30-60 seconds** with: + - Which step(s) failed + - Root cause analysis (product bug, test issue, or infrastructure) + - Related Jira tickets + - Pass rate history + - Recommended fixes + +## Configuration + +**Deployment configuration is in openshift/release#80559:** + +- `core-services/ci-chat-bot/triage-config.yaml` - Main config: + - Monitored channels (e.g., `#opp-discussion`) + - Ship-help MCP endpoint + - Analysis prompt template + - Rate limiting settings + +- `clusters/app.ci/ci-chat-bot/ci-chat-bot.yaml` - Deployment: + - Environment variables: `SHIP_HELP_MCP_URL`, `SHIP_HELP_MCP_TOKEN` + - ConfigMap mount: `/etc/triage-config/triage-config.yaml` + +## How to Enable Chaibot + +Chaibot is enabled via **command-line flags** (not environment variables): + +**Command-line flags (required):** +- `--enable-triage` - Enable Chaibot functionality +- `--triage-config-path=/etc/triage-config/triage-config.yaml` - Path to config file + +**Environment variables (required):** +- `SHIP_HELP_MCP_URL` - Ship-help MCP endpoint +- `SHIP_HELP_MCP_TOKEN` - Authentication token (from Kubernetes secret) + +**Example deployment command:** +```yaml +# In clusters/app.ci/ci-chat-bot/ci-chat-bot.yaml +args: + - --enable-triage + - --triage-config-path=/etc/triage-config/triage-config.yaml +env: + - name: SHIP_HELP_MCP_URL + value: "https://ship-help-mcp-continuous-release-tooling--ship-help-bot.apps.gpc.ocp-hub.prod.psi.redhat.com/personas/ocp_ai_helpdesk/mcp" + - name: SHIP_HELP_MCP_TOKEN + valueFrom: + secretKeyRef: + name: cluster-secrets-chaibot-ship-help + key: ship-help-token +``` + +**Without these flags, Chaibot will NOT activate** - even if environment variables are set. + +## Related PRs + +- **This PR (openshift/ci-tools#5251)** - Chaibot implementation (analyzer, handler, router, main.go) +- **openshift/release#80559** - Configuration and deployment (config files, secrets, ConfigMaps) +- Based on `/analyze-failure` skill by MPEX Integrity team +- Alternative to PR openshift/release#80476 (OpenAI approach) + +## Architecture + +``` +User posts Prow URL in Slack + ↓ +Slack Event API → ci-chat-bot deployment + ↓ +pkg/slack/events/chaibot/handler.go + - Detects Prow URL + - Extracts job URL + ↓ +pkg/chaibot/analyzer.go + - Calls ship-help MCP (ask_persona tool) + - Sends prompt with job URL + ↓ +Ship-Help MCP (ocp_ai_helpdesk persona) + - Searches Jira, Sippy, Prow logs + - Analyzes failure + - Returns comprehensive analysis + ↓ +pkg/chaibot/analyzer.go + - Formats response as Slack Block Kit + ↓ +Slack API + - Posts analysis in thread +``` + +## Cost Comparison + +| Solution | Cost | Data Sources | +|----------|------|--------------| +| **Chaibot (ship-help MCP)** | $0/month | 9+ sources (Jira, Sippy, Prow, GitHub, etc.) | +| OpenAI GPT-4o (PR #80476) | ~$1,080/year | 3 sources (limited context) | + +**Chaibot uses internal Red Hat infrastructure** - no external API costs. diff --git a/pkg/chaibot/analyzer.go b/pkg/chaibot/analyzer.go new file mode 100644 index 00000000000..5458571f4fc --- /dev/null +++ b/pkg/chaibot/analyzer.go @@ -0,0 +1,276 @@ +// Package chaibot provides test failure analysis using Chai Bot (ship-help MCP) +// This is the implementation code that goes in openshift/ci-tools/pkg/chaibot/ + +package chaibot + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Analyzer provides test failure analysis using ship-help MCP +type Analyzer struct { + mcpURL string + token string + client *http.Client + template string + sessionID string // MCP session ID +} + +// NewAnalyzer creates a new Analyzer instance +func NewAnalyzer(mcpURL, token, promptTemplate string) *Analyzer { + return &Analyzer{ + mcpURL: mcpURL, + token: token, + template: promptTemplate, + client: &http.Client{ + Timeout: 180 * time.Second, // Ship-help analysis can take 2-3 minutes + }, + } +} + +// MCPRequest represents an MCP JSON-RPC request +type MCPRequest struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params interface{} `json:"params"` +} + +// ToolCallParams represents parameters for calling an MCP tool +type ToolCallParams struct { + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments"` +} + +// MCPResponse represents an MCP JSON-RPC response +type MCPResponse struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Result struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// AnalysisResult contains the result of a failure analysis +type AnalysisResult struct { + JobURL string + Analysis string + Duration time.Duration + Error error +} + +// AnalyzeFailure analyzes a Prow CI job failure using Chai Bot +func (a *Analyzer) AnalyzeFailure(ctx context.Context, jobURL string) (*AnalysisResult, error) { + startTime := time.Now() + + // Initialize MCP session if needed + if a.sessionID == "" { + if err := a.initializeSession(ctx); err != nil { + return nil, fmt.Errorf("initialize session: %w", err) + } + } + + // Build prompt using the configured template + prompt := strings.ReplaceAll(a.template, "{job_url}", jobURL) + + // Call ship-help MCP tools/call method + reqBody := MCPRequest{ + JSONRPC: "2.0", + ID: 1, + Method: "tools/call", + Params: map[string]interface{}{ + "name": "ask_persona", + "arguments": map[string]interface{}{ + "question": prompt, + }, + }, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", a.mcpURL, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+a.token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Mcp-Session-Id", a.sessionID) + + resp, err := a.client.Do(req) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + // Check HTTP status code before parsing JSON + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) + } + + // Parse SSE response (ship-help MCP returns text/event-stream format) + sseData := parseSSEMessage(string(body)) + if sseData == "" { + return nil, fmt.Errorf("no JSON data in SSE response: %s", string(body)) + } + + // Parse MCP response + var mcpResp MCPResponse + if err := json.Unmarshal([]byte(sseData), &mcpResp); err != nil { + return nil, fmt.Errorf("parse response: %w", err) + } + + // Check for errors + if mcpResp.Error != nil { + return nil, fmt.Errorf("MCP error %d: %s", mcpResp.Error.Code, mcpResp.Error.Message) + } + + // Extract analysis text + if len(mcpResp.Result.Content) == 0 { + return nil, fmt.Errorf("no content in response") + } + + analysis := mcpResp.Result.Content[0].Text + + return &AnalysisResult{ + JobURL: jobURL, + Analysis: analysis, + Duration: time.Since(startTime), + }, nil +} + +// ExtractProwURL extracts a Prow job URL from a message +func ExtractProwURL(text string) string { + // Look for Prow URLs + patterns := []string{ + "https://prow.ci.openshift.org/view/gs/", + "https://prow.ci.openshift.org/?pr=", + "https://deck-internal-ci.apps.ci.l2s4.p1.openshiftapps.com/", + } + + for _, pattern := range patterns { + if idx := strings.Index(text, pattern); idx != -1 { + // Extract URL until whitespace + urlStart := idx + urlEnd := urlStart + for urlEnd < len(text) && !isWhitespace(text[urlEnd]) { + urlEnd++ + } + + // Trim trailing punctuation (handles cases like "https://...)" or "") + url := text[urlStart:urlEnd] + url = strings.TrimRight(url, ")>]}.,;:") + + return url + } + } + + return "" +} + +func isWhitespace(c byte) bool { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' +} + +// ContainsProwURL checks if a message contains a Prow URL +func ContainsProwURL(text string) bool { + return ExtractProwURL(text) != "" +} + +// FormatSlackResponse formats the analysis for Slack using Block Kit +// Returns a simple text message since we're posting in a thread +func FormatSlackResponse(result *AnalysisResult) string { + // Guard against nil result to prevent panic + if result == nil { + return "❌ Error: Unable to format analysis (nil result)" + } + + // Format as markdown text for thread reply + return fmt.Sprintf("🔍 *Chaibot Analysis*\n\n%s\n\n_Analysis completed in %.1fs • Powered by Chai Bot_", + result.Analysis, + result.Duration.Seconds(), + ) +} + +// initializeSession initializes an MCP session and stores the session ID +func (a *Analyzer) initializeSession(ctx context.Context) error { + // Create initialize request with required MCP protocol params + reqBody := MCPRequest{ + JSONRPC: "2.0", + ID: 0, + Method: "initialize", + Params: map[string]interface{}{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]interface{}{}, + "clientInfo": map[string]interface{}{ + "name": "chaibot", + "version": "1.0", + }, + }, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshal init request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", a.mcpURL, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("create init request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+a.token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + + resp, err := a.client.Do(req) + if err != nil { + return fmt.Errorf("send init request: %w", err) + } + defer resp.Body.Close() + + // Extract session ID from response header + sessionID := resp.Header.Get("Mcp-Session-Id") + if sessionID == "" { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("no session ID in response (HTTP %d): %s", resp.StatusCode, string(body)) + } + + a.sessionID = sessionID + return nil +} + +// parseSSEMessage extracts JSON data from Server-Sent Events response +// SSE format: "event: message\ndata: {json}\n\n" +func parseSSEMessage(body string) string { + lines := strings.Split(body, "\n") + for _, line := range lines { + if strings.HasPrefix(line, "data: ") { + return strings.TrimPrefix(line, "data: ") + } + } + return "" +} diff --git a/pkg/chaibot/analyzer_test.go b/pkg/chaibot/analyzer_test.go new file mode 100644 index 00000000000..6bc8d4f73d1 --- /dev/null +++ b/pkg/chaibot/analyzer_test.go @@ -0,0 +1,164 @@ +package chaibot + +import ( + "testing" + "time" +) + +func TestExtractProwURL(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "standard prow URL", + input: "Job failed: https://prow.ci.openshift.org/view/gs/test-platform-results/logs/periodic-ci-stolostron-policy-collection-main-ocp4.22-interop-opp-aws/2066255424226594816", + expected: "https://prow.ci.openshift.org/view/gs/test-platform-results/logs/periodic-ci-stolostron-policy-collection-main-ocp4.22-interop-opp-aws/2066255424226594816", + }, + { + name: "prow PR URL", + input: "Check this: https://prow.ci.openshift.org/?pr=12345 please", + expected: "https://prow.ci.openshift.org/?pr=12345", + }, + { + name: "deck internal URL", + input: "https://deck-internal-ci.apps.ci.l2s4.p1.openshiftapps.com/view/gcs/test-platform-results/logs/job/123", + expected: "https://deck-internal-ci.apps.ci.l2s4.p1.openshiftapps.com/view/gcs/test-platform-results/logs/job/123", + }, + { + name: "no URL", + input: "This is just a normal message", + expected: "", + }, + { + name: "URL with trailing text", + input: "Failed again https://prow.ci.openshift.org/view/gs/origin-ci-test/logs/job/456 any ideas?", + expected: "https://prow.ci.openshift.org/view/gs/origin-ci-test/logs/job/456", + }, + { + name: "URL with trailing parenthesis", + input: "Check this out (https://prow.ci.openshift.org/view/gs/test-platform-results/logs/job/789)", + expected: "https://prow.ci.openshift.org/view/gs/test-platform-results/logs/job/789", + }, + { + name: "URL with trailing angle bracket", + input: "", + expected: "https://prow.ci.openshift.org/view/gs/test-platform-results/logs/job/999", + }, + { + name: "URL with trailing period", + input: "Failed job: https://prow.ci.openshift.org/view/gs/test-platform-results/logs/job/111.", + expected: "https://prow.ci.openshift.org/view/gs/test-platform-results/logs/job/111", + }, + { + name: "URL with trailing comma", + input: "Jobs failed: https://prow.ci.openshift.org/view/gs/test-platform-results/logs/job/222, and more", + expected: "https://prow.ci.openshift.org/view/gs/test-platform-results/logs/job/222", + }, + { + name: "URL with multiple trailing punctuation", + input: "See: https://prow.ci.openshift.org/view/gs/test-platform-results/logs/job/333>.", + expected: "https://prow.ci.openshift.org/view/gs/test-platform-results/logs/job/333", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExtractProwURL(tt.input) + if result != tt.expected { + t.Errorf("ExtractProwURL(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestContainsProwURL(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "contains prow URL", + input: "Job failed: https://prow.ci.openshift.org/view/gs/test-platform-results/logs/job/123", + expected: true, + }, + { + name: "no prow URL", + input: "This is just a message", + expected: false, + }, + { + name: "contains other URL", + input: "Check out https://github.com/openshift/release", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ContainsProwURL(tt.input) + if result != tt.expected { + t.Errorf("ContainsProwURL(%q) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestFormatSlackResponse(t *testing.T) { + result := &AnalysisResult{ + JobURL: "https://prow.ci.openshift.org/view/gs/test-platform-results/logs/job/123", + Analysis: "Test analysis result", + Duration: 42 * time.Second, + } + + response := FormatSlackResponse(result) + + // Check that response is a string + if response == "" { + t.Error("Expected non-empty response string") + } + + // Check that response contains the analysis text + if !containsString(response, "Test analysis result") { + t.Errorf("Expected response to contain analysis text, got: %s", response) + } + + // Check that response contains the duration + if !containsString(response, "42.0s") { + t.Errorf("Expected response to contain duration, got: %s", response) + } + + // Check that response contains "Chaibot" or similar branding + if !containsString(response, "Chaibot") && !containsString(response, "Chai Bot") { + t.Errorf("Expected response to contain branding, got: %s", response) + } +} + +func TestFormatSlackResponse_NilResult(t *testing.T) { + // Should not panic with nil result + response := FormatSlackResponse(nil) + + // Check that response has error message + if response == "" { + t.Error("Expected non-empty error message") + } + + // Check that response contains error indicator + if !containsString(response, "Error") && !containsString(response, "❌") { + t.Errorf("Expected error response, got: %s", response) + } +} + +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + func() bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false + }()) +} diff --git a/pkg/slack/events/chaibot/handler.go b/pkg/slack/events/chaibot/handler.go new file mode 100644 index 00000000000..32c56423dc6 --- /dev/null +++ b/pkg/slack/events/chaibot/handler.go @@ -0,0 +1,96 @@ +package chaibot + +import ( + "context" + + "github.com/sirupsen/logrus" + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + + "github.com/openshift/ci-tools/pkg/chaibot" + "github.com/openshift/ci-tools/pkg/slack/events" +) + +type handler struct { + client *slack.Client + analyzer *chaibot.Analyzer + monitoredChannels map[string]bool +} + +func (h *handler) Handle(callback *slackevents.EventsAPIEvent, logger *logrus.Entry) (handled bool, err error) { + if callback.Type != slackevents.CallbackEvent { + return false, nil + } + + event, ok := callback.InnerEvent.Data.(*slackevents.MessageEvent) + if !ok { + return false, nil + } + + // Ignore bot messages to prevent loops + if event.BotID != "" { + return false, nil + } + + // Only monitor configured channels + if !h.monitoredChannels[event.Channel] { + return false, nil + } + + // Check for Prow URL + prowURL := chaibot.ExtractProwURL(event.Text) + if prowURL == "" { + return false, nil + } + + logger = logger.WithFields(logrus.Fields{ + "channel": event.Channel, + "url": prowURL, + }) + logger.Info("Chaibot detected Prow failure URL") + + // Analyze async (can take 30-60s) + go h.analyzeAndRespond(event, prowURL, logger) + + return true, nil +} + +func (h *handler) Identifier() string { + return "chaibot" +} + +func (h *handler) analyzeAndRespond(event *slackevents.MessageEvent, prowURL string, logger *logrus.Entry) { + result, err := h.analyzer.AnalyzeFailure(context.Background(), prowURL) + if err != nil { + logger.WithError(err).Error("Chaibot analysis failed") + return + } + + message := chaibot.FormatSlackResponse(result) + + _, _, err = h.client.PostMessage( + event.Channel, + slack.MsgOptionText(message, false), + slack.MsgOptionTS(event.TimeStamp), + ) + + if err != nil { + logger.WithError(err).Error("Failed to post Chaibot response") + } else { + logger.WithField("duration", result.Duration).Info("Chaibot analysis posted successfully") + } +} + +// Handler creates a new Chaibot event handler +func Handler(client *slack.Client, analyzer *chaibot.Analyzer, monitoredChannels []string) events.PartialHandler { + channelMap := make(map[string]bool) + for _, ch := range monitoredChannels { + channelMap[ch] = true + } + + return &handler{ + client: client, + analyzer: analyzer, + monitoredChannels: channelMap, + } +} diff --git a/pkg/slack/events/chaibot/handler_test.go b/pkg/slack/events/chaibot/handler_test.go new file mode 100644 index 00000000000..ddbb21c3160 --- /dev/null +++ b/pkg/slack/events/chaibot/handler_test.go @@ -0,0 +1,93 @@ +package chaibot + +import ( + "testing" + + "github.com/sirupsen/logrus" + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + + "github.com/openshift/ci-tools/pkg/chaibot" +) + +func TestHandler_IgnoresBotMessages(t *testing.T) { + analyzer := chaibot.NewAnalyzer("http://test", "token", "template") + h := Handler(slack.New("test-token"), analyzer, []string{"C12345"}) + + event := &slackevents.EventsAPIEvent{ + Type: slackevents.CallbackEvent, + InnerEvent: slackevents.EventsAPIInnerEvent{ + Type: slackevents.MessageEvent, + Data: &slackevents.MessageEvent{ + Channel: "C12345", + BotID: "B12345", + Text: "https://prow.ci.openshift.org/view/gs/test/123", + }, + }, + } + + handled, err := h.Handle(event, logrus.NewEntry(logrus.New())) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if handled { + t.Error("should not handle bot messages") + } +} + +func TestHandler_IgnoresNonMonitoredChannels(t *testing.T) { + analyzer := chaibot.NewAnalyzer("http://test", "token", "template") + h := Handler(slack.New("test-token"), analyzer, []string{"C12345"}) + + event := &slackevents.EventsAPIEvent{ + Type: slackevents.CallbackEvent, + InnerEvent: slackevents.EventsAPIInnerEvent{ + Type: slackevents.MessageEvent, + Data: &slackevents.MessageEvent{ + Channel: "C99999", // Different channel + Text: "https://prow.ci.openshift.org/view/gs/test/123", + }, + }, + } + + handled, err := h.Handle(event, logrus.NewEntry(logrus.New())) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if handled { + t.Error("should not handle non-monitored channels") + } +} + +func TestHandler_IgnoresMessagesWithoutProwURL(t *testing.T) { + analyzer := chaibot.NewAnalyzer("http://test", "token", "template") + h := Handler(slack.New("test-token"), analyzer, []string{"C12345"}) + + event := &slackevents.EventsAPIEvent{ + Type: slackevents.CallbackEvent, + InnerEvent: slackevents.EventsAPIInnerEvent{ + Type: slackevents.MessageEvent, + Data: &slackevents.MessageEvent{ + Channel: "C12345", + Text: "Just a normal message", + }, + }, + } + + handled, err := h.Handle(event, logrus.NewEntry(logrus.New())) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if handled { + t.Error("should not handle messages without Prow URLs") + } +} + +func TestHandler_Identifier(t *testing.T) { + analyzer := chaibot.NewAnalyzer("http://test", "token", "template") + h := Handler(slack.New("test-token"), analyzer, []string{"C12345"}) + + if h.Identifier() != "chaibot" { + t.Errorf("expected identifier 'chaibot', got %s", h.Identifier()) + } +} diff --git a/pkg/slack/events/router/router.go b/pkg/slack/events/router/router.go index 7b8a0fc2210..406d0e818ed 100644 --- a/pkg/slack/events/router/router.go +++ b/pkg/slack/events/router/router.go @@ -7,8 +7,10 @@ import ( ctrlruntimeclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/prow/pkg/config" + "github.com/openshift/ci-tools/pkg/chaibot" "github.com/openshift/ci-tools/pkg/jira" "github.com/openshift/ci-tools/pkg/slack/events" + chaibothandler "github.com/openshift/ci-tools/pkg/slack/events/chaibot" "github.com/openshift/ci-tools/pkg/slack/events/helpdesk" "github.com/openshift/ci-tools/pkg/slack/events/joblink" "github.com/openshift/ci-tools/pkg/slack/events/mention" @@ -17,12 +19,19 @@ import ( // ForEvents returns a Handler that appropriately routes // event callbacks for the handlers we know about -func ForEvents(client *slack.Client, filer jira.IssueFiler, kubeClient ctrlruntimeclient.Client, config config.Getter, gcsClient *storage.Client, keywordsConfig helpdesk.KeywordsConfig, helpdeskAlias, forumChannelId, reviewRequestWorkflowID, namespace, supportRequestChannelID string, supportRequestThreadMessageThreshold int, requireWorkflowsInForum bool) events.Handler { - return events.MultiHandler( +func ForEvents(client *slack.Client, filer jira.IssueFiler, kubeClient ctrlruntimeclient.Client, config config.Getter, gcsClient *storage.Client, keywordsConfig helpdesk.KeywordsConfig, helpdeskAlias, forumChannelId, reviewRequestWorkflowID, namespace, supportRequestChannelID string, supportRequestThreadMessageThreshold int, requireWorkflowsInForum bool, chaibotAnalyzer *chaibot.Analyzer, chaibotChannels []string) events.Handler { + handlers := []events.PartialHandler{ helpdesk.MessageHandler(client, keywordsConfig, helpdeskAlias, forumChannelId, reviewRequestWorkflowID, requireWorkflowsInForum), helpdesk.FAQHandler(client, kubeClient, forumChannelId, namespace), supportrequest.HandlerWithLock(client, filer, supportRequestChannelID, supportRequestThreadMessageThreshold, supportrequest.NewConfigMapLockClient(kubeClient, namespace)), mention.Handler(client), joblink.Handler(client, joblink.NewJobGetter(config), gcsClient), - ) + } + + // Add chaibot handler if configured + if chaibotAnalyzer != nil && len(chaibotChannels) > 0 { + handlers = append(handlers, chaibothandler.Handler(client, chaibotAnalyzer, chaibotChannels)) + } + + return events.MultiHandler(handlers...) }