summaryrefslogtreecommitdiffstats
path: root/.github/scripts
diff options
context:
space:
mode:
authorKirill Rysin <[email protected]>2026-04-09 22:09:24 +0200
committerGitHub <[email protected]>2026-04-09 23:09:24 +0300
commitdd7241688046d7fc656d2f4fc85ff0fe438c7869 (patch)
tree529d5481a2807d1341131ea6a419d0030e1811d5 /.github/scripts
parent739783c2537658fdc0ea29c86369d10d508b9629 (diff)
Mute: TESTOWNERS normalization (#37801)
Diffstat (limited to '.github/scripts')
-rw-r--r--.github/scripts/analytics/testowners_utils.py34
-rwxr-xr-x.github/scripts/analytics/tests_monitor.py6
-rw-r--r--.github/scripts/github_issue_utils.py38
-rw-r--r--.github/scripts/telegram/parse_and_send_team_issues.py101
-rw-r--r--.github/scripts/telegram/send_digest.py8
-rwxr-xr-x.github/scripts/tests/create_new_muted_ya.py9
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