diff --git a/action.yaml b/action.yaml index 077ec41ff1..b84a27c7df 100644 --- a/action.yaml +++ b/action.yaml @@ -3,6 +3,18 @@ description: 'Summarize, review and suggest improvements for pull requests' branding: icon: 'award' color: 'green' +inputs: + artifact_path: + description: 'Path to a CI artifact file (relative to GITHUB_WORKSPACE or absolute) to include as extra context in PR analysis. Leave empty to disable artifact injection.' + required: false + default: '' + artifact_instructions: + description: 'Custom instructions telling the AI how to interpret the artifact. Leave empty for a sensible default.' + required: false + default: '' runs: using: 'docker' image: 'Dockerfile.github_action_dockerhub' + env: + ARTIFACT_PATH: ${{ inputs.artifact_path }} + ARTIFACT_INSTRUCTIONS: ${{ inputs.artifact_instructions }} diff --git a/github_action/entrypoint.sh b/github_action/entrypoint.sh index 4d493c7c79..1ed53d29a3 100644 --- a/github_action/entrypoint.sh +++ b/github_action/entrypoint.sh @@ -1,2 +1,3 @@ #!/bin/bash +set -e python /app/pr_agent/servers/github_action_runner.py diff --git a/pr_agent/algo/artifacts.py b/pr_agent/algo/artifacts.py new file mode 100644 index 0000000000..87a798bc3b --- /dev/null +++ b/pr_agent/algo/artifacts.py @@ -0,0 +1,111 @@ +import os +from pathlib import Path +from typing import Optional + +from pr_agent.config_loader import get_settings +from pr_agent.log import get_logger + +DEFAULT_ARTIFACT_INSTRUCTIONS = ( + "Consider this CI artifact as additional context when analyzing the PR. " + "It was produced by a prior CI step." +) + + +def resolve_artifact_path(path: str) -> Optional[Path]: + if not path: + return None + try: + workspace = os.environ.get("GITHUB_WORKSPACE", "") + + artifact_path = Path(path) + if artifact_path.is_absolute(): + resolved = artifact_path.resolve() + elif workspace: + resolved = (Path(workspace) / artifact_path).resolve() + else: + resolved = artifact_path.resolve() + + if workspace: + workspace_resolved = Path(workspace).resolve() + under_workspace = resolved == workspace_resolved or resolved.is_relative_to(workspace_resolved) + if not under_workspace: + get_logger().warning( + f"Artifact path '{path}' resolves outside GITHUB_WORKSPACE: {resolved}" + ) + return None + + return resolved if resolved.is_file() else None + except OSError as e: + get_logger().warning(f"Failed to resolve artifact path '{path}': {e}") + return None + + +_TRUNCATION_MARKER = "\n\n[... content truncated due to size limit ...]" + + +def _read_and_truncate(path: Path, max_size: int) -> str: + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + content = f.read(max_size + 1) + except (OSError, IOError) as e: + get_logger().warning(f"Failed to read artifact file {path}: {e}") + return "" + + if len(content) > max_size: + available = max_size - len(_TRUNCATION_MARKER) + content = content[:available] + _TRUNCATION_MARKER if available > 0 else content[:max_size] + return content + + +def format_artifact_content(content: str, label: str, instructions: str) -> str: + header = f"CI Artifact: {label}" if label else "CI Artifact" + instructions = (instructions or "").strip() or DEFAULT_ARTIFACT_INSTRUCTIONS + return ( + f"{header}\n" + f"=====\n" + f"{content}\n" + f"=====\n" + f"{instructions}" + ) + + +def load_artifact() -> str: + try: + artifacts_settings = get_settings().get("ARTIFACTS", {}) + except AttributeError: + return "" + + if not artifacts_settings: + return "" + + enable = artifacts_settings.get("enable", False) + if isinstance(enable, str): + enable = enable.lower() == "true" + if not enable: + return "" + + artifact_path_str = artifacts_settings.get("artifact_path", "") + if not artifact_path_str: + return "" + + artifact_path = resolve_artifact_path(artifact_path_str) + if not artifact_path: + get_logger().warning( + f"Artifact file not found or path rejected: '{artifact_path_str}' " + f"(GITHUB_WORKSPACE={os.environ.get('GITHUB_WORKSPACE', 'not set')})" + ) + return "" + + try: + max_size = int(artifacts_settings.get("max_artifact_size", 50000)) + except (TypeError, ValueError): + max_size = 50000 + if max_size <= 0: + max_size = 50000 + content = _read_and_truncate(artifact_path, max_size) + if not content: + return "" + + label = artifacts_settings.get("artifact_label", "") or artifact_path.name + instructions = artifacts_settings.get("artifact_instructions", "") + return format_artifact_content(content, label, instructions) diff --git a/pr_agent/servers/github_action_runner.py b/pr_agent/servers/github_action_runner.py index e99f51a9b5..255f2d3afd 100644 --- a/pr_agent/servers/github_action_runner.py +++ b/pr_agent/servers/github_action_runner.py @@ -30,6 +30,53 @@ def get_setting_or_env(key: str, default: Union[str, bool] = None) -> Union[str, return value +def _inject_artifact_context(): + """Inject CI artifact content into extra_instructions for configured tools.""" + artifact_path_env = ( + os.environ.get("ARTIFACT_PATH") or os.environ.get("PR_AGENT_ARTIFACT_PATH") or "" + ).strip() + artifact_instructions_env = ( + os.environ.get("ARTIFACT_INSTRUCTIONS") or os.environ.get("PR_AGENT_ARTIFACT_INSTRUCTIONS") or "" + ).strip() + if artifact_path_env: + get_settings().set("ARTIFACTS.ENABLE", True) + get_settings().set("ARTIFACTS.ARTIFACT_PATH", artifact_path_env) + if artifact_instructions_env: + get_settings().set("ARTIFACTS.ARTIFACT_INSTRUCTIONS", artifact_instructions_env) + + artifacts_enabled = get_settings().get("ARTIFACTS.ENABLE", False) + if not is_true(artifacts_enabled): + return + + try: + from pr_agent.algo.artifacts import load_artifact + + artifact_text = load_artifact() + if not artifact_text: + return + target_tools = get_settings().get( + "ARTIFACTS.TARGET_TOOLS", + ["pr_reviewer", "pr_description", "pr_code_suggestions"] + ) + if isinstance(target_tools, str): + target_tools = [t.strip() for t in target_tools.split(",") if t.strip()] + target_tools = {str(t).lower() for t in target_tools} + separator = "\n======\n\n" + for key in get_settings(): + setting = get_settings().get(key) + if str(type(setting)) == "": + if key.lower() in target_tools and hasattr(setting, 'extra_instructions'): + extra_instructions = str(setting.extra_instructions or "") + if artifact_text not in extra_instructions: + setting.extra_instructions = ( + extra_instructions + separator + artifact_text + if extra_instructions else artifact_text + ) + get_logger().info(f"Injected artifact context into tools: {target_tools}") + except (OSError, ValueError, TypeError) as e: + get_logger().warning(f"github action: failed to process artifacts: {e}", exc_info=True) + + async def run_action(): # Get environment variables GITHUB_EVENT_NAME = os.environ.get('GITHUB_EVENT_NAME') @@ -106,8 +153,11 @@ async def run_action(): setting.extra_instructions = updated_instructions except Exception as e: get_logger().info(f"github action: failed to apply language-specific instructions: {e}") + # Handle pull request opened event if GITHUB_EVENT_NAME == "pull_request" or GITHUB_EVENT_NAME == "pull_request_target": + # Inject artifact context here so it runs after apply_repo_settings above + _inject_artifact_context() action = event_payload.get("action") # Retrieve the list of actions from the configuration @@ -227,6 +277,7 @@ async def run_action(): comment_id = event_payload.get("comment", {}).get("id") provider = get_git_provider()(pr_url=url) if is_pr: + _inject_artifact_context() await PRAgent().handle_request( url, body, notify=lambda: provider.add_eyes_reaction( comment_id, disable_eyes=disable_eyes @@ -235,6 +286,58 @@ async def run_action(): else: await PRAgent().handle_request(url, body) + # Handle workflow_run event (triggered after another workflow completes, e.g. after a terraform plan) + elif GITHUB_EVENT_NAME == "workflow_run": + workflow_run = event_payload.get("workflow_run", {}) + if workflow_run.get("event") not in ("pull_request", "pull_request_target"): + get_logger().info( + f"Skipping workflow_run: originating event is '{workflow_run.get('event')}', " + "not 'pull_request' or 'pull_request_target'" + ) + return + + pull_requests = workflow_run.get("pull_requests", []) + if not pull_requests: + get_logger().info("Skipping workflow_run: no pull_requests found in payload (fork PRs are not supported)") + return + + pr_url = pull_requests[0].get("url") + if not pr_url: + get_logger().info("Skipping workflow_run: pull_requests[0] has no url") + return + + try: + apply_repo_settings(pr_url) + except Exception as e: + get_logger().warning(f"github action: failed to apply repo settings for workflow_run: {e}") + + # Inject artifact context after repo settings are applied for workflow_run + _inject_artifact_context() + + auto_review = get_setting_or_env("GITHUB_ACTION.AUTO_REVIEW", None) + if auto_review is None: + auto_review = get_setting_or_env("GITHUB_ACTION_CONFIG.AUTO_REVIEW", None) + auto_describe = get_setting_or_env("GITHUB_ACTION.AUTO_DESCRIBE", None) + if auto_describe is None: + auto_describe = get_setting_or_env("GITHUB_ACTION_CONFIG.AUTO_DESCRIBE", None) + auto_improve = get_setting_or_env("GITHUB_ACTION.AUTO_IMPROVE", None) + if auto_improve is None: + auto_improve = get_setting_or_env("GITHUB_ACTION_CONFIG.AUTO_IMPROVE", None) + + get_settings().config.is_auto_command = True + get_settings().pr_description.final_update_message = False + get_logger().info( + f"Running auto actions for workflow_run: auto_describe={auto_describe}, " + f"auto_review={auto_review}, auto_improve={auto_improve}" + ) + + if auto_describe is None or is_true(auto_describe): + await PRDescription(pr_url).run() + if auto_review is None or is_true(auto_review): + await PRReviewer(pr_url).run() + if auto_improve is None or is_true(auto_improve): + await PRCodeSuggestions(pr_url).run() + if __name__ == '__main__': asyncio.run(run_action()) diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 458cfd00d2..1309e27b34 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -395,6 +395,20 @@ extra_instructions = "" # public - extra instructions to the auto best practices content = "" max_patterns = 5 # max number of patterns to be detected +[artifacts] +# Enable artifact injection into tool prompts (off by default; auto-enabled when artifact_path input is set) +enable = false +# File path to the artifact (relative to GITHUB_WORKSPACE, or absolute) +artifact_path = "" +# Custom instructions appended after the artifact content (leave empty for a sensible default) +artifact_instructions = "" +# Label shown to the AI — defaults to the filename when empty +artifact_label = "" +# Which tools receive artifact context +target_tools = ["pr_reviewer", "pr_description", "pr_code_suggestions"] +# Max artifact size in characters (content is truncated if exceeded) +max_artifact_size = 50000 + [azure_devops] default_comment_status = "closed" diff --git a/tests/unittest/test_artifacts.py b/tests/unittest/test_artifacts.py new file mode 100644 index 0000000000..39e408ef38 --- /dev/null +++ b/tests/unittest/test_artifacts.py @@ -0,0 +1,247 @@ +import os +from unittest.mock import patch + +from pr_agent.algo.artifacts import ( + DEFAULT_ARTIFACT_INSTRUCTIONS, + _read_and_truncate, + format_artifact_content, + load_artifact, + resolve_artifact_path, +) + + +class TestResolveArtifactPathRobustness: + def test_whitespace_path_returns_none(self): + assert resolve_artifact_path(" ") is None + + def test_oserror_during_resolve_returns_none(self, tmp_path): + with patch("pr_agent.algo.artifacts.Path") as mock_path_cls: + mock_path_cls.return_value.is_absolute.return_value = True + mock_path_cls.return_value.resolve.side_effect = OSError("symlink loop") + result = resolve_artifact_path("/some/path/file.txt") + assert result is None + + +class TestFormatArtifactContentRobustness: + def test_whitespace_only_instructions_uses_default(self): + result = format_artifact_content("output", "file.txt", " ") + assert DEFAULT_ARTIFACT_INSTRUCTIONS in result + + def test_none_instructions_uses_default(self): + result = format_artifact_content("output", "file.txt", None) + assert DEFAULT_ARTIFACT_INSTRUCTIONS in result + + +class TestLoadArtifactEnableFlag: + def test_string_true_enables(self, tmp_path): + f = tmp_path / "artifact.txt" + f.write_text("content") + with patch("pr_agent.algo.artifacts.get_settings") as mock_gs: + mock_gs.return_value.get.return_value = { + "enable": "true", + "artifact_path": str(f), + "artifact_instructions": "", + "artifact_label": "", + "max_artifact_size": 50000, + } + with patch.dict(os.environ, {"GITHUB_WORKSPACE": str(tmp_path)}): + result = load_artifact() + assert result != "" + + def test_string_false_disables(self): + with patch("pr_agent.algo.artifacts.get_settings") as mock_gs: + mock_gs.return_value.get.return_value = { + "enable": "false", + "artifact_path": "some/path.txt", + } + assert load_artifact() == "" + + def test_string_True_capitalised_disables(self): + with patch("pr_agent.algo.artifacts.get_settings") as mock_gs: + mock_gs.return_value.get.return_value = { + "enable": "True", + "artifact_path": "some/path.txt", + } + # "True".lower() == "true" → should enable; but file won't exist → returns "" + assert load_artifact() == "" + + +class TestResolveArtifactPath: + def test_empty_path_returns_none(self): + assert resolve_artifact_path("") is None + assert resolve_artifact_path(None) is None + + def test_absolute_path_existing_file(self, tmp_path): + f = tmp_path / "plan.txt" + f.write_text("content") + with patch.dict(os.environ, {"GITHUB_WORKSPACE": str(tmp_path)}): + assert resolve_artifact_path(str(f)) == f.resolve() + + def test_absolute_path_missing_file(self, tmp_path): + with patch.dict(os.environ, {"GITHUB_WORKSPACE": str(tmp_path)}): + assert resolve_artifact_path(str(tmp_path / "nonexistent.txt")) is None + + def test_relative_path_with_github_workspace(self, tmp_path): + f = tmp_path / "output" / "plan.txt" + f.parent.mkdir(parents=True) + f.write_text("terraform plan") + + with patch.dict(os.environ, {"GITHUB_WORKSPACE": str(tmp_path)}): + result = resolve_artifact_path("output/plan.txt") + assert result == f.resolve() + + def test_relative_path_without_workspace_falls_back_to_cwd(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + f = tmp_path / "plan.txt" + f.write_text("content") + + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("GITHUB_WORKSPACE", None) + result = resolve_artifact_path("plan.txt") + assert result == f.resolve() + + def test_relative_path_not_found_returns_none(self): + with patch.dict(os.environ, {"GITHUB_WORKSPACE": "/tmp/nonexistent_workspace_xyz"}): + assert resolve_artifact_path("missing.txt") is None + + def test_rejects_path_traversal_above_workspace(self, tmp_path): + outside = tmp_path / "outside" / "secret.txt" + outside.parent.mkdir(parents=True) + outside.write_text("secret") + + workspace = tmp_path / "workspace" + workspace.mkdir() + + with patch.dict(os.environ, {"GITHUB_WORKSPACE": str(workspace)}): + result = resolve_artifact_path("../outside/secret.txt") + assert result is None + + def test_rejects_absolute_path_outside_workspace(self, tmp_path): + outside = tmp_path / "outside.txt" + outside.write_text("secret") + + workspace = tmp_path / "workspace" + workspace.mkdir() + + with patch.dict(os.environ, {"GITHUB_WORKSPACE": str(workspace)}): + result = resolve_artifact_path(str(outside)) + assert result is None + + def test_root_workspace_does_not_reject_valid_paths(self, tmp_path): + f = tmp_path / "artifact.txt" + f.write_text("data") + + with patch.dict(os.environ, {"GITHUB_WORKSPACE": "/"}): + result = resolve_artifact_path(str(f)) + assert result == f.resolve() + + +class TestReadAndTruncate: + def test_reads_file_content(self, tmp_path): + f = tmp_path / "artifact.txt" + f.write_text("hello world") + assert _read_and_truncate(f, 50000) == "hello world" + + def test_truncates_large_content(self, tmp_path): + f = tmp_path / "big.txt" + f.write_text("x" * 1000) + result = _read_and_truncate(f, 100) + assert len(result) <= 100 + assert result.startswith("x") + assert "[... content truncated due to size limit ...]" in result + + def test_returns_empty_on_read_error(self, tmp_path): + missing = tmp_path / "no_such_file.txt" + assert _read_and_truncate(missing, 50000) == "" + + def test_does_not_read_entire_large_file(self, tmp_path): + f = tmp_path / "huge.txt" + f.write_text("x" * 1_000_000) + result = _read_and_truncate(f, 100) + # Should contain exactly 100 chars of content + truncation marker + assert len(result) < 200 + + def test_result_never_exceeds_max_size_when_limit_smaller_than_marker(self, tmp_path): + f = tmp_path / "small.txt" + f.write_text("x" * 100) + result = _read_and_truncate(f, 30) + assert len(result) <= 30 + + +class TestFormatArtifactContent: + def test_with_label_and_custom_instructions(self): + result = format_artifact_content("plan output", "plan.txt", "Check for deletions.") + assert "CI Artifact: plan.txt" in result + assert "plan output" in result + assert "Check for deletions." in result + + def test_with_label_uses_default_instructions_when_empty(self): + result = format_artifact_content("some output", "build.log", "") + assert "CI Artifact: build.log" in result + assert DEFAULT_ARTIFACT_INSTRUCTIONS in result + + def test_without_label(self): + result = format_artifact_content("output", "", "") + assert "CI Artifact\n" in result + assert DEFAULT_ARTIFACT_INSTRUCTIONS in result + + +class TestLoadArtifact: + def test_returns_empty_when_no_config(self): + with patch("pr_agent.algo.artifacts.get_settings") as mock_gs: + mock_gs.return_value.get.return_value = {} + assert load_artifact() == "" + + def test_returns_empty_when_disabled(self): + with patch("pr_agent.algo.artifacts.get_settings") as mock_gs: + mock_gs.return_value.get.return_value = {"enable": False, "artifact_path": "plan.txt"} + assert load_artifact() == "" + + def test_returns_empty_when_no_path(self): + with patch("pr_agent.algo.artifacts.get_settings") as mock_gs: + mock_gs.return_value.get.return_value = {"enable": True, "artifact_path": ""} + assert load_artifact() == "" + + def test_returns_empty_when_file_not_found(self): + with patch("pr_agent.algo.artifacts.get_settings") as mock_gs: + mock_gs.return_value.get.return_value = { + "enable": True, + "artifact_path": "/nonexistent/file.txt", + } + assert load_artifact() == "" + + def test_loads_and_formats_with_default_instructions(self, tmp_path): + f = tmp_path / "plan.txt" + f.write_text("+ aws_s3_bucket.data") + + with patch("pr_agent.algo.artifacts.get_settings") as mock_gs: + mock_gs.return_value.get.return_value = { + "enable": True, + "artifact_path": str(f), + "artifact_instructions": "", + "artifact_label": "", + "max_artifact_size": 50000, + } + with patch.dict(os.environ, {"GITHUB_WORKSPACE": str(tmp_path)}): + result = load_artifact() + assert "CI Artifact: plan.txt" in result + assert "+ aws_s3_bucket.data" in result + assert DEFAULT_ARTIFACT_INSTRUCTIONS in result + + def test_loads_and_formats_with_custom_instructions(self, tmp_path): + f = tmp_path / "results.xml" + f.write_text("FAILED: test_login") + + with patch("pr_agent.algo.artifacts.get_settings") as mock_gs: + mock_gs.return_value.get.return_value = { + "enable": True, + "artifact_path": str(f), + "artifact_instructions": "Flag any test failures.", + "artifact_label": "Test Results", + "max_artifact_size": 50000, + } + with patch.dict(os.environ, {"GITHUB_WORKSPACE": str(tmp_path)}): + result = load_artifact() + assert "CI Artifact: Test Results" in result + assert "FAILED: test_login" in result + assert "Flag any test failures." in result diff --git a/tests/unittest/test_github_action_runner_core.py b/tests/unittest/test_github_action_runner_core.py index 3fe96d6b96..478f8fd36a 100644 --- a/tests/unittest/test_github_action_runner_core.py +++ b/tests/unittest/test_github_action_runner_core.py @@ -116,6 +116,8 @@ def restore_github_settings(): original_cfg = copy.deepcopy(settings.get("GITHUB_ACTION_CONFIG", None)) had_app = "GITHUB_APP" in settings original_app = copy.deepcopy(settings.get("GITHUB_APP", None)) + original_is_auto = getattr(settings.config, "is_auto_command", None) + original_final_update = getattr(settings.pr_description, "final_update_message", None) yield if had_github: settings.set("GITHUB", original_github) @@ -129,6 +131,10 @@ def restore_github_settings(): settings.set("GITHUB_APP", original_app) else: settings.unset("GITHUB_APP", force=True) + if original_is_auto is not None: + settings.config.is_auto_command = original_is_auto + if original_final_update is not None: + settings.pr_description.final_update_message = original_final_update def _write_synchronize_event(tmp_path, before_sha="abc", after_sha="def", merge_commit_sha=None, sender_type="User"): @@ -198,6 +204,23 @@ async def test_issue_comment_from_bot_sender_is_skipped(monkeypatch, tmp_path, r assert handled == [] # bot comment skipped; no command handled +@pytest.mark.asyncio +async def test_issue_comment_calls_inject_artifact_context(monkeypatch, tmp_path, restore_github_settings): + """_inject_artifact_context must be called for comment-triggered runs.""" + handled = [] + _patch_issue_comment_deps(monkeypatch, handled) + monkeypatch.setenv("GITHUB_EVENT_NAME", "issue_comment") + monkeypatch.setenv("GITHUB_EVENT_PATH", str(_write_issue_comment_event(tmp_path, "User"))) + monkeypatch.setenv("GITHUB_TOKEN", "token") + + inject_calls = [] + monkeypatch.setattr(github_action_runner, "_inject_artifact_context", lambda: inject_calls.append(1)) + + await github_action_runner.run_action() + + assert inject_calls, "_inject_artifact_context was not called for issue_comment event" + + def _patch_synchronize_deps(monkeypatch, handled, push_commands, handle_push_trigger=True): monkeypatch.setattr(github_action_runner, "apply_repo_settings", lambda pr_url: None) settings = get_settings() @@ -259,7 +282,9 @@ async def test_synchronize_skips_equal_before_after_sha(monkeypatch, tmp_path, r @pytest.mark.asyncio -async def test_synchronize_event_triggers_push_commands_on_pull_request_target(monkeypatch, tmp_path, restore_github_settings): +async def test_synchronize_event_triggers_push_commands_on_pull_request_target( + monkeypatch, tmp_path, restore_github_settings +): handled = [] _patch_synchronize_deps(monkeypatch, handled, ["/describe", "/improve"]) monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request_target") @@ -337,3 +362,98 @@ async def test_issue_comment_from_user_is_processed(monkeypatch, tmp_path, resto await github_action_runner.run_action() assert handled == [("https://api.github.com/repos/org/repo/pulls/1", "/review")] + + +def _write_workflow_run_event(tmp_path, originating_event="pull_request", pull_requests=None): + if pull_requests is None: + pull_requests = [{"url": "https://api.github.com/repos/org/repo/pulls/42", "number": 42}] + event_path = tmp_path / "event.json" + event_path.write_text(json.dumps({ + "action": "completed", + "workflow_run": { + "id": 9999, + "event": originating_event, + "conclusion": "success", + "pull_requests": pull_requests, + }, + })) + return event_path + + +def _patch_workflow_run_deps(monkeypatch, runs): + monkeypatch.setattr(github_action_runner, "apply_repo_settings", lambda pr_url: None) + + class FakeTool: + name = "base" + + def __init__(self, pr_url): + self.pr_url = pr_url + + async def run(self): + runs.append((self.name, self.pr_url)) + + class FakeDescription(FakeTool): + name = "describe" + + class FakeReviewer(FakeTool): + name = "review" + + class FakeSuggestions(FakeTool): + name = "improve" + + monkeypatch.setattr(github_action_runner, "PRDescription", FakeDescription) + monkeypatch.setattr(github_action_runner, "PRReviewer", FakeReviewer) + monkeypatch.setattr(github_action_runner, "PRCodeSuggestions", FakeSuggestions) + + +@pytest.mark.asyncio +async def test_workflow_run_runs_auto_tools(monkeypatch, tmp_path, restore_github_settings): + runs = [] + _patch_workflow_run_deps(monkeypatch, runs) + monkeypatch.setenv("GITHUB_EVENT_NAME", "workflow_run") + monkeypatch.setenv("GITHUB_EVENT_PATH", str(_write_workflow_run_event(tmp_path))) + monkeypatch.setenv("GITHUB_TOKEN", "token") + + def fake_get_setting_or_env(key, default=None): + values = { + "GITHUB_ACTION.AUTO_DESCRIBE": True, + "GITHUB_ACTION.AUTO_REVIEW": True, + "GITHUB_ACTION.AUTO_IMPROVE": False, + "GITHUB_ACTION_CONFIG.ENABLE_OUTPUT": True, + } + return values.get(key, default) + + monkeypatch.setattr(github_action_runner, "get_setting_or_env", fake_get_setting_or_env) + + await github_action_runner.run_action() + + assert runs == [ + ("describe", "https://api.github.com/repos/org/repo/pulls/42"), + ("review", "https://api.github.com/repos/org/repo/pulls/42"), + ] + + +@pytest.mark.asyncio +async def test_workflow_run_skips_non_pull_request_origin(monkeypatch, tmp_path, restore_github_settings): + runs = [] + _patch_workflow_run_deps(monkeypatch, runs) + monkeypatch.setenv("GITHUB_EVENT_NAME", "workflow_run") + monkeypatch.setenv("GITHUB_EVENT_PATH", str(_write_workflow_run_event(tmp_path, originating_event="push"))) + monkeypatch.setenv("GITHUB_TOKEN", "token") + + await github_action_runner.run_action() + + assert runs == [] + + +@pytest.mark.asyncio +async def test_workflow_run_skips_when_pull_requests_empty(monkeypatch, tmp_path, restore_github_settings): + runs = [] + _patch_workflow_run_deps(monkeypatch, runs) + monkeypatch.setenv("GITHUB_EVENT_NAME", "workflow_run") + monkeypatch.setenv("GITHUB_EVENT_PATH", str(_write_workflow_run_event(tmp_path, pull_requests=[]))) + monkeypatch.setenv("GITHUB_TOKEN", "token") + + await github_action_runner.run_action() + + assert runs == []