diff --git a/services/github/check_suites/test_get_failed_check_runs.py b/services/github/check_suites/test_get_failed_check_runs.py index 101010795..d9ea03b2d 100644 --- a/services/github/check_suites/test_get_failed_check_runs.py +++ b/services/github/check_suites/test_get_failed_check_runs.py @@ -1,15 +1,14 @@ from unittest.mock import Mock, patch import requests -from services.github.check_suites.get_failed_check_runs import \ - get_failed_check_runs_from_check_suite +from services.github.check_suites.get_failed_check_runs import ( + get_failed_check_runs_from_check_suite, +) @patch("services.github.check_suites.get_failed_check_runs.requests.get") @patch("services.github.check_suites.get_failed_check_runs.create_headers") -def test_returns_failed_check_runs_when_mixed_outcomes( - mock_create_headers, mock_get -): +def test_returns_failed_check_runs_when_mixed_outcomes(mock_create_headers, mock_get): """Returns only failed check runs when suite includes failed and non-failed runs""" mock_create_headers.return_value = {"Authorization": "Bearer test-token"} @@ -85,9 +84,7 @@ def test_returns_empty_when_no_failures(mock_create_headers, mock_get): @patch("services.github.check_suites.get_failed_check_runs.requests.get") @patch("services.github.check_suites.get_failed_check_runs.create_headers") @patch("builtins.print") -def test_returns_empty_when_api_error( - mock_print, mock_create_headers, mock_get -): +def test_returns_empty_when_api_error(mock_print, mock_create_headers, mock_get): """Returns empty list when API returns error status code""" mock_create_headers.return_value = {"Authorization": "Bearer test-token"} @@ -125,9 +122,7 @@ def test_returns_empty_when_not_found(mock_create_headers, mock_get): @patch("services.github.check_suites.get_failed_check_runs.requests.get") @patch("services.github.check_suites.get_failed_check_runs.create_headers") @patch("builtins.print") -def test_returns_empty_when_unauthorized( - mock_print, mock_create_headers, mock_get -): +def test_returns_empty_when_unauthorized(mock_print, mock_create_headers, mock_get): """Returns empty list when authentication fails""" mock_create_headers.return_value = {"Authorization": "Bearer test-token"} @@ -167,9 +162,7 @@ def test_returns_empty_when_response_has_no_check_runs_key( @patch("services.github.check_suites.get_failed_check_runs.requests.get") @patch("services.github.check_suites.get_failed_check_runs.create_headers") @patch("builtins.print") -def test_returns_empty_when_forbidden( - mock_print, mock_create_headers, mock_get -): +def test_returns_empty_when_forbidden(mock_print, mock_create_headers, mock_get): """Returns empty list when access is forbidden""" mock_create_headers.return_value = {"Authorization": "Bearer test-token"} @@ -188,9 +181,7 @@ def test_returns_empty_when_forbidden( @patch("services.github.check_suites.get_failed_check_runs.requests.get") @patch("services.github.check_suites.get_failed_check_runs.create_headers") -def test_returns_empty_when_check_runs_array_is_empty( - mock_create_headers, mock_get -): +def test_returns_empty_when_check_runs_array_is_empty(mock_create_headers, mock_get): """Returns empty list when check_runs array is empty""" mock_create_headers.return_value = {"Authorization": "Bearer test-token"} @@ -422,9 +413,7 @@ def test_returns_empty_when_json_parsing_fails(mock_create_headers, mock_get): @patch("services.github.check_suites.get_failed_check_runs.requests.get") @patch("services.github.check_suites.get_failed_check_runs.create_headers") -def test_skips_check_runs_with_empty_string_conclusion( - mock_create_headers, mock_get -): +def test_skips_check_runs_with_empty_string_conclusion(mock_create_headers, mock_get): """Skips check runs with empty string as conclusion""" mock_create_headers.return_value = {"Authorization": "Bearer test-token"} diff --git a/services/github/pulls/create_pull_request.py b/services/github/pulls/create_pull_request.py index 4d86516bd..7452cf381 100644 --- a/services/github/pulls/create_pull_request.py +++ b/services/github/pulls/create_pull_request.py @@ -6,8 +6,8 @@ from utils.error.handle_exceptions import handle_exceptions -@handle_exceptions(default_return_value=None, raise_on_error=False) -def create_pull_request(body: str, title: str, base_args: BaseArgs) -> str | None: +@handle_exceptions(raise_on_error=True) +def create_pull_request(body: str, title: str, base_args: BaseArgs): """https://docs.github.com/en/rest/pulls/pulls#create-a-pull-request""" owner, repo, base, head, token = ( base_args["owner"], @@ -24,13 +24,15 @@ def create_pull_request(body: str, title: str, base_args: BaseArgs) -> str | Non ) if response.status_code == 422: msg = f"{create_pull_request.__name__} encountered an HTTPError: 422 Client Error: Unprocessable Entity for url: {response.url}, which is because no commits between the base branch and the working branch." - print(msg) - return None + raise requests.exceptions.HTTPError(msg) + response.raise_for_status() pr_data = response.json() + pr_url: str = pr_data["html_url"] + pr_number: int = pr_data["number"] # Add reviewers to the pull request - base_args["pr_number"] = pr_data["number"] + base_args["pr_number"] = pr_number add_reviewers(base_args=base_args) - return pr_data["html_url"] + return pr_url, pr_number diff --git a/services/github/pulls/test_create_pull_request.py b/services/github/pulls/test_create_pull_request.py index c2383a162..196dcff4d 100644 --- a/services/github/pulls/test_create_pull_request.py +++ b/services/github/pulls/test_create_pull_request.py @@ -65,7 +65,7 @@ def test_create_pull_request_success( result = create_pull_request("Test body", "Test title", base_args) - assert result == "https://github.com/owner/repo/pull/123" + assert result == ("https://github.com/owner/repo/pull/123", 123) mock_post.assert_called_once_with( url="https://api.github.com/repos/gitautoai/gitauto/pulls", headers={"Authorization": "Bearer token"}, @@ -89,9 +89,7 @@ def test_create_pull_request_success( @patch("services.github.pulls.create_pull_request.add_reviewers") @patch("services.github.pulls.create_pull_request.create_headers") @patch("services.github.pulls.create_pull_request.requests.post") -@patch("builtins.print") def test_create_pull_request_422_error( - mock_print, mock_post, mock_create_headers, mock_add_reviewers, @@ -101,9 +99,14 @@ def test_create_pull_request_422_error( mock_post.return_value = mock_422_response mock_create_headers.return_value = {"Authorization": "Bearer token"} - result = create_pull_request("Test body", "Test title", base_args) + with pytest.raises(requests.exceptions.HTTPError) as exc_info: + create_pull_request("Test body", "Test title", base_args) + + assert "422 Client Error: Unprocessable Entity" in str(exc_info.value) + assert "no commits between the base branch and the working branch" in str( + exc_info.value + ) - assert result is None mock_post.assert_called_once_with( url="https://api.github.com/repos/gitautoai/gitauto/pulls", headers={"Authorization": "Bearer token"}, @@ -120,9 +123,6 @@ def test_create_pull_request_422_error( mock_422_response.json.assert_not_called() mock_add_reviewers.assert_not_called() - expected_msg = "create_pull_request encountered an HTTPError: 422 Client Error: Unprocessable Entity for url: https://api.github.com/repos/owner/repo/pulls, which is because no commits between the base branch and the working branch." - mock_print.assert_called_once_with(expected_msg) - @patch("services.github.pulls.create_pull_request.add_reviewers") @patch("services.github.pulls.create_pull_request.create_headers") @@ -137,9 +137,9 @@ def test_create_pull_request_http_error( mock_post.return_value = mock_error_response mock_create_headers.return_value = {"Authorization": "Bearer token"} - result = create_pull_request("Test body", "Test title", base_args) + with pytest.raises(requests.HTTPError): + create_pull_request("Test body", "Test title", base_args) - assert result is None mock_post.assert_called_once_with( url="https://api.github.com/repos/gitautoai/gitauto/pulls", headers={"Authorization": "Bearer token"}, @@ -171,9 +171,9 @@ def test_create_pull_request_json_error( mock_create_headers.return_value = {"Authorization": "Bearer token"} mock_response.json.side_effect = ValueError("Invalid JSON") - result = create_pull_request("Test body", "Test title", base_args) + with pytest.raises(ValueError): + create_pull_request("Test body", "Test title", base_args) - assert result is None mock_post.assert_called_once() mock_create_headers.assert_called_once_with(token="test-token-mock") mock_response.raise_for_status.assert_called_once() @@ -195,9 +195,11 @@ def test_create_pull_request_add_reviewers_error( mock_create_headers.return_value = {"Authorization": "Bearer token"} mock_add_reviewers.side_effect = Exception("Reviewer error") - result = create_pull_request("Test body", "Test title", base_args) + with pytest.raises(Exception) as exc_info: + create_pull_request("Test body", "Test title", base_args) + + assert "Reviewer error" in str(exc_info.value) - assert result is None mock_post.assert_called_once() mock_create_headers.assert_called_once_with(token="test-token-mock") mock_response.raise_for_status.assert_called_once() @@ -219,7 +221,7 @@ def test_create_pull_request_empty_strings( result = create_pull_request("", "", base_args) - assert result == "https://github.com/owner/repo/pull/123" + assert result == ("https://github.com/owner/repo/pull/123", 123) mock_post.assert_called_once_with( url="https://api.github.com/repos/gitautoai/gitauto/pulls", headers={"Authorization": "Bearer token"}, @@ -245,7 +247,7 @@ def test_create_pull_request_different_branches( result = create_pull_request("Feature body", "Feature title", base_args) - assert result == "https://github.com/owner/repo/pull/123" + assert result == ("https://github.com/owner/repo/pull/123", 123) mock_post.assert_called_once_with( url="https://api.github.com/repos/gitautoai/gitauto/pulls", headers={"Authorization": "Bearer token"}, @@ -268,9 +270,9 @@ def test_create_pull_request_requests_exception( mock_post.side_effect = requests.RequestException("Network error") mock_create_headers.return_value = {"Authorization": "Bearer token"} - result = create_pull_request("Test body", "Test title", base_args) + with pytest.raises(requests.RequestException): + create_pull_request("Test body", "Test title", base_args) - assert result is None mock_post.assert_called_once() mock_create_headers.assert_called_once_with(token="test-token-mock") mock_add_reviewers.assert_not_called() @@ -284,26 +286,13 @@ def test_create_pull_request_create_headers_exception( ): mock_create_headers.side_effect = Exception("Header creation error") - result = create_pull_request("Test body", "Test title", base_args) + with pytest.raises(Exception) as exc_info: + create_pull_request("Test body", "Test title", base_args) + + assert "Header creation error" in str(exc_info.value) - assert result is None mock_create_headers.assert_called_once_with(token="test-token-mock") mock_post.assert_not_called() mock_add_reviewers.assert_not_called() -@patch("services.github.pulls.create_pull_request.add_reviewers") -@patch("services.github.pulls.create_pull_request.create_headers") -@patch("services.github.pulls.create_pull_request.requests.post") -def test_create_pull_request_key_error_in_base_args( - mock_post, mock_create_headers, mock_add_reviewers, test_owner, test_repo -): - mock_create_headers.return_value = {"Authorization": "Bearer token"} - incomplete_base_args = {"owner": test_owner, "repo": test_repo} - - result = create_pull_request("Test body", "Test title", incomplete_base_args) - - assert result is None - mock_create_headers.assert_not_called() - mock_post.assert_not_called() - mock_add_reviewers.assert_not_called() diff --git a/services/webhook/issue_handler.py b/services/webhook/issue_handler.py index 4b2e0f795..0fa9bf95c 100644 --- a/services/webhook/issue_handler.py +++ b/services/webhook/issue_handler.py @@ -6,7 +6,7 @@ from typing import Literal, cast # Local imports -from config import PRODUCT_ID, PR_BODY_STARTS_WITH +from config import GITHUB_API_URL, PRODUCT_ID, PR_BODY_STARTS_WITH from constants.messages import COMPLETED_PR, SETTINGS_LINKS from services.chat_with_agent import chat_with_agent from services.resend.send_email import send_email @@ -25,15 +25,18 @@ from services.github.commits.get_latest_remote_commit_sha import ( get_latest_remote_commit_sha, ) +from services.github.commits.replace_remote_file import replace_remote_file_content +from services.github.files.get_raw_content import get_raw_content from services.github.files.get_remote_file_content_by_url import ( get_remote_file_content_by_url, ) from services.github.markdown.render_text import render_text from services.github.pulls.create_pull_request import create_pull_request +from services.github.pulls.get_pull_request_files import get_pull_request_files from services.github.reactions.add_reaction_to_issue import add_reaction_to_issue from services.github.types.github_types import GitHubLabeledPayload -from services.jira.types import JiraPayload from services.github.utils.deconstruct_github_payload import deconstruct_github_payload +from services.jira.types import JiraPayload # Local imports (Jira, OpenAI, Slack) from services.jira.deconstruct_jira_payload import deconstruct_jira_payload @@ -53,16 +56,18 @@ from services.supabase.owners.get_owner import get_owner # Local imports (Utils) +from utils.files.is_test_file import is_test_file +from utils.files.merge_test_file_headers import merge_test_file_headers from utils.images.get_base64 import get_base64 from utils.progress_bar.progress_bar import create_progress_bar -from utils.text.comment_identifiers import PROGRESS_BAR_FILLED, PROGRESS_BAR_EMPTY +from utils.text.comment_identifiers import PROGRESS_BAR_EMPTY, PROGRESS_BAR_FILLED from utils.text.text_copy import ( UPDATE_COMMENT_FOR_422, git_command, pull_request_completed, ) -from utils.time.is_lambda_timeout_approaching import is_lambda_timeout_approaching from utils.time.get_timeout_message import get_timeout_message +from utils.time.is_lambda_timeout_approaching import is_lambda_timeout_approaching from utils.urls.extract_urls import extract_image_urls @@ -355,16 +360,17 @@ def create_pr_from_issue( issue_link: str = f"{PR_BODY_STARTS_WITH}{issue_number}\n\n" pr_body = issue_link + git_command(new_branch_name=new_branch_name) - pr_url = create_pull_request(body=pr_body, title=issue_title, base_args=base_args) + pr_url, pr_number = create_pull_request( + body=pr_body, title=issue_title, base_args=base_args + ) - if pr_url is not None: - comment_body = f"Created pull request: {pr_url}" - p += 5 - log_messages.append(comment_body) - update_comment( - body=create_progress_bar(p=p, msg="\n".join(log_messages)), - base_args=base_args, - ) + comment_body = f"Created pull request: {pr_url}" + p += 5 + log_messages.append(comment_body) + update_comment( + body=create_progress_bar(p=p, msg="\n".join(log_messages)), + base_args=base_args, + ) # Loop a process explore repo and commit changes until the ticket is resolved previous_calls = [] @@ -485,6 +491,37 @@ def create_pr_from_issue( # Because the agent is committing changes, keep doing the loop retry_count = 0 + # Add headers to test files before triggering CI + files_url = ( + f"{GITHUB_API_URL}/repos/{owner_name}/{repo_name}/pulls/{pr_number}/files" + ) + changed_files = get_pull_request_files(url=files_url, token=token) + + for file_change in changed_files: + file_path = file_change["filename"] + if not is_test_file(file_path): + continue + + file_content = get_raw_content( + owner=owner_name, + repo=repo_name, + file_path=file_path, + ref=new_branch_name, + token=token, + ) + if not file_content: + continue + + updated_content = merge_test_file_headers(file_content, file_path) + if not updated_content or updated_content == file_content: + continue + + replace_remote_file_content( + file_content=updated_content, + file_path=file_path, + base_args=base_args, + ) + # Trigger final test workflows with an empty commit comment_body = "Triggering workflows..." p += 5 @@ -494,31 +531,20 @@ def create_pr_from_issue( ) create_empty_commit(base_args=base_args) - # Update the issue comment based on if the PR was created or not - pr_number = None - if pr_url is not None: - is_completed = True - pr_number = int(pr_url.split("/")[-1]) - body_after_pr = pull_request_completed( - issuer_name=issuer_name, - sender_name=sender_name, - pr_url=pr_url, - is_automation=is_automation, - ) - - # Success notification - success_msg = f"Work completed for {owner_name}/{repo_name} PR: {pr_url}" - slack_notify(success_msg, thread_ts) - else: - is_completed = False - body_after_pr = UPDATE_COMMENT_FOR_422 - - # Failure notification - failure_msg = f"@channel Failed to create PR for {owner_name}/{repo_name}" - slack_notify(failure_msg, thread_ts) - + # Update the issue comment + is_completed = True + body_after_pr = pull_request_completed( + issuer_name=issuer_name, + sender_name=sender_name, + pr_url=pr_url, + is_automation=is_automation, + ) update_comment(body=body_after_pr, base_args=base_args) + # Success notification + success_msg = f"Work completed for {owner_name}/{repo_name} PR: {pr_url}" + slack_notify(success_msg, thread_ts) + end_time = time.time() update_usage( usage_id=usage_id, diff --git a/services/webhook/test_issue_handler.py b/services/webhook/test_issue_handler.py index 349e5dc5e..0a0b248ef 100644 --- a/services/webhook/test_issue_handler.py +++ b/services/webhook/test_issue_handler.py @@ -164,7 +164,10 @@ def test_issue_handler_token_accumulation( mock_get_latest_remote_commit_sha.return_value = "abc123" mock_create_remote_branch.return_value = None - mock_create_pull_request.return_value = "https://github.com/test/repo/pull/123" + mock_create_pull_request.return_value = ( + "https://github.com/test/repo/pull/123", + 123, + ) mock_create_empty_commit.return_value = None mock_check_branch_exists.return_value = True mock_insert_credit.return_value = None diff --git a/utils/files/merge_test_file_headers.py b/utils/files/merge_test_file_headers.py new file mode 100644 index 000000000..d6ac093b3 --- /dev/null +++ b/utils/files/merge_test_file_headers.py @@ -0,0 +1,110 @@ +import re +from typing import TypedDict +from utils.error.handle_exceptions import handle_exceptions +from utils.files.is_test_file import is_test_file + + +class LanguageConfig(TypedDict): + extensions: list[str] + rules: list[str] + pattern: str + format: str + + +TEST_FILE_HEADERS: dict[str, LanguageConfig] = { + "typescript": { + "extensions": [".ts", ".tsx"], + "rules": [ + "@typescript-eslint/no-unused-vars", + "@typescript-eslint/no-var-requires", + ], + "pattern": r"/\*\s*eslint-disable\s+([^*]+)\s*\*/", + "format": "/* eslint-disable {rules} */", + }, + "javascript": { + "extensions": [".js", ".jsx"], + "rules": ["no-unused-vars"], + "pattern": r"/\*\s*eslint-disable\s+([^*]+)\s*\*/", + "format": "/* eslint-disable {rules} */", + }, + "python": { + "extensions": [".py"], + "rules": ["redefined-outer-name", "unused-argument"], + "pattern": r"#\s*pylint:\s*disable=([^\n]+)", + "format": "# pylint: disable={rules}", + }, + "php": { + "extensions": [".php"], + "rules": ["unused-variable"], + "pattern": r"//\s*phpcs:disable\s+([^\n]+)", + "format": "// phpcs:disable {rules}", + }, +} + + +@handle_exceptions(default_return_value="", raise_on_error=False) +def merge_test_file_headers( + file_content: str | None, file_path: str | None +) -> str | None: + if not isinstance(file_content, str) or not isinstance(file_path, str): + return file_content + + if not is_test_file(file_path): + return file_content + + file_path_lower = file_path.lower() + + config: LanguageConfig | None = None + for cfg in TEST_FILE_HEADERS.values(): + for ext in cfg["extensions"]: + if file_path_lower.endswith(ext): + config = cfg + break + if config: + break + + if not config: + return file_content + + needed_rules = set(config["rules"]) + existing_rules = set() + + pattern = config["pattern"] + matches = list(re.finditer(pattern, file_content)) + + for match in matches: + rules_text = match.group(1).strip() + if "," in rules_text: + rules = [r.strip() for r in rules_text.split(",")] + else: + rules = [rules_text.strip()] + + for rule in rules: + if rule: + existing_rules.add(rule) + + missing_rules = needed_rules - existing_rules + + if not missing_rules: + return file_content + + all_rules = sorted(existing_rules | needed_rules) + rules_str = ", ".join(all_rules) + new_header = config["format"].format(rules=rules_str) + + if matches: + content_without_comments = file_content + for match in reversed(matches): + end_pos = match.end() + if ( + end_pos < len(content_without_comments) + and content_without_comments[end_pos] == "\n" + ): + end_pos += 1 + content_without_comments = ( + content_without_comments[: match.start()] + + content_without_comments[end_pos:] + ) + content_without_comments = content_without_comments.lstrip() + return new_header + "\n" + content_without_comments + return new_header + "\n" + file_content diff --git a/utils/files/test_merge_test_file_headers.py b/utils/files/test_merge_test_file_headers.py new file mode 100644 index 000000000..ac4e4262f --- /dev/null +++ b/utils/files/test_merge_test_file_headers.py @@ -0,0 +1,146 @@ +from utils.files.merge_test_file_headers import merge_test_file_headers + + +def test_merge_test_file_headers_pattern1_no_ignore_lines(): + file_content = """import { render } from '@testing-library/react'; + +describe('Test', () => {}); +""" + result = merge_test_file_headers(file_content, "Component.test.tsx") + expected = """/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-var-requires */ +import { render } from '@testing-library/react'; + +describe('Test', () => {}); +""" + assert result == expected + + +def test_merge_test_file_headers_pattern2_has_some_ignore_lines(): + file_content = """/* eslint-disable no-console */ +import { render } from '@testing-library/react'; + +describe('Test', () => {}); +""" + result = merge_test_file_headers(file_content, "Component.test.tsx") + expected = """/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-var-requires, no-console */ +import { render } from '@testing-library/react'; + +describe('Test', () => {}); +""" + assert result == expected + + +def test_merge_test_file_headers_pattern3_already_has_exact_rules(): + file_content = """/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-var-requires */ +import { render } from '@testing-library/react'; + +describe('Test', () => {}); +""" + result = merge_test_file_headers(file_content, "Component.test.tsx") + assert result == file_content + + +def test_merge_test_file_headers_python(): + file_content = """import pytest + +def test_function(): + assert True +""" + result = merge_test_file_headers(file_content, "test_module.py") + expected = """# pylint: disable=redefined-outer-name, unused-argument +import pytest + +def test_function(): + assert True +""" + assert result == expected + + +def test_merge_test_file_headers_comma_separated_existing(): + file_content = """/* eslint-disable no-console, no-debugger */ +import { render } from '@testing-library/react'; +""" + result = merge_test_file_headers(file_content, "Component.test.tsx") + expected = """/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-var-requires, no-console, no-debugger */ +import { render } from '@testing-library/react'; +""" + assert result == expected + + +def test_merge_test_file_headers_non_test_file(): + file_content = """import React from 'react'; +""" + result = merge_test_file_headers(file_content, "Component.tsx") + assert result == file_content + + +def test_merge_test_file_headers_invalid_input(): + result = merge_test_file_headers(None, "test.tsx") + assert result is None + + result = merge_test_file_headers("content", None) + assert result == "content" + + +def test_merge_test_file_headers_multiple_ignore_lines(): + file_content = """/* eslint-disable no-console */ +import { render } from '@testing-library/react'; +/* eslint-disable no-debugger */ + +describe('Test', () => {}); +""" + result = merge_test_file_headers(file_content, "Component.test.tsx") + expected = """/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-var-requires, no-console, no-debugger */ +import { render } from '@testing-library/react'; + +describe('Test', () => {}); +""" + assert result == expected + + +def test_merge_test_file_headers_has_extra_rules(): + file_content = """/* eslint-disable @typescript-eslint/no-unused-vars, @typescript-eslint/no-var-requires, no-console, no-debugger */ +import { render } from '@testing-library/react'; + +describe('Test', () => {}); +""" + result = merge_test_file_headers(file_content, "Component.test.tsx") + assert result == file_content + + +def test_merge_test_file_headers_javascript(): + file_content = """const render = require('@testing-library/react').render; + +describe('Test', () => {}); +""" + result = merge_test_file_headers(file_content, "Component.test.js") + expected = """/* eslint-disable no-unused-vars */ +const render = require('@testing-library/react').render; + +describe('Test', () => {}); +""" + assert result == expected + + +def test_merge_test_file_headers_php(): + file_content = r"""assertTrue(true); + } +} +""" + result = merge_test_file_headers(file_content, "MyTest.php") + expected = r"""// phpcs:disable unused-variable +assertTrue(true); + } +} +""" + assert result == expected