summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKirill Rysin <[email protected]>2025-05-26 18:30:06 +0200
committerGitHub <[email protected]>2025-05-26 19:30:06 +0300
commit9b92253b68c58fba01919cadf28a47bb460b90dd (patch)
treea81bf6c355f74bbe890c9ba2f53885eb3caa7d41
parent521a632276395a05fe4dc7580b387ff30041bba8 (diff)
CI: Fix create issues for muted tests + close unmuted tests (#18863)
-rwxr-xr-x.github/scripts/tests/create_new_muted_ya.py73
-rwxr-xr-x.github/scripts/tests/update_mute_issues.py338
2 files changed, 401 insertions, 10 deletions
diff --git a/.github/scripts/tests/create_new_muted_ya.py b/.github/scripts/tests/create_new_muted_ya.py
index e2aae4931d9..99d6168e1c5 100755
--- a/.github/scripts/tests/create_new_muted_ya.py
+++ b/.github/scripts/tests/create_new_muted_ya.py
@@ -12,6 +12,7 @@ from update_mute_issues import (
create_and_add_issue_to_project,
generate_github_issue_title_and_body,
get_muted_tests_from_issues,
+ close_unmuted_issues,
)
# Configure logging
@@ -300,11 +301,22 @@ def read_tests_from_file(file_path):
return result
-def create_mute_issues(all_tests, file_path):
+def create_mute_issues(all_tests, file_path, close_issues=True):
base_date = datetime.datetime(1970, 1, 1)
tests_from_file = read_tests_from_file(file_path)
muted_tests_in_issues = get_muted_tests_from_issues()
prepared_tests_by_suite = {}
+ temp_tests_by_suite = {}
+
+ # Create set of muted tests for faster lookup
+ muted_tests_set = {test['full_name'] for test in tests_from_file}
+
+ # Check and close issues if needed
+ closed_issues = []
+ if close_issues:
+ closed_issues = close_unmuted_issues(muted_tests_set)
+
+ # First, collect all tests into temporary dictionary
for test in all_tests:
for test_from_file in tests_from_file:
if test['full_name'] == test_from_file['full_name']:
@@ -314,9 +326,9 @@ def create_mute_issues(all_tests, file_path):
)
else:
key = f"{test_from_file['testsuite']}:{test['owner']}"
- if not prepared_tests_by_suite.get(key):
- prepared_tests_by_suite[key] = []
- prepared_tests_by_suite[key].append(
+ if not temp_tests_by_suite.get(key):
+ temp_tests_by_suite[key] = []
+ temp_tests_by_suite[key].append(
{
'mute_string': f"{ test.get('suite_folder')} {test.get('test_name')}",
'test_name': test.get('test_name'),
@@ -333,9 +345,20 @@ def create_mute_issues(all_tests, file_path):
'branch': test.get('branch'),
}
)
+
+ # Split groups larger than 20 tests
+ for key, tests in temp_tests_by_suite.items():
+ if len(tests) <= 40:
+ prepared_tests_by_suite[key] = tests
+ else:
+ # Split into groups of 40
+ for i in range(0, len(tests), 40):
+ chunk = tests[i:i+40]
+ chunk_key = f"{key}_{i//40 + 1}" # Add iterator to key starting from 1
+ prepared_tests_by_suite[chunk_key] = chunk
+
results = []
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']
result = create_and_add_issue_to_project(title, body, state='Muted', owner=owner_value)
@@ -343,11 +366,42 @@ def create_mute_issues(all_tests, file_path):
break
else:
results.append(
- f"Created issue '{title}' for {prepared_tests_by_suite[item][0]['owner']}, url {result['issue_url']}"
+ {
+ 'message': f"Created issue '{title}' for TEAM:@ydb-platform/{owner_value}, url {result['issue_url']}",
+ 'owner': owner_value
+ }
)
+ # Sort results by owner
+ results.sort(key=lambda x: x['owner'])
+
+ # Group results by owner and add spacing and headers
+ formatted_results = []
+
+ # Add closed issues section if any
+ if closed_issues:
+ formatted_results.append("CLOSED ISSUES:")
+ for issue in closed_issues:
+ formatted_results.append(f"Closed {issue['url']}")
+ formatted_results.append("Unmuted tests:")
+ for test in issue['tests']:
+ formatted_results.append(f" - {test}")
+ formatted_results.append("")
+ formatted_results.append("CREATED ISSUES:")
+
+ # Add created issues
+ current_owner = None
+ for result in results:
+ if current_owner != result['owner']:
+ if formatted_results and formatted_results[-1] != "": # Add blank line between owner groups if last line is not empty
+ formatted_results.append('')
+ current_owner = result['owner']
+ # Add owner header with team URL
+ formatted_results.append(f"TEAM:@ydb-platform/{current_owner} @https://github.com/orgs/ydb-platform/teams/{current_owner}")
+ formatted_results.append(result['message'])
+
print("\n\n")
- print("\n".join(results))
+ print("\n".join(formatted_results))
if 'GITHUB_OUTPUT' in os.environ:
if 'GITHUB_WORKSPACE' not in os.environ:
raise EnvironmentError("GITHUB_WORKSPACE environment variable is not set.")
@@ -357,7 +411,7 @@ def create_mute_issues(all_tests, file_path):
with open(file_path, 'w') as f:
f.write("\n")
- f.write("\n".join(results))
+ f.write("\n".join(formatted_results))
f.write("\n")
with open(os.environ['GITHUB_OUTPUT'], 'a') as gh_out:
@@ -397,7 +451,7 @@ def mute_worker(args):
elif args.mode == 'create_issues':
file_path = args.file_path
- create_mute_issues(all_tests, file_path)
+ create_mute_issues(all_tests, file_path, close_issues=args.close_issues)
if __name__ == "__main__":
@@ -417,6 +471,7 @@ if __name__ == "__main__":
'--file_path', default=f'{repo_path}/mute_update/flaky.txt', required=False, help='file path'
)
create_issues_parser.add_argument('--branch', default='main', help='Branch to get history')
+ create_issues_parser.add_argument('--close_issues', action='store_true', default=True, help='Close issues when all tests are unmuted (default: True)')
args = parser.parse_args()
diff --git a/.github/scripts/tests/update_mute_issues.py b/.github/scripts/tests/update_mute_issues.py
index a9b8f7ebde6..4901ea500ea 100755
--- a/.github/scripts/tests/update_mute_issues.py
+++ b/.github/scripts/tests/update_mute_issues.py
@@ -18,6 +18,30 @@ CURRENT_TEST_HISTORY_DASHBOARD = "https://datalens.yandex/34xnbsom67hcq?"
# admin:org
# project
+GITHUB_MAX_BODY_LENGTH = 65000 # Setting slightly below 65536 to be safe
+
+def truncate_issue_body(body):
+ """Truncates issue body if it exceeds GitHub's maximum length.
+
+ Args:
+ body (str): The original issue body
+
+ Returns:
+ str: Truncated body if necessary, with a note about truncation
+ """
+ if len(body) <= GITHUB_MAX_BODY_LENGTH:
+ return body
+
+ truncation_message = "\n\n... [Content truncated due to length limitations] ..."
+ available_length = GITHUB_MAX_BODY_LENGTH - len(truncation_message)
+
+ # Find the last newline before the cutoff to avoid cutting in the middle of a line
+ last_newline = body.rfind('\n', 0, available_length)
+ if last_newline == -1:
+ last_newline = available_length
+
+ truncated_body = body[:last_newline] + truncation_message
+ return truncated_body
def handle_github_errors(response):
if 'errors' in response:
@@ -113,6 +137,9 @@ def create_and_add_issue_to_project(title, body, project_id=PROJECT_ID, org_name
"""
result = None
+ # Truncate body if necessary
+ body = truncate_issue_body(body)
+
# Получаем ID полей "State" и "Owner"
inner_project_id, project_fields = get_project_v2_fields(org_name, project_id)
state_field_id = None
@@ -458,7 +485,7 @@ def get_muted_tests_from_issues():
issues = get_issues_and_tests_from_project(ORG_NAME, PROJECT_ID)
muted_tests = {}
for issue in issues:
- if issues[issue]["status"] == "Muted" and issues[issue]["state"] != 'CLOSED':
+ if issues[issue]["state"] != 'CLOSED':
for test in issues[issue]['tests']:
if test not in muted_tests:
muted_tests[test] = []
@@ -470,12 +497,321 @@ def get_muted_tests_from_issues():
'status': issues[issue]['status'],
'state': issues[issue]['state'],
'branches': issues[issue]['branches'],
+ 'id': issue,
}
)
return muted_tests
+def close_issue(issue_id):
+ """Closes GitHub issue using GraphQL API.
+
+ Args:
+ issue_id (str): GitHub issue node ID
+ """
+ query = """
+ mutation ($issueId: ID!) {
+ closeIssue(input: {issueId: $issueId}) {
+ issue {
+ id
+ url
+ }
+ }
+ }
+ """
+ variables = {"issueId": issue_id}
+ result = run_query(query, variables)
+ if not result.get('errors'):
+ print(f"Issue {issue_id} closed")
+ else:
+ print(f"Error: Issue {issue_id} not closed")
+
+def add_issue_comment(issue_id, comment):
+ """Adds a comment to GitHub issue using GraphQL API.
+
+ Args:
+ issue_id (str): GitHub issue node ID
+ comment (str): Comment text
+ """
+ query = """
+ mutation ($issueId: ID!, $body: String!) {
+ addComment(input: {subjectId: $issueId, body: $body}) {
+ commentEdge {
+ node {
+ id
+ }
+ }
+ }
+ }
+ """
+ variables = {"issueId": issue_id, "body": comment}
+ result = run_query(query, variables)
+ if not result.get('errors'):
+ print(f"Added comment to issue {issue_id}")
+ else:
+ print(f"Error: Failed to add comment to issue {issue_id}")
+
+def update_issue_status(issue_id, status_field_id, status_option_id, issue_url):
+ """Updates the status of an issue in the project.
+
+ Args:
+ issue_id (str): The ID of the issue
+ status_field_id (str): The ID of the status field
+ status_option_id (str): The ID of the status option to set
+ """
+ # Get project's global ID first
+ query = """
+ {
+ organization(login: "%s") {
+ projectV2(number: %s) {
+ id
+ }
+ }
+ }
+ """ % (ORG_NAME, PROJECT_ID)
+
+ result = run_query(query)
+ if not result.get('data'):
+ print("Error: Failed to fetch project ID")
+ return
+
+ project_global_id = result['data']['organization']['projectV2']['id']
+
+ # Now update the status
+ query = """
+ mutation ($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String) {
+ updateProjectV2ItemFieldValue(input: {
+ projectId: $projectId,
+ itemId: $itemId,
+ fieldId: $fieldId,
+ value: {
+ singleSelectOptionId: $optionId
+ }
+ }) {
+ projectV2Item {
+ id
+ }
+ }
+ }
+ """
+ variables = {
+ "projectId": project_global_id,
+ "itemId": issue_id,
+ "fieldId": status_field_id,
+ "optionId": status_option_id
+ }
+ result = run_query(query, variables)
+ if not result.get('errors'):
+ print(f"Updated status for issue {issue_url}")
+ else:
+ print(f"Error: Failed to update status for issue {issue_url}")
+
+def update_all_closed_issues_status(status_field_id, unmuted_option_id):
+ """Updates status to Unmuted for all closed issues in the project.
+
+ Args:
+ status_field_id (str): The ID of the status field
+ unmuted_option_id (str): The ID of the Unmuted status option
+ """
+ has_next_page = True
+ end_cursor = "null"
+
+ while has_next_page:
+ query = """
+ {
+ organization(login: "%s") {
+ projectV2(number: %s) {
+ items(first: 100, after: %s) {
+ nodes {
+ id
+ content {
+ ... on Issue {
+ id
+ state
+ url
+ }
+ }
+ fieldValues(first: 20) {
+ nodes {
+ ... on ProjectV2ItemFieldSingleSelectValue {
+ field {
+ ... on ProjectV2SingleSelectField {
+ name
+ }
+ }
+ name
+ }
+ }
+ }
+ }
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ }
+ }
+ }
+ }
+ """ % (ORG_NAME, PROJECT_ID, end_cursor)
+
+ result = run_query(query)
+ if not result.get('data'):
+ print("Error: Failed to fetch project items")
+ return
+
+ items = result['data']['organization']['projectV2']['items']['nodes']
+ for item in items:
+ if item['content'] and item['content']['state'] == 'CLOSED':
+ # Check if status is not already Unmuted
+ current_status = None
+ for field_value in item['fieldValues']['nodes']:
+ if (field_value.get('field', {}).get('name', '').lower() == 'status' and
+ 'name' in field_value):
+ current_status = field_value.get('name')
+ break
+
+ if current_status != 'Unmuted':
+ update_issue_status(item['id'], status_field_id, unmuted_option_id, item['content']['url'])
+
+ # Update pagination info
+ page_info = result['data']['organization']['projectV2']['items']['pageInfo']
+ has_next_page = page_info['hasNextPage']
+ end_cursor = f"\"{page_info['endCursor']}\"" if page_info['endCursor'] else "null"
+
+def get_issue_comments(issue_id):
+ """Gets all comments for an issue.
+
+ Args:
+ issue_id (str): The ID of the issue
+
+ Returns:
+ list: List of comment bodies
+ """
+ query = """
+ {
+ node(id: "%s") {
+ ... on Issue {
+ comments(first: 100) {
+ nodes {
+ body
+ }
+ }
+ }
+ }
+ }
+ """ % issue_id
+
+ result = run_query(query)
+ if not result.get('data', {}).get('node', {}).get('comments', {}).get('nodes'):
+ return []
+
+ return [comment['body'] for comment in result['data']['node']['comments']['nodes']]
+
+def has_unmute_comment(comments, unmuted_tests):
+ """Checks if there's already a comment about unmuting these tests.
+
+ Args:
+ comments (list): List of comment bodies
+ unmuted_tests (list): List of unmuted test names
+
+ Returns:
+ bool: True if a comment about unmuting these tests exists
+ """
+ test_set = set(unmuted_tests)
+ for comment in comments:
+ if "tests have been unmuted" in comment:
+ # Extract test names from the comment
+ comment_tests = set()
+ for line in comment.split('\n'):
+ if line.startswith('- Test '):
+ test_name = line[7:] # Remove '- Test ' prefix
+ comment_tests.add(test_name.replace(' unmuted', ''))
+ # If all current unmuted tests are in the comment, we don't need a new one
+ if test_set.issubset(comment_tests):
+ return True
+ return False
+
+def close_unmuted_issues(muted_tests_set, do_not_close_issues=False):
+ """Closes issues where all tests are no longer muted.
+
+ Args:
+ muted_tests_set (set): Set of currently muted test names
+ do_not_close_issues (bool): If True, issues will NOT be closed. If False, issues will be closed.
+
+ Returns:
+ list: List of dictionaries containing information about closed issues
+ """
+ issues = get_muted_tests_from_issues()
+ closed_issues = []
+
+ # Get status field ID and Unmuted option ID
+ _, project_fields = get_project_v2_fields(ORG_NAME, PROJECT_ID)
+ status_field_id = None
+ unmuted_option_id = None
+ for field in project_fields:
+ if field.get('name') and field['name'].lower() == "status":
+ status_field_id = field['id']
+ for option in field['options']:
+ if option['name'].lower() == "unmuted":
+ unmuted_option_id = option['id']
+ break
+ break
+
+ if not status_field_id or not unmuted_option_id:
+ print("Warning: Could not find status field or Unmuted option")
+ return closed_issues
+
+ # First, group tests by issue ID
+ tests_by_issue = {}
+ for test_name, issue_data_list in issues.items():
+ for issue_data in issue_data_list:
+ issue_id = issue_data['id']
+ if issue_id not in tests_by_issue:
+ tests_by_issue[issue_id] = {
+ 'tests': set(),
+ 'url': issue_data['url'],
+ 'state': issue_data['state'],
+ 'status': issue_data['status']
+ }
+ tests_by_issue[issue_id]['tests'].add(test_name)
+
+ # Then check each issue
+ for issue_id, issue_info in tests_by_issue.items():
+ if issue_info['state'] != 'CLOSED':
+ unmuted_tests = [test for test in issue_info['tests'] if test not in muted_tests_set]
+ if unmuted_tests:
+
+ # If all tests are unmuted, close the issue
+ if len(unmuted_tests) == len(issue_info['tests']):
+ if not has_unmute_comment(existing_comments, unmuted_tests):
+ comment = "All tests have been unmuted:\n" + "\n".join(f"- Test {test}" for test in sorted(unmuted_tests))
+ add_issue_comment(issue_id, comment)
+ if not do_not_close_issues:
+ close_issue(issue_id)
+ closed_issues.append({
+ 'url': issue_info['url'],
+ 'tests': sorted(list(issue_info['tests']))
+ })
+ print(f"{'Would close' if do_not_close_issues else 'Closed'} issue as all its tests are no longer muted: {issue_info['url']}")
+ print(f"Unmuted tests: {', '.join(sorted(unmuted_tests))}")
+ # If some tests are unmuted but not all, just add a comment if needed
+ else:
+ # Get existing comments
+ existing_comments = get_issue_comments(issue_id)
+ if not has_unmute_comment(existing_comments, unmuted_tests):
+ comment = "Some tests have been unmuted:\n" + "\n".join(f"- Test {test}" for test in sorted(unmuted_tests))
+ add_issue_comment(issue_id, comment)
+ print(f"Added comment about unmuted tests to issue: {issue_info['url']}")
+ print(f"Unmuted tests: {', '.join(sorted(unmuted_tests))}")
+
+ # Update status for all closed issues
+ print("Updating status for all closed issues...")
+ update_all_closed_issues_status(status_field_id, unmuted_option_id)
+
+ return closed_issues
+
+
def main():
if "GITHUB_TOKEN" not in os.environ: