Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Comment thread
MahmoudHaouachi marked this conversation as resolved.
1 change: 1 addition & 0 deletions github_action/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
#!/bin/bash
set -e
python /app/pr_agent/servers/github_action_runner.py
111 changes: 111 additions & 0 deletions pr_agent/algo/artifacts.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Comment thread
MahmoudHaouachi marked this conversation as resolved.


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)
Comment thread
MahmoudHaouachi marked this conversation as resolved.
103 changes: 103 additions & 0 deletions pr_agent/servers/github_action_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)) == "<class 'dynaconf.utils.boxing.DynaBox'>":
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')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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())
14 changes: 14 additions & 0 deletions pr_agent/settings/configuration.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading
Loading