# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license from datetime import datetime, timezone from unittest.mock import MagicMock, patch from actions.dispatch_actions import ( RUN_ALL_KEYWORD, RUN_CI_KEYWORD, RUN_DOCKER_KEYWORD, get_pr_branch, main, trigger_and_get_workflow_info, update_comment, ) def test_get_pr_branch(): """Test getting PR branch name.""" mock_event = MagicMock() mock_event.event_data = {"issue": {"number": 123}} mock_event.get_repo_data.return_value = { "head": {"ref": "feature-branch", "repo": {"id": 1}}, "base": {"repo": {"id": 1}}, } branch, temp_branch = get_pr_branch(mock_event) assert branch == "feature-branch" assert temp_branch is None mock_event.get_repo_data.assert_called_once_with("pulls/123") def test_get_pr_branch_fork(): """Test getting PR branch name for fork PRs.""" mock_event = MagicMock() mock_event.event_data = {"issue": {"number": 456}} mock_event.repository = "base/repo" mock_event.get_repo_data.return_value = { "head": {"ref": "fork-branch", "sha": "abc123", "repo": {"id": 2, "full_name": "fork/repo"}}, "base": {"repo": {"id": 1}}, } with patch("time.time", return_value=1234567.890), patch("subprocess.run") as mock_run, patch( "os.environ.get", return_value="test-token" ): branch, temp_branch = get_pr_branch(mock_event) assert branch == "temp-ci-456-1234567890" assert temp_branch == "temp-ci-456-1234567890" # Verify git commands were called assert mock_run.call_count == 4 # clone, remote add, fetch, push def test_get_pr_branch_fork_requires_token(): """Fork PRs need a token to push the temporary branch.""" mock_event = MagicMock() mock_event.event_data = {"issue": {"number": 456}} mock_event.get_repo_data.return_value = { "head": {"ref": "fork-branch", "repo": {"id": 2, "full_name": "fork/repo"}}, "base": {"repo": {"id": 1}}, } try: with patch("os.environ.get", return_value=None): get_pr_branch(mock_event) except ValueError as e: assert str(e) == "GITHUB_TOKEN environment variable is not set" else: raise AssertionError("Expected missing token to raise") def test_get_pr_branch_fork_sanitizes_git_errors(): """Git errors should not leak the GitHub token.""" import subprocess mock_event = MagicMock() mock_event.event_data = {"issue": {"number": 456}} mock_event.repository = "base/repo" mock_event.get_repo_data.return_value = { "head": {"ref": "fork-branch", "repo": {"id": 2, "full_name": "fork/repo"}}, "base": {"repo": {"id": 1}}, } error = subprocess.CalledProcessError(128, ["git"], stderr=b"https://x-access-token:test-token@github.com") try: with patch("subprocess.run", side_effect=error), patch("os.environ.get", return_value="test-token"): get_pr_branch(mock_event) except RuntimeError as e: assert "test-token" not in str(e) assert "***TOKEN***" in str(e) else: raise AssertionError("Expected git failure to raise") def test_trigger_and_get_workflow_info(): """Test triggering workflows and getting info.""" mock_event = MagicMock() mock_event.repository = "test/repo" # Mock the workflow and runs responses separately workflow_response = MagicMock() workflow_response.status_code = 200 workflow_response.json.return_value = {"name": "CI Workflow"} runs_response = MagicMock() runs_response.status_code = 200 runs_response.json.return_value = { "workflow_runs": [{"html_url": "https://github.com/test/repo/actions/runs/123", "run_number": 42}] } # Set up get method to return different responses for different URLs def get_side_effect(url): if "workflows/ci.yml" in url and "runs" not in url: return workflow_response elif "workflows/ci.yml/runs" in url: return runs_response # Return default response for unexpected URLs default = MagicMock() default.status_code = 404 return default mock_event.get.side_effect = get_side_effect # Use patch to skip time.sleep and limit to one workflow with patch("time.sleep"): results = trigger_and_get_workflow_info(mock_event, "feature-branch", ["ci.yml"]) # Check results assert len(results) == 1 assert results[0]["name"] == "CI Workflow" assert results[0]["run_number"] == 42 def test_trigger_and_get_workflow_info_reports_dispatch_failures(): """Failed workflow dispatches are returned without polling for runs.""" mock_event = MagicMock() mock_event.repository = "test/repo" response = MagicMock() response.status_code = 422 response.json.return_value = {"message": "No ref found for: deleted-branch"} mock_event.post.return_value = response with patch("time.sleep") as mock_sleep: results = trigger_and_get_workflow_info(mock_event, "deleted-branch", ["ci.yml"]) assert results == [ { "name": "Ci", "file": "ci.yml", "url": "https://github.com/test/repo/actions/workflows/ci.yml", "run_number": None, "error": "No ref found for: deleted-branch", } ] mock_sleep.assert_not_called() mock_event.get.assert_not_called() def test_trigger_and_get_workflow_info_with_temp_branch(): """Test that temp branches are deleted after triggering workflows.""" mock_event = MagicMock() mock_event.repository = "test/repo" workflow_response = MagicMock() workflow_response.status_code = 200 workflow_response.json.return_value = {"name": "CI Workflow"} runs_response = MagicMock() runs_response.status_code = 200 runs_response.json.return_value = {"workflow_runs": [{"html_url": "https://example.com", "run_number": 1}]} def get_side_effect(url): if "workflows/ci.yml" in url and "runs" not in url: return workflow_response elif "runs" in url: return runs_response default = MagicMock() default.status_code = 404 return default mock_event.get.side_effect = get_side_effect with patch("time.sleep"): trigger_and_get_workflow_info(mock_event, "temp-ci-123-456", ["ci.yml"], temp_branch="temp-ci-123-456") # Verify temp branch was deleted mock_event.delete.assert_called_once_with("https://api.github.com/repos/test/repo/git/refs/heads/temp-ci-123-456") def test_update_comment_function(): """Test updating comment with workflow info.""" mock_event = MagicMock() mock_event.repository = "test/repo" mock_event.event_data = {"comment": {"id": 456}} comment_body = f"Run tests please {RUN_CI_KEYWORD}" triggered_actions = [ { "name": "CI Workflow", "file": "ci.yml", "url": "https://github.com/test/repo/actions/workflows/ci.yml", "run_number": 42, } ] # Mock datetime to have a consistent timestamp with patch("actions.dispatch_actions.datetime") as mock_datetime: mock_datetime.now.return_value = datetime(2023, 1, 1, 12, 0, 0, tzinfo=timezone.utc) # Call without capturing return value update_comment(mock_event, comment_body, RUN_CI_KEYWORD, triggered_actions) # Check that patch was called with expected content mock_event.patch.assert_called_once() # Verify key content in the comment body args, kwargs = mock_event.patch.call_args assert "https://api.github.com/repos/test/repo/issues/comments/456" in args[0] assert "Actions Trigger" in kwargs["json"]["body"] assert "CI Workflow" in kwargs["json"]["body"] assert "2023-01-01 12:00:00 UTC" in kwargs["json"]["body"] assert ( f"(available commands are `{RUN_ALL_KEYWORD}`, `{RUN_CI_KEYWORD}`, and `{RUN_DOCKER_KEYWORD}`):" in kwargs["json"]["body"] ) def test_update_comment_skips_empty_actions(): """No comment update is needed if no workflow was triggered.""" mock_event = MagicMock() update_comment(mock_event, f"Run tests {RUN_CI_KEYWORD}", RUN_CI_KEYWORD, []) mock_event.patch.assert_not_called() def test_update_comment_reports_failed_action_guidance(): """Failed dispatch comments include deleted-branch guidance from the GitHub error.""" mock_event = MagicMock() mock_event.repository = "test/repo" mock_event.event_data = {"comment": {"id": 456}} update_comment( mock_event, f"Run tests {RUN_CI_KEYWORD}", RUN_CI_KEYWORD, [ { "name": "CI", "file": "ci.yml", "url": "https://github.com/test/repo/actions/workflows/ci.yml", "run_number": None, "error": "No ref found for: deleted-branch", } ], ) body = mock_event.patch.call_args.kwargs["json"]["body"] assert "No GitHub Actions workflows were started" in body assert "restore it or open a new PR from an existing branch" in body def test_main_triggers_ci_only(): """Test main function triggers only CI for the CI command.""" with patch("actions.dispatch_actions.Action") as MockAction: mock_event = MockAction.return_value mock_event.event_name = "issue_comment" mock_event.repository = "test/repo" mock_event.event_data = { "action": "created", "issue": {"pull_request": {}}, "comment": {"body": f"Please run CI {RUN_CI_KEYWORD}", "user": {"login": "testuser"}, "id": 789}, } mock_event.is_org_member.return_value = True with patch("actions.dispatch_actions.get_pr_branch") as mock_get_branch, patch( "actions.dispatch_actions.trigger_and_get_workflow_info" ) as mock_trigger, patch("actions.dispatch_actions.update_comment"): mock_get_branch.return_value = ("feature-branch", None) mock_trigger.return_value = [{"name": "CI", "file": "ci.yml", "url": "url", "run_number": 1}] main() mock_trigger.assert_called_once_with(mock_event, "feature-branch", ["ci.yml"], None) def test_main_triggers_all_workflows(): """Test main function triggers CI and Docker for the all command.""" with patch("actions.dispatch_actions.Action") as MockAction: mock_event = MockAction.return_value mock_event.event_name = "issue_comment" mock_event.repository = "test/repo" mock_event.event_data = { "action": "created", "issue": {"pull_request": {}}, "comment": {"body": f"Please run all {RUN_ALL_KEYWORD}", "user": {"login": "testuser"}, "id": 789}, } mock_event.is_org_member.return_value = True with patch("actions.dispatch_actions.get_pr_branch") as mock_get_branch, patch( "actions.dispatch_actions.trigger_and_get_workflow_info" ) as mock_trigger, patch("actions.dispatch_actions.update_comment"): mock_get_branch.return_value = ("feature-branch", None) mock_trigger.return_value = [{"name": "CI", "file": "ci.yml", "url": "url", "run_number": 1}] main() mock_trigger.assert_called_once_with(mock_event, "feature-branch", ["ci.yml", "docker.yml"], None) def test_main_triggers_docker_only(): """Test main function triggers only docker workflow for docker command.""" with patch("actions.dispatch_actions.Action") as MockAction: mock_event = MockAction.return_value mock_event.event_name = "issue_comment" mock_event.repository = "test/repo" mock_event.event_data = { "action": "created", "issue": {"pull_request": {}}, "comment": {"body": f"Please run Docker {RUN_DOCKER_KEYWORD}", "user": {"login": "testuser"}, "id": 789}, } mock_event.is_org_member.return_value = True with patch("actions.dispatch_actions.get_pr_branch") as mock_get_branch, patch( "actions.dispatch_actions.trigger_and_get_workflow_info" ) as mock_trigger, patch("actions.dispatch_actions.update_comment"): mock_get_branch.return_value = ("feature-branch", None) mock_trigger.return_value = [{"name": "Docker", "file": "docker.yml", "url": "url", "run_number": 1}] main() mock_trigger.assert_called_once_with(mock_event, "feature-branch", ["docker.yml"], None) def test_main_skips_non_pr_comments(): """Test main function skips non-PR comments.""" with patch("actions.dispatch_actions.Action") as MockAction: # Configure mock mock_event = MockAction.return_value mock_event.event_name = "issue_comment" mock_event.event_data = { "action": "created", "issue": {}, # No pull_request key "comment": {"body": f"Please run CI {RUN_CI_KEYWORD}", "user": {"login": "testuser"}}, } main() # Verify toggle_eyes_reaction was not called mock_event.toggle_eyes_reaction.assert_not_called() def test_main_skips_unauthorized_comments(): """Only org members can trigger workflows from PR comments.""" with patch("actions.dispatch_actions.Action") as MockAction: mock_event = MockAction.return_value mock_event.event_name = "issue_comment" mock_event.event_data = { "action": "created", "issue": {"pull_request": {}}, "comment": {"body": f"Please run CI {RUN_CI_KEYWORD}", "user": {"login": "external"}}, } mock_event.is_org_member.return_value = False main() mock_event.toggle_eyes_reaction.assert_not_called()