-
Notifications
You must be signed in to change notification settings - Fork 47
feat: add GitHub Action for automated PR review via AgentCore Harness #934
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
jesseturner21
merged 11 commits into
aws:main
from
jesseturner21:feat/pr-ai-review-upstream
Apr 24, 2026
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
722654f
feat: add GitHub Action for automated PR review via AgentCore Harness
jesseturner21 84f7e80
refactor: replace local service model with raw HTTP + SigV4 signing
jesseturner21 ed8d6d6
refactor: inline harness config into review script
jesseturner21 f169102
refactor: extract invoke_harness helper for cleaner main flow
jesseturner21 80248b8
refactor: simplify config and improve script readability
jesseturner21 29bafd4
refactor: separate event parsing from display logic
jesseturner21 a6f2186
docs: add explanatory comments to harness review functions
jesseturner21 ce027cf
refactor: derive region from HARNESS_ARN instead of separate env var
jesseturner21 3e2cef5
chore: rename label to agentcore-harness-reviewing
jesseturner21 7d63638
refactor: move auth check to job level so entire review is skipped early
jesseturner21 cb0b2ff
chore: exclude AI prompt templates from prettier
jesseturner21 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| Review this GitHub PR: {pr_url} | ||
|
|
||
| You have tools to fetch the PR diff, read files, search the web, and post comments on the PR. | ||
|
|
||
| You have these repos cloned locally for context: | ||
| - /opt/workspace/agentcore-cli — aws/agentcore-cli | ||
| - /opt/workspace/agentcore-l3-cdk-constructs — aws/agentcore-l3-cdk-constructs | ||
|
|
||
| Before reviewing, read all existing comments on the PR to understand what has already been discussed. Do not repeat or re-post issues that have already been raised in existing comments. | ||
|
|
||
| Review the PR. If there are any serious issues that require code changes before merging, post a comment on the PR for each issue explaining the problem. If there are multiple ways to fix an issue, list the options so the author can choose. Skip style nits and minor suggestions — only flag things that actually need to change. | ||
|
|
||
| If all serious issues have already been raised in existing comments, or if you found no new issues, post a single comment on the PR saying it looks good to merge (or that all issues have already been flagged). | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| # AgentCore CLI Development Workspace | ||
|
|
||
| This workspace contains two repos for developing and testing the AgentCore CLI. | ||
|
|
||
| ## Repositories | ||
|
|
||
| ### agentcore-cli/ (`aws/agentcore-cli`) | ||
|
|
||
| The terminal experience for creating, developing, and deploying AI agents to AgentCore. Node.js/TypeScript CLI built with Ink (React-based TUI). | ||
|
|
||
| ### agentcore-l3-cdk-constructs/ (`aws/agentcore-l3-cdk-constructs`) | ||
|
|
||
| AWS CDK L3 constructs for declaring and deploying AgentCore infrastructure. Used by agentcore-cli to vend CDK projects when users run `agentcore create`. | ||
|
|
||
| ## How they relate | ||
|
|
||
| `agentcore-cli` is the main product. It vends CDK projects using constructs from `agentcore-l3-cdk-constructs`. | ||
|
|
||
| ## Testing with a bundled distribution | ||
|
|
||
| Run `npm run bundle` in `agentcore-cli/` to create a tar distribution that includes the packaged `agentcore-l3-cdk-constructs`. You can then install it globally with `npm install -g <path-to-tar>` to test the CLI end-to-end. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,213 @@ | ||
| """Invoke Bedrock AgentCore Harness to review a GitHub PR. | ||
|
|
||
| Reads PR_URL from the environment. Streams harness output to stdout. | ||
| Uses raw HTTP with SigV4 signing — no custom service model needed. | ||
| """ | ||
|
|
||
| import json | ||
| import os | ||
| import sys | ||
| import time | ||
| import uuid | ||
|
|
||
| import boto3 | ||
| from botocore.auth import SigV4Auth | ||
| from botocore.awsrequest import AWSRequest | ||
| from botocore.eventstream import EventStreamBuffer | ||
| from urllib.parse import quote | ||
| import urllib3 | ||
|
|
||
| # ANSI color codes | ||
| CYAN = "\033[36m" | ||
| YELLOW = "\033[33m" | ||
| GREEN = "\033[32m" | ||
| RED = "\033[31m" | ||
| DIM = "\033[2m" | ||
| RESET = "\033[0m" | ||
|
|
||
| SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..") | ||
|
|
||
|
|
||
| def read_prompt(filename): | ||
| """Read a prompt template from the prompts directory.""" | ||
| path = os.path.join(SCRIPTS_DIR, "prompts", filename) | ||
| with open(path) as f: | ||
| return f.read() | ||
|
|
||
|
|
||
| def invoke_harness(harness_arn, body, region): | ||
| """Send a SigV4-signed request to the harness invoke endpoint. Returns a streaming response. | ||
|
|
||
| InvokeHarness is not in standard boto3, so we call the REST API directly. | ||
| boto3 is only used to resolve AWS credentials (from env vars, OIDC, etc.) | ||
| and sign the request with SigV4. The response is an AWS binary event stream. | ||
| """ | ||
| session = boto3.Session(region_name=region) | ||
| credentials = session.get_credentials().get_frozen_credentials() | ||
| url = f"https://bedrock-agentcore.{region}.amazonaws.com/harnesses/invoke?harnessArn={quote(harness_arn, safe='')}" | ||
| request = AWSRequest(method="POST", url=url, data=body, headers={ | ||
| "Content-Type": "application/json", | ||
| "Accept": "application/vnd.amazon.eventstream", | ||
| }) | ||
| SigV4Auth(credentials, "bedrock-agentcore", region).add_auth(request) | ||
| return urllib3.PoolManager().urlopen( | ||
| "POST", url, body=body, | ||
| headers=dict(request.headers), | ||
| preload_content=False, | ||
| timeout=urllib3.Timeout(connect=10, read=600), | ||
| ) | ||
|
|
||
|
|
||
| def parse_events(http_response): | ||
|
jesseturner21 marked this conversation as resolved.
|
||
| """Yield (event_type, payload) tuples from the harness binary event stream. | ||
|
|
||
| The response arrives as raw bytes in AWS binary event stream format. | ||
| EventStreamBuffer reassembles complete events from the 4KB chunks, | ||
| and we decode each event's JSON payload before yielding it. | ||
| """ | ||
| event_buffer = EventStreamBuffer() | ||
| for chunk in http_response.stream(4096): | ||
| event_buffer.add_data(chunk) | ||
| for event in event_buffer: | ||
| if event.headers.get(":message-type") == "exception": | ||
| payload = json.loads(event.payload.decode("utf-8")) | ||
| print(f"\n{RED}ERROR: {payload}{RESET}", file=sys.stderr) | ||
| sys.exit(1) | ||
| event_type = event.headers.get(":event-type", "") | ||
| if event.payload: | ||
| yield event_type, json.loads(event.payload.decode("utf-8")) | ||
|
|
||
|
|
||
| def print_stream(http_response): | ||
| """Display harness events with GitHub Actions log groups. | ||
|
|
||
| The harness streams events as the agent works: | ||
| contentBlockStart — a new block begins (text or tool call) | ||
| contentBlockDelta — incremental chunks of text or tool input JSON | ||
| contentBlockStop — block complete, we now have full tool input to display | ||
| messageStop — agent finished | ||
| internalServerException — server error | ||
|
|
||
| Tool calls are wrapped in ::group::/::endgroup:: for collapsible sections | ||
| in the GitHub Actions log UI. Agent reasoning text is printed inline in dim. | ||
| """ | ||
| start_time = time.time() | ||
| iteration = 0 | ||
| tool_name = None | ||
| tool_input = "" | ||
| tool_start = 0.0 | ||
| in_group = False | ||
| had_text = False | ||
|
|
||
| def close_group(): | ||
| nonlocal in_group | ||
| if in_group: | ||
| print("::endgroup::", flush=True) | ||
| in_group = False | ||
|
|
||
| for event_type, payload in parse_events(http_response): | ||
|
|
||
| if event_type == "contentBlockStart": | ||
| start = payload.get("start", {}) | ||
| if "toolUse" in start: | ||
| tool_name = start["toolUse"].get("name", "unknown") | ||
| tool_input = "" | ||
| tool_start = time.time() | ||
| iteration += 1 | ||
|
|
||
| elif event_type == "contentBlockDelta": | ||
| delta = payload.get("delta", {}) | ||
| if "text" in delta: | ||
| close_group() | ||
| print(flush=True) | ||
| print(f"{DIM}{delta['text']}{RESET}", end="", flush=True) | ||
| had_text = True | ||
| if "toolUse" in delta: | ||
| tool_input += delta["toolUse"].get("input", "") | ||
|
|
||
| elif event_type == "contentBlockStop": | ||
| if tool_name: | ||
| elapsed = time.time() - tool_start | ||
| try: | ||
| parsed = json.loads(tool_input) | ||
| except (json.JSONDecodeError, TypeError): | ||
| parsed = tool_input | ||
|
|
||
| close_group() | ||
| if had_text: | ||
| print("\n", flush=True) | ||
| had_text = False | ||
|
|
||
| cmd = parsed.get("command") if isinstance(parsed, dict) else None | ||
| header = f"{CYAN}[{iteration}]{RESET} {YELLOW}{tool_name}{RESET} {DIM}({elapsed:.1f}s){RESET}" | ||
| if cmd: | ||
| header += f": $ {cmd}" | ||
|
|
||
| print(f"::group::{header}", flush=True) | ||
| in_group = True | ||
|
|
||
| if isinstance(parsed, dict): | ||
| for k, v in parsed.items(): | ||
| if k != "command": | ||
| print(f" {DIM}{k}:{RESET} {str(v)[:300]}", flush=True) | ||
|
|
||
| tool_name = None | ||
| tool_input = "" | ||
|
|
||
| elif event_type == "messageStop": | ||
| close_group() | ||
| if payload.get("stopReason") == "end_turn": | ||
| total = time.time() - start_time | ||
| print(f"\n\n{GREEN}{'=' * 50}", flush=True) | ||
| print(f" Done ({int(total // 60)}m {int(total % 60)}s)", flush=True) | ||
| print(f"{'=' * 50}{RESET}", flush=True) | ||
|
|
||
| elif event_type == "internalServerException": | ||
| close_group() | ||
| print(f"\n{RED}ERROR: {payload}{RESET}", file=sys.stderr) | ||
| sys.exit(1) | ||
|
|
||
| close_group() | ||
| total = time.time() - start_time | ||
| print(f"\n{GREEN}Review complete.{RESET} {DIM}({iteration} tool calls, {int(total)}s total){RESET}") | ||
|
|
||
|
|
||
| # --- Main --- | ||
|
|
||
| # All config comes from environment variables (set via GitHub secrets/workflow) | ||
| MODEL_ID = os.environ.get("HARNESS_MODEL_ID", "us.anthropic.claude-opus-4-7") | ||
| HARNESS_ARN = os.environ.get("HARNESS_ARN", "") | ||
| PR_URL = os.environ.get("PR_URL", "") | ||
|
|
||
| for name, val in [("HARNESS_ARN", HARNESS_ARN), ("PR_URL", PR_URL)]: | ||
| if not val: | ||
| print(f"{RED}ERROR: {name} environment variable is required{RESET}", file=sys.stderr) | ||
| sys.exit(1) | ||
|
|
||
| # Extract region from the ARN (arn:aws:bedrock-agentcore:{region}:{account}:harness/{id}) | ||
| REGION = HARNESS_ARN.split(":")[3] | ||
| SESSION_ID = str(uuid.uuid4()).upper() | ||
|
|
||
| print(f"{CYAN}Session:{RESET} {SESSION_ID}") | ||
| print(f"{CYAN}PR:{RESET} {PR_URL}") | ||
| print(f"{CYAN}Harness:{RESET} {HARNESS_ARN}") | ||
| print() | ||
|
|
||
| SYSTEM_PROMPT = read_prompt("system.md") | ||
| REVIEW_PROMPT = read_prompt("review.md").format(pr_url=PR_URL) | ||
|
|
||
| request_body = json.dumps({ | ||
| "runtimeSessionId": SESSION_ID, | ||
| "systemPrompt": [{"text": SYSTEM_PROMPT}], | ||
| "messages": [{"role": "user", "content": [{"text": REVIEW_PROMPT}]}], | ||
| "model": {"bedrockModelConfig": {"modelId": MODEL_ID}}, | ||
| }) | ||
|
|
||
| http_response = invoke_harness(HARNESS_ARN, request_body, REGION) | ||
|
|
||
| if http_response.status != 200: | ||
| error = http_response.read().decode("utf-8") | ||
| print(f"{RED}ERROR: HTTP {http_response.status}: {error}{RESET}", file=sys.stderr) | ||
| sys.exit(1) | ||
|
|
||
| print_stream(http_response) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.