Files
TheRock/rocm-libraries/.github/scripts/notify_teams.py
T
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

664 lines
24 KiB
Python

#!/usr/bin/env python3
"""
Microsoft Teams Notification Script for CI Failures
This script extracts error context from build/test logs and sends
notifications to Microsoft Teams via webhook. It supports automatic
routing to the correct Teams channel based on PR title tags or
component names.
Usage (Multi-project routing - recommended):
python notify_teams.py \
--failure-stage test \
--log-path ./test_logs/test_output.log \
--webhook-urls '{"miopen": "URL1", "hipdnn": "URL2"}' \
--pr-number NUM \
--pr-title "[miopen] Fix bug" \
--component-name "miopen" \
[--job-name "Test name"] \
[--dry-run]
Usage (Legacy single-project mode):
python notify_teams.py \
--project miopen \
--failure-stage build \
--log-path TheRock/build/logs \
--webhook-url "$WEBHOOK_URL" \
--pr-number NUM \
[--pr-title "TITLE"] \
[--job-name "Test name"] \
[--dry-run]
Routing Logic (when using --webhook-urls):
1. Single PR title tag (e.g., "[miopen]") -> routes to that project
2. Multiple tags (e.g., "[miopen][hipdnn]") -> uses component name to disambiguate
3. No tags -> falls back to component name matching
4. No match -> exits with success (0), no notification sent
Note: The GitHub Actions run URL is constructed automatically from
GITHUB_REPOSITORY and GITHUB_RUN_ID environment variables. For local
testing, these environment variables are optional and will use placeholder
values if not set.
"""
import argparse
import json
import os
import platform
import re
import subprocess
import sys
from pathlib import Path
from typing import Optional, Tuple, Dict, List
import logging
logger = logging.getLogger(__name__)
class ChannelRouter:
"""Routes notifications to the appropriate Teams channel based on PR title tags or component"""
# Project configuration - order matters! First match wins.
# Projects are checked in insertion order (Python 3.7+ guarantees this).
PROJECT_CONFIG = {
"miopen": {
"title_tags": ["[miopen]"],
"component_patterns": ["miopen"],
},
"hipdnn": {
"title_tags": ["[hipdnn]", "[miopenprovider]"],
"component_patterns": ["hipdnn"],
},
# Future projects can be added here:
# "rocblas": {
# "title_tags": ["[rocblas]"],
# "component_patterns": ["rocblas"],
# },
}
@staticmethod
def validate_webhook_url(url: str) -> bool:
"""Validate that the URL is a legitimate Teams webhook URL.
Supports both:
- Legacy Office 365 webhooks: https://<tenant>.webhook.office.com/webhookb2/...
- Power Automate workflows: https://<id>.environment.api.powerplatform.com/...
Returns False for empty strings or non-Teams URLs.
"""
if not url or not url.strip():
return False
# Legacy Office 365 webhook format
office365_pattern = r"^https://[a-zA-Z0-9-]+\.webhook\.office\.com/webhookb2/.*"
# Power Automate workflow format
power_automate_pattern = r"^https://[a-zA-Z0-9-]+\.[\w.]*\.api\.powerplatform\.com(:\d+)?/powerautomate/.*"
return bool(
re.match(office365_pattern, url) or re.match(power_automate_pattern, url)
)
@staticmethod
def sanitize_pr_title(title: str) -> str:
"""Sanitize PR title to prevent injection attacks"""
# Remove potentially dangerous characters while preserving readability
return re.sub(r'[<>&"\']', "", title)
def _get_matching_projects_from_title(self, pr_title: str) -> List[str]:
"""Extract all matching project names from PR title tags.
Returns:
List of project names that have matching tags in the PR title.
"""
matches = []
if not pr_title:
return matches
pr_title_lower = pr_title.lower()
for project, config in self.PROJECT_CONFIG.items():
for tag in config["title_tags"]:
if tag.lower() in pr_title_lower:
matches.append(project)
break # Found match for this project, move to next
return matches
def _get_project_from_component(self, component_name: str) -> Optional[str]:
"""Match component name to a project.
Returns:
Project name if matched, None otherwise.
"""
if not component_name:
return None
component_lower = component_name.lower()
for project, config in self.PROJECT_CONFIG.items():
for pattern in config["component_patterns"]:
if pattern.lower() in component_lower:
return project
return None
def determine_target_channel(
self, pr_title: str, component_name: str
) -> Optional[str]:
"""Determine which channel to notify based on PR title and component.
Routing logic:
1. If PR title has exactly one project tag, use that project
2. If PR title has multiple project tags, use failing component to pick one
3. If PR title has no tags, fall back to component matching
4. If no match, return None
Returns:
str: The project name to notify, or None if no match found.
"""
# Sanitize inputs
pr_title = self.sanitize_pr_title(pr_title) if pr_title else ""
# Get all projects tagged in PR title
title_matches = self._get_matching_projects_from_title(pr_title)
if len(title_matches) == 1:
# Single tag - use it directly
project = title_matches[0]
logger.info(f"Matched project '{project}' via single PR title tag")
return project
if len(title_matches) > 1:
# Multiple tags - use component to disambiguate
logger.info(
f"Multiple project tags found in PR title: {title_matches}. "
f"Using component '{component_name}' to disambiguate."
)
component_project = self._get_project_from_component(component_name)
if component_project and component_project in title_matches:
logger.info(
f"Matched project '{component_project}' via component disambiguation"
)
return component_project
else:
# Component doesn't match any tagged project - use first tag
project = title_matches[0]
logger.warning(
f"Component '{component_name}' doesn't match any tagged project. "
f"Falling back to first tag: '{project}'"
)
return project
# No tags found - fall back to component matching
component_project = self._get_project_from_component(component_name)
if component_project:
logger.info(f"Matched project '{component_project}' via component pattern")
return component_project
# No match found
logger.info(
f"No matching project found for PR title '{pr_title}' and component '{component_name}'. "
f"Supported projects: {', '.join(self.PROJECT_CONFIG.keys())}. Skipping notification."
)
return None
class ErrorExtractor:
"""Extracts and categorizes errors from log files"""
# Error patterns for build failures
# Note: Order matters - more specific patterns should come first
BUILD_ERROR_PATTERNS = {
"Compilation Issue": r"(compil|undefined reference|unresolved external|linker|link.*error)",
"Docker Issue": r"(docker|container|image)",
"Configuration Issue": r"(cmake.*error|configuration.*error|configuration.*fail)",
"Timeout": r"(timeout|timed out)",
"Permission Issue": r"(permission|access denied)",
"Memory Issue": r"(out of memory|oom|memory)",
}
# Error patterns for test failures (includes build patterns + test-specific)
TEST_ERROR_PATTERNS = {
"Test Failure": r"(test.*fail|failed.*test|assertion|gtest)",
"Timeout": r"(timeout|timed out)",
"Memory Issue": r"(out of memory|oom|memory)",
"GPU/Driver Issue": r"(gpu|device|driver)",
}
# Error context extraction limits
MAX_CONTEXT_LINES = 10
MAX_OUTPUT_LINES = 7
def __init__(self, log_path: str, failure_stage: str):
self.log_path = Path(log_path)
self.failure_stage = failure_stage
def extract(self) -> Tuple[str, str]:
"""
Extract error context and categorize the issue type.
Returns:
Tuple of (error_log, issue_type)
"""
if self.log_path.is_dir():
return self._extract_from_directory()
if self.log_path.is_file():
return self._extract_from_file()
return (
"Error details not found in logs. Check the previous steps in GitHub Actions",
f"{self.failure_stage.title()} Failure",
)
def _extract_from_directory(self) -> Tuple[str, str]:
"""Extract errors from a directory of log files"""
error_log = ""
if self.log_path.exists():
try:
# Use grep to search for errors across all files in directory
result = subprocess.run(
[
"grep",
"--recursive",
"--ignore-case",
"--extended-regexp",
"(error|failed|fatal|exception)",
str(self.log_path),
"--after-context",
"3",
"--before-context",
"2",
"--max-count=5",
],
capture_output=True,
text=True,
timeout=10,
)
error_log = result.stdout
except (
subprocess.TimeoutExpired,
subprocess.SubprocessError,
FileNotFoundError,
):
# Fallback if grep fails or not available (Windows)
error_log = self._scan_directory_python()
if not error_log:
error_log = "Error details not found in logs. Check the GitHub Actions run for full output."
# Categorize based on full output BEFORE limiting to 7 lines
issue_type = self._categorize_error(error_log)
# Then limit to 7 lines for the error context
error_lines = error_log.split("\n")[:7]
error_log_limited = "\n".join(error_lines)
return (error_log_limited, issue_type)
def _extract_from_file(self) -> Tuple[str, str]:
"""Extract errors from a single log file"""
error_log = ""
if self.failure_stage == "test" and self.log_path.exists():
# For test logs, read from the file directly
try:
with open(self.log_path, "r", encoding="utf-8", errors="ignore") as f:
lines = f.readlines()
last_lines = lines[-100:] if len(lines) > 100 else lines
# Search for failure patterns
error_lines = []
for i, line in enumerate(last_lines):
if re.search(
r"(error|failed|fatal|exception|assertion)",
line,
re.IGNORECASE,
):
# Include context: 2 lines before and 3 lines after
start = max(0, i - 2)
end = min(len(last_lines), i + 4)
error_lines.extend(last_lines[start:end])
if len(error_lines) >= self.MAX_CONTEXT_LINES:
break
error_log = "".join(error_lines[: self.MAX_CONTEXT_LINES])
except (IOError, UnicodeDecodeError):
error_log = f"Test failed: Could not read log file at {self.log_path}"
elif self.log_path.exists():
# For build logs from a single file
return self._extract_from_directory() # Use grep approach
if not error_log:
error_log = "Error details not found in logs. Check the GitHub Actions run for full output."
# Categorize based on full output BEFORE limiting to 7 lines
issue_type = self._categorize_error(error_log)
# Then limit to 7 lines for the error context
error_lines = error_log.split("\n")[:7]
error_log_limited = "\n".join(error_lines)
return (error_log_limited, issue_type)
def _scan_directory_python(self) -> str:
"""Fallback method to scan directory using Python (for Windows compatibility)"""
error_log = ""
max_files = 10
files_scanned = 0
for log_file in self.log_path.rglob("*.log"):
if files_scanned >= max_files:
break
try:
with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
matches = re.finditer(
r"(error|failed|fatal|exception)", content, re.IGNORECASE
)
for match in list(matches)[:5]:
start = max(0, match.start() - 100)
end = min(len(content), match.end() + 100)
error_log += content[start:end] + "\n"
if len(error_log) > 500:
return error_log
files_scanned += 1
except (IOError, UnicodeDecodeError):
continue
return error_log
def _categorize_error(self, error_log: str) -> str:
"""Categorize the error based on patterns in the log"""
patterns = (
self.TEST_ERROR_PATTERNS
if self.failure_stage == "test"
else self.BUILD_ERROR_PATTERNS
)
for issue_type, pattern in patterns.items():
if re.search(pattern, error_log, re.IGNORECASE):
return issue_type
# Default issue type
return f"{self.failure_stage.title()} Failure"
def _limit_output(self, error_log: str) -> str:
"""Limit error output to MAX_OUTPUT_LINES"""
error_lines = error_log.split("\n")[: self.MAX_OUTPUT_LINES]
return "\n".join(error_lines)
class TeamsNotifier:
"""Sends notifications to Microsoft Teams via webhook"""
def __init__(self, webhook_url: str, dry_run: bool = False):
self.webhook_url = webhook_url
self.dry_run = dry_run
def send_notification(
self,
project: str,
platform: str,
failure_stage: str,
run_url: str,
error_context: str,
issue_type: str,
pr_number: Optional[str] = None,
pr_title: Optional[str] = None,
job_name: Optional[str] = None,
) -> bool:
"""
Send notification to Teams webhook.
Returns:
True if successful, False otherwise
"""
# Validate webhook URL
if not self.webhook_url or self.webhook_url in ["", "test"]:
print(
f"Warning: Webhook URL not set for project '{project}', skipping Teams notification"
)
return True # Exit gracefully
# Format the message
message = self._format_message(
project,
platform,
failure_stage,
run_url,
error_context,
issue_type,
pr_number,
pr_title,
job_name,
)
if self.dry_run:
print("DRY RUN MODE - Would send the following notification:")
print("=" * 80)
print(json.dumps(message, indent=2))
print("=" * 80)
return True
# Send the notification
try:
import urllib.request
data = json.dumps(message).encode("utf-8")
req = urllib.request.Request(
self.webhook_url,
data=data,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30) as response:
if response.status in [200, 202]:
print(
f"Successfully sent Teams notification for {project} with response status: {response.status}"
)
return True
except Exception as e:
print(f"Error sending Teams notification: {e}")
return False
def _format_message(
self,
project: str,
platform: str,
failure_stage: str,
run_url: str,
error_context: str,
issue_type: str,
pr_number: Optional[str] = None,
pr_title: Optional[str] = None,
job_name: Optional[str] = None,
) -> dict:
"""Format the Teams message payload for Power Automate webhook"""
# Platform name capitalization
platform_name = platform.title()
failure_label = f"{failure_stage.title()} Failure"
# Build PR info section
pr_info = ""
if pr_title and pr_number:
pr_info = f"\n**PR #{pr_number}:** {pr_title}\n"
# Build job name section
job_info = ""
if job_name:
job_info = f"\n**Job:** {job_name}\n"
# Clean error context (remove excessive whitespace, limit length)
error_snippet = error_context.strip()
if len(error_snippet) > 1000:
error_snippet = error_snippet[:1000] + "..."
# Build the text message with proper markdown (no escaping needed for Power Automate)
text = (
f"{platform_name} {failure_label}\n\n"
f"{pr_info}\n\n"
f"{job_info}\n\n"
f"**Issue Type:** {issue_type}\n\n"
f"**Error Context:**\n```\n{error_snippet}\n```\n\n"
f"[View Full Run]({run_url})"
)
# Create simple JSON payload for Power Automate webhook
message = {"text": text}
return message
def main():
# Configure logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
parser = argparse.ArgumentParser(
description="Send Microsoft Teams notifications for CI failures"
)
parser.add_argument(
"--project",
default="",
help="Project name (e.g., miopen, rocblas). Optional if using --webhook-urls with auto-routing.",
)
parser.add_argument(
"--failure-stage",
required=True,
choices=["build", "test"],
help="Stage of failure (build or test)",
)
parser.add_argument(
"--log-path",
required=True,
help="Path to log file or directory containing logs",
)
# New: JSON object mapping project names to webhook URLs
parser.add_argument(
"--webhook-urls",
default="",
help='JSON object mapping project names to webhook URLs (e.g., \'{"miopen": "url1", "hipdnn": "url2"}\')',
)
# Legacy: single webhook URL (for backwards compatibility)
parser.add_argument(
"--webhook-url",
default="",
help="Microsoft Teams webhook URL (legacy, use --webhook-urls for multi-project support)",
)
parser.add_argument("--pr-number", required=True, help="Pull request number")
parser.add_argument(
"--pr-title", nargs="*", default=[], help="Pull request title (optional)"
)
parser.add_argument(
"--component-name",
default="",
help="Component/job name for routing fallback when using --webhook-urls",
)
parser.add_argument(
"--job-name", default="", help="Job/test name (optional, for test failures)"
)
parser.add_argument(
"--dry-run", action="store_true", help="Print the message instead of sending it"
)
args = parser.parse_args()
# Handle pr_title which may be a list due to nargs='*'
pr_title = " ".join(args.pr_title) if args.pr_title else ""
# Determine webhook URL and project based on arguments
webhook_url = ""
project = args.project
if args.webhook_urls:
# New multi-project routing mode
try:
webhook_urls = json.loads(args.webhook_urls)
if not isinstance(webhook_urls, dict):
logger.error("--webhook-urls must be a JSON object")
sys.exit(1)
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON in --webhook-urls: {e}")
sys.exit(1)
# Determine target channel using ChannelRouter
router = ChannelRouter()
target_project = router.determine_target_channel(pr_title, args.component_name)
# Exit early if no matching project found
if target_project is None:
logger.info("No notification sent - project not supported")
sys.exit(0) # Exit with success
# Check if webhook URL exists for the matched project
if target_project not in webhook_urls:
logger.warning(
f"Project '{target_project}' matched but no webhook URL provided in --webhook-urls"
)
sys.exit(0) # Exit with success
webhook_url = webhook_urls[target_project]
project = target_project
# Check for empty webhook URL (secret not set)
if not webhook_url or not webhook_url.strip():
logger.warning(
f"Webhook URL for project '{target_project}' is empty (secret may not be set)"
)
sys.exit(0) # Exit with success
# Validate webhook URL format
if not ChannelRouter.validate_webhook_url(webhook_url):
logger.error(
f"Invalid Teams webhook URL format for project '{target_project}'. "
f"Expected: https://<tenant>.webhook.office.com/webhookb2/... or "
f"https://<id>.environment.api.powerplatform.com/powerautomate/..."
)
sys.exit(1)
elif args.webhook_url:
# Legacy single webhook mode
webhook_url = args.webhook_url
if not project:
logger.error("--project is required when using --webhook-url")
sys.exit(1)
else:
logger.error("Either --webhook-urls or --webhook-url must be provided")
sys.exit(1)
# Detect platform automatically
detected_platform = platform.system().lower()
# Construct GitHub Actions run URL from environment variables
# Use defaults for local testing when environment variables are not set
github_repository = os.getenv("GITHUB_REPOSITORY", "local/testing")
github_run_id = os.getenv("GITHUB_RUN_ID", "0")
run_url = f"https://github.com/{github_repository}/actions/runs/{github_run_id}"
if github_repository == "local/testing":
print("Warning: Running in local test mode (GITHUB_REPOSITORY not set)")
print(f"Using placeholder run URL: {run_url}")
# Extract error context
extractor = ErrorExtractor(args.log_path, args.failure_stage)
error_context, issue_type = extractor.extract()
# Send notification
notifier = TeamsNotifier(webhook_url, args.dry_run)
success = notifier.send_notification(
project=project,
platform=detected_platform,
failure_stage=args.failure_stage,
run_url=run_url,
error_context=error_context,
issue_type=issue_type,
pr_number=args.pr_number if args.pr_number else None,
pr_title=pr_title if pr_title else None,
job_name=args.job_name if args.job_name else None,
)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()