diff options
| author | Kirill Rysin <[email protected]> | 2025-05-26 18:30:06 +0200 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-05-26 19:30:06 +0300 |
| commit | 9b92253b68c58fba01919cadf28a47bb460b90dd (patch) | |
| tree | a81bf6c355f74bbe890c9ba2f53885eb3caa7d41 | |
| parent | 521a632276395a05fe4dc7580b387ff30041bba8 (diff) | |
CI: Fix create issues for muted tests + close unmuted tests (#18863)
| -rwxr-xr-x | .github/scripts/tests/create_new_muted_ya.py | 73 | ||||
| -rwxr-xr-x | .github/scripts/tests/update_mute_issues.py | 338 |
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: |
