diff options
| author | Kirill Rysin <[email protected]> | 2026-04-09 22:09:24 +0200 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-09 23:09:24 +0300 |
| commit | dd7241688046d7fc656d2f4fc85ff0fe438c7869 (patch) | |
| tree | 529d5481a2807d1341131ea6a419d0030e1811d5 /.github/scripts | |
| parent | 739783c2537658fdc0ea29c86369d10d508b9629 (diff) | |
Mute: TESTOWNERS normalization (#37801)
Diffstat (limited to '.github/scripts')
| -rw-r--r-- | .github/scripts/analytics/testowners_utils.py | 34 | ||||
| -rwxr-xr-x | .github/scripts/analytics/tests_monitor.py | 6 | ||||
| -rw-r--r-- | .github/scripts/github_issue_utils.py | 38 | ||||
| -rw-r--r-- | .github/scripts/telegram/parse_and_send_team_issues.py | 101 | ||||
| -rw-r--r-- | .github/scripts/telegram/send_digest.py | 8 | ||||
| -rwxr-xr-x | .github/scripts/tests/create_new_muted_ya.py | 9 |
6 files changed, 133 insertions, 63 deletions
diff --git a/.github/scripts/analytics/testowners_utils.py b/.github/scripts/analytics/testowners_utils.py index ed14ef5364e..12b28f39b73 100644 --- a/.github/scripts/analytics/testowners_utils.py +++ b/.github/scripts/analytics/testowners_utils.py @@ -1,13 +1,37 @@ -"""TESTOWNERS resolution for analytics (test_results / testowners uploads).""" +"""TESTOWNERS resolution for analytics (test_results / testowners uploads). -import os +``codeowners`` returns ``TEAM:@ydb-platform/<Slug>`` with whatever casing is in +``.github/TESTOWNERS``. We normalize team slugs to lowercase **here** (on read) so YDB +``owners`` / downstream marts always contain lowercase slugs without touching the file. + +For routing (digest queue, Telegram, GitHub project owner) use :func:`canonical_team_slug` +from ``github_issue_utils`` — it maps None / empty / unknown to the sentinel ``"unknown"``. +""" -from codeowners import CodeOwners +import os _MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) _REPO_ROOT = os.path.normpath(os.path.join(_MODULE_DIR, '..', '..', '..')) TESTOWNERS_FILE = os.path.join(_REPO_ROOT, '.github', 'TESTOWNERS') +_GITHUB_TEAM_PREFIX = "TEAM:@ydb-platform/" + + +def normalize_github_team_owners_string(owners_str: str) -> str: + """Lowercase path after ``TEAM:@ydb-platform/`` in each ``;;``-separated owner token.""" + if not owners_str or _GITHUB_TEAM_PREFIX not in owners_str: + return owners_str + parts = owners_str.split(";;") + normalized = [] + for raw in parts: + s = raw.strip() + if s.startswith(_GITHUB_TEAM_PREFIX): + rest = s[len(_GITHUB_TEAM_PREFIX) :] + normalized.append(_GITHUB_TEAM_PREFIX + rest.lower()) + else: + normalized.append(s) + return ";;".join(normalized) + def sort_codeowners_lines(codeowners_lines): def path_specificity(line): @@ -44,6 +68,8 @@ def get_testowners_for_tests(tests_data): Entry may be a dict with key ``suite_folder`` (upload pipeline) or any object with ``classname`` (e.g. generate-summary.TestResult — same path as suite_folder in JSON). """ + from codeowners import CodeOwners + with open(TESTOWNERS_FILE, 'r') as file: data = file.readlines() owners_obj = CodeOwners(''.join(sort_codeowners_lines(data))) @@ -51,5 +77,5 @@ def get_testowners_for_tests(tests_data): target_path = _suite_path_for_codeowners(test) owners = owners_obj.of(target_path) owners_str = ';;'.join([':'.join(x) for x in owners]) - _set_owners_field(test, owners_str) + _set_owners_field(test, normalize_github_team_owners_string(owners_str)) return tests_data diff --git a/.github/scripts/analytics/tests_monitor.py b/.github/scripts/analytics/tests_monitor.py index 960134fc840..cd9ef025099 100755 --- a/.github/scripts/analytics/tests_monitor.py +++ b/.github/scripts/analytics/tests_monitor.py @@ -16,6 +16,7 @@ from github_issue_utils import ( compute_effective_analytics_row, min_area_by_owner_team_from_rows, ) +from testowners_utils import normalize_github_team_owners_string def create_tables(ydb_wrapper, table_path): @@ -281,14 +282,13 @@ def _annotate_effective_owner_change_columns(df, last_exist_df): immediate = curr - def compute_owner(owner): if not owner or owner == '': - return 'Unknown' + return 'unknown' elif ';;' in owner: parts = owner.split(';;', 1) if 'TEAM' in parts[0]: - return parts[0] + return normalize_github_team_owners_string(parts[0]) else: return parts[1] else: diff --git a/.github/scripts/github_issue_utils.py b/.github/scripts/github_issue_utils.py index a0052935362..8153b8c5b51 100644 --- a/.github/scripts/github_issue_utils.py +++ b/.github/scripts/github_issue_utils.py @@ -11,6 +11,20 @@ from collections import defaultdict from dataclasses import dataclass, field from typing import Dict, List, Optional, Tuple +_GITHUB_TEAM_PREFIX = "TEAM:@ydb-platform/" + + +def team_slug_from_monitor_owner(owner) -> str: + """Lowercase team slug from ``tests_monitor.owner`` / YQL ``owner`` (SQL-aligned). + + Strips ``TEAM:@ydb-platform/`` then lowercases the remainder. + ``None`` → empty string (join keys / pandas use empty, not ``"unknown"``). + """ + if owner is None: + return "" + s = str(owner).replace(_GITHUB_TEAM_PREFIX, "").strip() + return s.lower() + DEFAULT_BUILD_TYPE = 'relwithdebinfo' DEFAULT_BRANCH = 'main' @@ -58,12 +72,22 @@ def normalize_analytics_area(raw) -> str: return s -def monitor_owner_to_team_key(owner) -> str: - """Lowercase slug like SQL ``Unicode::ToLower(ReplaceAll(owner, 'TEAM:@ydb-platform/', ''))``.""" - if owner is None: - return "" - s = str(owner).replace("TEAM:@ydb-platform/", "").strip() - return s.lower() +def canonical_team_slug(raw_owner_team) -> str: + """Lowercase team slug for routing (digest queue, Telegram ``teams``, GitHub project owner). + + Uses :func:`team_slug_from_monitor_owner` for the core strip/lowercase. Maps ``None``, + empty, ``unknown`` (any case), and a bare ``TEAM:@ydb-platform/`` to the slug ``unknown``. + """ + if raw_owner_team is None: + return "unknown" + raw = str(raw_owner_team).strip() + if not raw or raw.lower() == "unknown": + return "unknown" + return team_slug_from_monitor_owner(raw) or "unknown" + + +# Backward-compatible alias used in older SQL comments and callers. +monitor_owner_to_team_key = team_slug_from_monitor_owner def resolve_team_by_longest_area_prefix(normalized_area: str, area_to_owner: Dict[str, str]) -> Optional[str]: @@ -131,7 +155,7 @@ def compute_effective_analytics_row( area_to_owner: Dict[str, str], min_area_by_owner: Dict[str, str], ) -> Tuple[str, str]: - otk = monitor_owner_to_team_key(row.get("owner")) + otk = team_slug_from_monitor_owner(row.get("owner")) key = (str(row["full_name"]), str(row["branch"]), str(row["build_type"])) g = gim_by_key.get(key, {}) dw = row["date_window"] diff --git a/.github/scripts/telegram/parse_and_send_team_issues.py b/.github/scripts/telegram/parse_and_send_team_issues.py index ebfc6167b34..ff1c699d2f1 100644 --- a/.github/scripts/telegram/parse_and_send_team_issues.py +++ b/.github/scripts/telegram/parse_and_send_team_issues.py @@ -21,7 +21,7 @@ from send_telegram_message import send_telegram_message # Add analytics directory to path for ydb_wrapper import sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'analytics')) sys.path.append(os.path.join(os.path.dirname(__file__), '..')) -from github_issue_utils import DEFAULT_BUILD_TYPE, DEFAULT_BRANCH +from github_issue_utils import DEFAULT_BRANCH, DEFAULT_BUILD_TYPE, canonical_team_slug try: from ydb_wrapper import YDBWrapper YDB_AVAILABLE = True @@ -168,13 +168,11 @@ def get_all_team_data(use_yesterday=False, build_type=DEFAULT_BUILD_TYPE, branch if not owner: continue - # Handle both "TEAM:@ydb-platform/teamname" and "Unknown" formats - if owner.startswith('TEAM:@ydb-platform/'): - team_name = owner.split('/')[-1] - elif owner == 'Unknown': - team_name = 'Unknown' + # Accept TEAM:@ydb-platform/<slug> and the sentinel unknown (any case); + # skip everything else (plain usernames, email addresses, etc.) + if owner.startswith('TEAM:@ydb-platform/') or str(owner).strip().lower() == 'unknown': + team_name = canonical_team_slug(owner) else: - # Skip other formats continue if team_name not in team_data: @@ -579,7 +577,9 @@ def get_team_config(team_name, team_channels): """ if not team_channels: return None, None, None - + + team_name = canonical_team_slug(team_name) + # Get default channel first default_channel_name = team_channels.get('default_channel') default_chat_id, default_thread_id = None, None @@ -615,37 +615,29 @@ def get_team_config(team_name, team_channels): return team_responsible, team_chat_id, team_thread_id - # Try Unknown team as fallback - elif 'teams' in team_channels and 'Unknown' in team_channels['teams']: - unknown_config = team_channels['teams']['Unknown'] - - # Get responsible users from Unknown team - team_responsible = None - if 'responsible' in unknown_config: - team_responsible = {team_name: unknown_config['responsible']} - - # Use default channel or Unknown team's channel - if default_chat_id: - print(f"📨 Using default channel '{default_channel_name}' for unknown team {team_name}: {default_chat_id}" + (f" (thread {default_thread_id})" if default_thread_id else "")) - return team_responsible, default_chat_id, default_thread_id - elif 'channel' in unknown_config: - # Try Unknown team's specific channel - channel_name = unknown_config['channel'] - if 'channels' in team_channels and channel_name in team_channels['channels']: - team_chat_id, team_thread_id = parse_chat_and_thread_id(team_channels['channels'][channel_name]) - print(f"📨 Using Unknown team channel '{channel_name}' for team {team_name}: {team_chat_id}" + (f" (thread {team_thread_id})" if team_thread_id else "")) - return team_responsible, team_chat_id, team_thread_id - else: - print(f"❌ Unknown team channel '{channel_name}' not found") + # Fallback config: prefer key "unknown", accept legacy "Unknown" + elif 'teams' in team_channels: + unknown_config = team_channels['teams'].get('unknown') or team_channels['teams'].get('Unknown') + if unknown_config is not None: + team_responsible = None + if 'responsible' in unknown_config: + team_responsible = {team_name: unknown_config['responsible']} + if default_chat_id: + print(f"📨 Using default channel '{default_channel_name}' for unknown team {team_name}: {default_chat_id}" + (f" (thread {default_thread_id})" if default_thread_id else "")) + return team_responsible, default_chat_id, default_thread_id + if 'channel' in unknown_config: + channel_name = unknown_config['channel'] + if 'channels' in team_channels and channel_name in team_channels['channels']: + team_chat_id, team_thread_id = parse_chat_and_thread_id(team_channels['channels'][channel_name]) + print(f"📨 Using unknown-team channel '{channel_name}' for team {team_name}: {team_chat_id}" + (f" (thread {team_thread_id})" if team_thread_id else "")) + return team_responsible, team_chat_id, team_thread_id + print(f"❌ Unknown-team channel '{channel_name}' not found") return None, None, None - else: print(f"❌ No channel configuration found for unknown team {team_name}") return None, None, None - - # No configuration found - else: - print(f"❌ No channel configuration found for team {team_name}") - return None, None, None + + print(f"❌ No channel configuration found for team {team_name}") + return None, None, None def send_team_messages(teams, bot_token, delay=2, max_retries=5, retry_delay=10, team_channels=None, dry_run=False, muted_stats=None, include_plots=False, ydb_config=None, debug_plots_dir=None, all_team_data=None, show_diff=False): @@ -868,6 +860,28 @@ def test_telegram_connection(bot_token, chat_id, message_thread_id=None): return False +def _normalize_telegram_team_channels_config(data): + """Lowercase ``teams`` keys in mailing JSON so they match mart slugs (see ``canonical_team_slug``).""" + if not isinstance(data, dict): + return data + raw_teams = data.get("teams") + if not isinstance(raw_teams, dict): + return data + normalized: dict = {} + for k, v in raw_teams.items(): + nk = canonical_team_slug(k) + if nk in normalized: + if normalized[nk] != v: + print( + f"⚠️ Mailing config: duplicate team after normalizing keys {k!r} → {nk!r}; keeping first entry" + ) + else: + normalized[nk] = v + out = dict(data) + out["teams"] = normalized + return out + + def load_team_channels(team_channels_json): """ Load team channels configuration from JSON string or file. @@ -884,16 +898,17 @@ def load_team_channels(team_channels_json): try: # Try to parse as JSON string first if team_channels_json.strip().startswith('{'): - return json.loads(team_channels_json) + data = json.loads(team_channels_json) else: # Try to read as file file_path = Path(team_channels_json) if file_path.exists(): with open(file_path, 'r', encoding='utf-8') as f: - return json.load(f) + data = json.load(f) else: print(f"⚠️ Team channels file not found: {file_path}") return None + return _normalize_telegram_team_channels_config(data) except json.JSONDecodeError as e: print(f"❌ Error parsing team channels JSON: {e}") return None @@ -965,8 +980,9 @@ def send_period_updates(period, bot_token, team_channels, ydb_config, delay=2, m print(f"📨 Using default channel '{default_channel_name}' for team {team_name}: {team_chat_id}") # Determine channel name for logging - if team_channels and 'teams' in team_channels and team_name in team_channels['teams']: - team_config = team_channels['teams'][team_name] + team_key = canonical_team_slug(team_name) + if team_channels and 'teams' in team_channels and team_key in team_channels['teams']: + team_config = team_channels['teams'][team_key] team_channel_name = team_config.get('channel', team_channels.get('default_channel', 'default')) else: team_channel_name = team_channels.get('default_channel', 'default') if team_channels else 'default' @@ -1294,8 +1310,9 @@ def main(): responsible_info = "" channel_info = "" - if team_channels and 'teams' in team_channels and team_name in team_channels['teams']: - team_config = team_channels['teams'][team_name] + team_key = canonical_team_slug(team_name) + if team_channels and 'teams' in team_channels and team_key in team_channels['teams']: + team_config = team_channels['teams'][team_key] # Get responsible info if 'responsible' in team_config: diff --git a/.github/scripts/telegram/send_digest.py b/.github/scripts/telegram/send_digest.py index a10e46699b1..35eb24a3db1 100644 --- a/.github/scripts/telegram/send_digest.py +++ b/.github/scripts/telegram/send_digest.py @@ -30,8 +30,10 @@ import ydb from datetime import datetime, timezone from pathlib import Path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from github_issue_utils import make_profile_id +_scripts = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if _scripts not in sys.path: + sys.path.insert(0, _scripts) +from github_issue_utils import canonical_team_slug, make_profile_id # ISO weekday: Monday=1 … Sunday=7 (datetime.isoweekday()) _DEFAULT_SCHEDULE_WEEKDAYS = frozenset((1, 2, 3, 4, 5)) @@ -210,7 +212,7 @@ def _group_by_team(rows: list) -> dict: """Return {team_name: [{url, title}, ...]}.""" teams: dict = {} for row in rows: - team = (row.get("owner_team") or "Unknown").strip() or "Unknown" + team = canonical_team_slug(row.get("owner_team")) teams.setdefault(team, []).append( { "url": row.get("github_issue_url") or "", diff --git a/.github/scripts/tests/create_new_muted_ya.py b/.github/scripts/tests/create_new_muted_ya.py index 15537dff59b..23efab4e0e6 100755 --- a/.github/scripts/tests/create_new_muted_ya.py +++ b/.github/scripts/tests/create_new_muted_ya.py @@ -25,7 +25,7 @@ sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'analytics')) from ydb_wrapper import YDBWrapper sys.path.append(os.path.join(os.path.dirname(__file__), '..')) -from github_issue_utils import DEFAULT_BUILD_TYPE, make_profile_id +from github_issue_utils import DEFAULT_BUILD_TYPE, canonical_team_slug, make_profile_id # Configure logging logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') @@ -732,7 +732,7 @@ def create_mute_issues(all_tests, file_path, close_issues=True, branch='main', b 'success_rate': 0, 'days_in_state': 0, 'date_window': 'N/A', - 'owner': 'Unknown', + 'owner': 'unknown', 'state': 'Muted', 'summary': 'added manually, no monitor data', 'fail_count': 0, @@ -742,7 +742,7 @@ def create_mute_issues(all_tests, file_path, close_issues=True, branch='main', b } logging.info(f"test {full_name} not in monitor, using fallback data") - key = f"{test_from_file['testsuite']}:{entry['owner']}" + key = f"{test_from_file['testsuite']}:{canonical_team_slug(entry['owner'])}" if not temp_tests_by_suite.get(key): temp_tests_by_suite[key] = [] temp_tests_by_suite[key].append(entry) @@ -762,7 +762,8 @@ def create_mute_issues(all_tests, file_path, close_issues=True, branch='main', b queue_items = [] for item in prepared_tests_by_suite: title, body = generate_github_issue_title_and_body(prepared_tests_by_suite[item]) - owner_value = prepared_tests_by_suite[item][0]['owner'].split('/', 1)[1] if '/' in prepared_tests_by_suite[item][0]['owner'] else prepared_tests_by_suite[item][0]['owner'] + raw_owner = prepared_tests_by_suite[item][0]['owner'] + owner_value = canonical_team_slug(raw_owner) result = create_and_add_issue_to_project(title, body, state='Muted', owner=owner_value) if not result: break |
