From 529ed56643e2d9859bc56f14558e85e04449303e Mon Sep 17 00:00:00 2001 From: ElmarKenguerli Date: Fri, 26 Sep 2025 14:42:11 +0200 Subject: [PATCH 1/3] Add generic push outputs mechanism with Slack relay server - Implement provider-agnostic push_outputs function supporting stdout, file, and webhook channels - Add FastAPI-based Slack webhook relay server for external integrations - Integrate push_outputs into PR reviewer tool to emit review data - Add configuration section for push_outputs with default disabled state --- pr_agent/algo/utils.py | 57 ++++++++++++++++ pr_agent/servers/push_outputs_relay.py | 95 ++++++++++++++++++++++++++ pr_agent/settings/configuration.toml | 10 +++ pr_agent/tools/pr_reviewer.py | 9 ++- 4 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 pr_agent/servers/push_outputs_relay.py diff --git a/pr_agent/algo/utils.py b/pr_agent/algo/utils.py index 72a0624c51..5c44e4c949 100644 --- a/pr_agent/algo/utils.py +++ b/pr_agent/algo/utils.py @@ -1264,6 +1264,63 @@ def github_action_output(output_data: dict, key_name: str): return +# Generic push mechanism to external sinks (provider-agnostic) +# Config section: [push_outputs] +# enable = false +# channels = ["stdout"] # supported: "stdout", "file", "webhook" +# file_path = "pr-agent-outputs/reviews.jsonl" +# webhook_url = "" +# presentation = "markdown" # reserved for future presentation controls + +def push_outputs(message_type: str, payload: dict | None = None, markdown: str | None = None) -> None: + try: + cfg = get_settings().get('push_outputs', {}) or {} + if not cfg.get('enable', False): + return + + channels = cfg.get('channels', []) or [] + record = { + "type": message_type, + "timestamp": datetime.utcnow().isoformat() + "Z", + "payload": payload or {}, + } + if markdown is not None: + record["markdown"] = markdown + + # stdout channel + if "stdout" in channels: + try: + print(json.dumps(record, ensure_ascii=False)) + except Exception: + # Do not fail the flow if stdout printing fails + get_logger().warning("Failed to print push_outputs to stdout") + + # file channel (append JSONL) + if "file" in channels: + try: + file_path = cfg.get('file_path', 'pr-agent-outputs/reviews.jsonl') + folder = os.path.dirname(file_path) + if folder: + os.makedirs(folder, exist_ok=True) + with open(file_path, 'a', encoding='utf-8') as fh: + fh.write(json.dumps(record, ensure_ascii=False) + "\n") + except Exception as e: + get_logger().warning(f"Failed to write push_outputs to file: {e}") + + # webhook channel (generic HTTP POST) + if "webhook" in channels: + url = cfg.get('webhook_url', '') + if url: + try: + headers = {'Content-Type': 'application/json'} + requests.post(url, data=json.dumps(record), headers=headers, timeout=5) + except Exception as e: + get_logger().warning(f"Failed to POST push_outputs to webhook: {e}") + except Exception as e: + get_logger().error(f"push_outputs failed: {e}") + return + + def show_relevant_configurations(relevant_section: str) -> str: skip_keys = ['ai_disclaimer', 'ai_disclaimer_title', 'ANALYTICS_FOLDER', 'secret_provider', "skip_keys", "app_id", "redirect", 'trial_prefix_message', 'no_eligible_message', 'identity_provider', 'ALLOWED_REPOS','APP_NAME'] diff --git a/pr_agent/servers/push_outputs_relay.py b/pr_agent/servers/push_outputs_relay.py new file mode 100644 index 0000000000..bd281668f4 --- /dev/null +++ b/pr_agent/servers/push_outputs_relay.py @@ -0,0 +1,95 @@ +""" +Provider-agnostic push outputs relay for Slack + +This FastAPI service receives generic PR-Agent push outputs (from [push_outputs]) and relays them +as Slack Incoming Webhook messages. + +Usage +----- +1) Run the relay (choose one): + - uvicorn pr_agent.servers.push_outputs_relay:app --host 0.0.0.0 --port 8000 + - python -m pr_agent.servers.push_outputs_relay + +2) Configure the destination Slack webhook: + - Set environment variable SLACK_WEBHOOK_URL to your Slack Incoming Webhook URL. + +3) Point PR-Agent to the relay: + In your configuration (e.g., .pr_agent.toml or central config), enable generic push outputs: + + [push_outputs] + enable = true + channels = ["webhook"] + webhook_url = "http://localhost:8000/relay" # adjust host/port if needed + presentation = "markdown" + +Security +-------- +- Keep the relay private or place it behind an auth gateway if exposed externally. +- You can also wrap this service with a reverse proxy that enforces authentication and rate limits. + +Notes +----- +- The relay is intentionally Slack-specific while living outside the provider-agnostic core. +- If record['markdown'] is present, it will be used as Slack message text. Otherwise, a JSON fallback + is generated from record['payload']. +- Slack supports basic Markdown (mrkdwn). Complex HTML/GitGFM sections may not render perfectly. +""" + +from __future__ import annotations + +import json +import os +from typing import Any, Dict + +import requests +from fastapi import FastAPI, HTTPException + +app = FastAPI(title="PR-Agent Push Outputs Relay (Slack)") + + +def _to_slack_text(record: Dict[str, Any]) -> str: + """ + Prefer full review markdown; otherwise fallback to a compact JSON of the payload. + """ + markdown = record.get("markdown") + if isinstance(markdown, str) and markdown.strip(): + return markdown + + payload = record.get("payload") or {} + try: + return "```\n" + json.dumps(payload, ensure_ascii=False, indent=2) + "\n```" + except Exception: + return str(payload) + + +@app.post("/relay") +async def relay(record: Dict[str, Any]): + slack_url = os.environ.get("SLACK_WEBHOOK_URL", "").strip() + if not slack_url: + raise HTTPException(status_code=500, detail="SLACK_WEBHOOK_URL environment variable is not set") + + text = _to_slack_text(record) + + body = { + "text": text, + "mrkdwn": True, + } + + try: + resp = requests.post(slack_url, json=body, timeout=8) + if resp.status_code >= 300: + raise HTTPException(status_code=resp.status_code, detail=f"Slack webhook error: {resp.text}") + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=502, detail=f"Failed to post to Slack: {e}") + + return {"status": "ok"} + + +if __name__ == "__main__": + # Allow running directly: python -m pr_agent.servers.push_outputs_relay + import uvicorn + + port = int(os.environ.get("PORT", "8000")) + uvicorn.run("pr_agent.servers.push_outputs_relay:app", host="0.0.0.0", port=port, reload=False) diff --git a/pr_agent/settings/configuration.toml b/pr_agent/settings/configuration.toml index 5e21b4f8cc..77dfb570e9 100644 --- a/pr_agent/settings/configuration.toml +++ b/pr_agent/settings/configuration.toml @@ -390,3 +390,13 @@ pr_commands = [ "/review", "/improve", ] + +# Generic push outputs configuration (disabled by default). This allows emitting PR outputs +# to stdout, a local file, or a generic webhook without calling provider-specific APIs. +# To enable, set enable=true and choose one or more channels. +[push_outputs] +enable = false +channels = ["stdout"] +file_path = "pr-agent-outputs/reviews.jsonl" +webhook_url = "" +presentation = "markdown" diff --git a/pr_agent/tools/pr_reviewer.py b/pr_agent/tools/pr_reviewer.py index c4917f3597..1398e69fb5 100644 --- a/pr_agent/tools/pr_reviewer.py +++ b/pr_agent/tools/pr_reviewer.py @@ -15,7 +15,7 @@ from pr_agent.algo.token_handler import TokenHandler from pr_agent.algo.utils import (ModelType, PRReviewHeader, convert_to_markdown_v2, github_action_output, - load_yaml, show_relevant_configurations) + load_yaml, show_relevant_configurations, push_outputs) from pr_agent.config_loader import get_settings from pr_agent.git_providers import (get_git_provider, get_git_provider_with_context) @@ -270,6 +270,13 @@ def _prepare_pr_review(self) -> str: if get_settings().get('config', {}).get('output_relevant_configurations', False): markdown_text += show_relevant_configurations(relevant_section='pr_reviewer') + # Push outputs to optional external channels (stdout/file/webhook) without provider APIs + try: + push_outputs("review", payload=data.get('review', {}), markdown=markdown_text) + except Exception: + # non-fatal + pass + # Add custom labels from the review prediction (effort, security) self.set_review_labels(data) From 2bf9f7ed015a625c747a3837210f8ba3f3b78b6a Mon Sep 17 00:00:00 2001 From: ElmarKenguerli Date: Fri, 26 Sep 2025 14:46:59 +0200 Subject: [PATCH 2/3] Add documentation for provider-agnostic push outputs and Slack relay feature --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 80a6dfa90d..d9614a923b 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,33 @@ ___
+## Provider-agnostic push outputs and Slack relay + +PR-Agent can optionally emit review results to external sinks without calling git provider APIs. +This is disabled by default. To enable and forward to Slack via a lightweight relay: + +1) Start the relay (in a separate shell): + - Set an Incoming Webhook URL for Slack: + - CMD: set SLACK_WEBHOOK_URL=https://hooks.slack.com/services/TXXXX/BXXXX/XXXXXXXX + - PS: $env:SLACK_WEBHOOK_URL="https://hooks.slack.com/services/TXXXX/BXXXX/XXXXXXXX" + - Run: + uvicorn pr_agent.servers.push_outputs_relay:app --host 0.0.0.0 --port 8000 + +2) In your repository, configure PR-Agent to emit to the relay by creating .pr_agent.toml: + +``` +[push_outputs] +enable = true +channels = ["webhook"] +webhook_url = "http://localhost:8000/relay" +presentation = "markdown" +``` + +Notes: +- This mechanism is provider-agnostic and uses minimal API calls. +- You can also use the "file" channel to append JSONL records locally. +- The relay transforms the generic payload into Slack’s Incoming Webhook schema. + ## Try It Now Try the GPT-5 powered PR-Agent instantly on _your public GitHub repository_. Just mention `@CodiumAI-Agent` and add the desired command in any PR comment. The agent will generate a response based on your command. From 3430d411113e2ecfd935c91693d8d740e74855cb Mon Sep 17 00:00:00 2001 From: ElmarKenguerli Date: Fri, 26 Sep 2025 15:39:43 +0200 Subject: [PATCH 3/3] Add support for Slack Workflow triggers in push outputs relay --- pr_agent/servers/push_outputs_relay.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/pr_agent/servers/push_outputs_relay.py b/pr_agent/servers/push_outputs_relay.py index bd281668f4..c2b6818b63 100644 --- a/pr_agent/servers/push_outputs_relay.py +++ b/pr_agent/servers/push_outputs_relay.py @@ -70,10 +70,21 @@ async def relay(record: Dict[str, Any]): text = _to_slack_text(record) - body = { - "text": text, - "mrkdwn": True, - } + # If using a Slack Workflow "triggers" URL, the workflow expects top-level fields + # that match the configured variables in the Workflow (e.g., "markdown", "payload"). + # Otherwise, for Incoming Webhooks ("services" URL), use the standard {text, mrkdwn}. + if "hooks.slack.com/triggers/" in slack_url: + body = { + # Map our computed text to the workflow variable named "markdown" + "markdown": text, + # Provide original payload if the workflow defines a variable for it + "payload": record.get("payload", {}), + } + else: + body = { + "text": text, + "mrkdwn": True, + } try: resp = requests.post(slack_url, json=body, timeout=8)