Pull‑request reviewers spend minutes copying snippets into ChatGPT, then manually applying the suggestions. What if the whole edit could happen automatically, right after the PR is created? By wiring EventBridge Pipes to an ECS task you can turn an AI coding assistant into a CI‑native, auditable workflow.
Why Event‑Driven AI Coding Beats Manual Prompting
In plain English: When a developer opens a PR, the system should react immediately, without a person having to type a prompt.
Human reviewers are good at spotting intent, but they waste time moving data between tools. An event‑driven design treats every PR open as a signal (an event) that can trigger other services automatically.
- Speed: The moment the PR exists, the AI can start working. No waiting for a reviewer to paste code.
- Repeatability: The same logic runs for every PR, so you get a consistent experience across the team.
- Auditability: Each AI‑generated commit is part of the Git history, so you can review, revert, or approve it like any other change.
Think of it like a kitchen assembly line. The moment a raw ingredient (the PR) arrives on the conveyor belt, a robot (the ECS task) grabs it, processes it (calls Claude), and places the finished dish (the edited code) back on the belt for the chef (the reviewer) to taste.
Key terms (first use)
- Event: A piece of data that says “something happened”, e.g., “a PR was opened”.
- EventBridge: Amazon’s service that receives events and routes them to targets.
- Pipe: A built‑in connection inside EventBridge that links a source directly to a target, optionally applying a filter or transformation.
- ECS (Elastic Container Service): A managed container platform; Fargate is the server‑less mode that runs containers without you managing servers.
- Bedrock: AWS’s hosted platform for foundation models such as Claude, accessed through a normal HTTP request.
By letting the AI sit inside the same event pipeline that already moves code around, you remove the manual copy‑paste step entirely.
Building the EventBridge Pipe: From GitHub webhook to ECS task
Tip: Create the pipe once and reuse it for every PR. The pipe itself is cheap; the heavy lifting happens in the ECS task.
What the pipe does
-
Source: API Gateway forwards a GitHub webhook (
pull_request.opened) to EventBridge. -
Filter (optional): Only let events that contain changed
.jsor.tsfiles through. - Target: An ECS Fargate task that runs a small Node.js program.
The pipe definition lives in code so you can version‑control it. Below is a minimal, complete example that creates the pipe using the @aws-sdk/client-eventbridge package.
import {
EventBridgeClient,
CreatePipeCommand,
CreatePipeCommandInput,
} from "@aws-sdk/client-eventbridge";
// 1️⃣ Create a client – it knows which AWS region to talk to.
const ebClient = new EventBridgeClient({ region: "us-east-1" });
// 2️⃣ Define the pipe.
// - Source: an EventBridge event bus that API Gateway will put events onto.
// - Target: an ECS task run on Fargate.
// - Filter: keep only PR events with at least one .js/.ts file.
const pipeParams: CreatePipeCommandInput = {
Name: "PRToAICodingPipe",
RoleArn: "arn:aws:iam::123456789012:role/EventBridgePipeRole", // grants EB to start ECS tasks
Source: "arn:aws:events:us-east-1:123456789012:event-bus/default",
SourceParameters: {
// No special settings needed for a simple event bus source.
},
Target: "arn:aws:ecs:us-east-1:123456789012:cluster/ai-coding-cluster",
TargetParameters: {
// Tell EventBridge how to launch the task.
ECSParameters: {
TaskDefinitionArn: "arn:aws:ecs:us-east-1:123456789012:task-definition/ai-coding-task:1",
LaunchType: "FARGATE",
NetworkConfiguration: {
awsvpcConfiguration: {
Subnets: ["subnet-abc123"], // pick a subnet with internet access
AssignPublicIp: "ENABLED",
},
},
// Pass the whole event as JSON to the container's STDIN (or env var)
TaskOverride: {
ContainerOverrides: [
{
Name: "ai-coding-container",
// EventBridge will inject the event as the first argument.
Command: ["node", "run.js"],
},
],
},
},
},
// Simple filter: only allow events where detail.action === "opened"
// and at least one file ends with .js or .ts.
// Note: The filter expression must evaluate within 5 seconds.
FilterCriteria: {
Filters: [
{
Pattern: JSON.stringify({
"detail": {
"action": ["opened"],
"changed_files": [{ "suffix": [".js", ".ts"] }],
},
}),
},
],
},
// Optional: dead‑letter queue for failures.
// We'll set this up later in the “Gotchas” note.
};
async function createPipe() {
try {
const cmd = new CreatePipeCommand(pipeParams);
const response = await ebClient.send(cmd);
console.log("Pipe created:", response.Arn);
} catch (err) {
console.error("Failed to create pipe:", err);
}
}
createPipe();
What the code does, line by line
- Import the SDK classes you need.
-
Instantiate an
EventBridgeClientpointing at your region. -
Build a
CreatePipeCommandInputobject that describes source, target, and filter. -
Set
RoleArnto an IAM role that lets EventBridge callecs:RunTask. - Define a 5‑second filter that looks for PR‑opened events with JavaScript/TypeScript files.
-
Call
CreatePipeCommandand log the resulting ARN.
Gotchas specific to this step
- The filter evaluation limit (5 seconds) means you cannot run heavy regexes or large JSON traversals. Keep the pattern tiny.
- If you later add a new field to the GitHub webhook payload, the pipe might silently stop matching. Test changes in a sandbox.
- Cross‑account pipes require you to add a resource‑based policy on the target ECS cluster; missing it results in “AccessDenied” errors that are hard to trace.
Using S3 as a Prompt Cache with native fetch
Key takeaway: Store the raw file contents in S3 so the container can download them quickly, even if the PR contains dozens of files.
The container needs the exact code it will send to Claude. Instead of calling the GitHub API from inside the task (which adds latency and extra auth), we let the webhook handler upload the changed files to an S3 bucket before the event hits the pipe. The bucket acts as a prompt cache—a place where the AI can retrieve the same snapshot later.
Uploading the files (the webhook side)
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
// The bucket where we keep PR snapshots.
const s3 = new S3Client({ region: "us-east-1" });
async function cacheChangedFiles(prNumber: number, files: Record<string, string>) {
const promises = Object.entries(files).map(async ([path, content]) => {
const key = `pr-${prNumber}/${path}`; // e.g., pr-42/src/index.ts
const cmd = new PutObjectCommand({
Bucket: "ai-coding-prompt-cache",
Key: key,
Body: content,
// Include the PR number in metadata so we can detect stale caches.
Metadata: { "pr-number": prNumber.toString() },
});
await s3.send(cmd);
});
await Promise.all(promises);
}
-
filesis a map where each key is the file path and each value is the file contents. - We store each file under a folder named after the PR number; this makes versioning simple.
- Adding
Metadatawith the PR number helps us later decide whether a cached prompt is still fresh.
Pulling the files inside the ECS container
Inside the container we use the built‑in fetch API (available in Node 20+). No extra SDK needed.
// run.js – entry point for the Fargate task.
import { readFileSync } from "fs";
import { createReadStream } from "stream";
// The event arrives on STDIN as a JSON string.
const event = JSON.parse(readFileSync(0, "utf-8"));
const prNumber = event.detail.pull_request.number;
// Helper to download a single file from S3.
async function downloadFile(key) {
const url = `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${key}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch ${key}: ${response.status}`);
return await response.text();
}
// Build the prompt by concatenating all changed files.
async function buildPrompt() {
// List of keys we stored earlier – in a real implementation you could
// read a manifest file from S3, but for brevity we assume we know the list.
const changedFiles = ["src/index.ts", "src/util.js"]; // example
const parts = [];
for (const file of changedFiles) {
const key = `pr-${prNumber}/${file}`;
const content = await downloadFile(key);
parts.push(`--- ${file} ---\n${content}`);
}
// Simple instruction for Claude.
return `You are a coding assistant. Update the following files to address the PR description.\n${parts.join("\n")}`;
}
(async () => {
const prompt = await buildPrompt();
console.log("Prompt ready, length:", prompt.length);
// Next step: call Claude (see next section).
})();
Explanation of the code
- The event is read from STDIN because EventBridge pipes can forward the event that way.
-
downloadFilebuilds a public S3 URL and usesfetchto retrieve the file. (Make the bucket public‑read or use a signed URL; the latter is recommended for security, but omitted here for clarity.) -
buildPromptloops over a known list of changed files, downloads each, and stitches them together with a simple delimiter (--- filename ---). This structure helps Claude understand file boundaries.
Gotchas for the S3 cache
-
Version awareness: If a PR is updated quickly, the bucket may still hold the old version. Include the PR number (or
git sha) in the S3 object key or metadata and check it before using the cached file. - Stale suggestions: If the pipe fires multiple times for the same PR (e.g., due to re‑opens), you may end up with duplicate branches. Guard against it by checking whether a branch already exists on GitHub.
-
Permissions: The ECS task’s task role must have
s3:GetObjecton the bucket. Mixing up the task role with the execution role is a common source of silent failures.
Calling Claude via Bedrock inside the ECS container
In plain English: Claude lives behind an HTTP endpoint provided by Bedrock. From the container we just
fetchthat endpoint, send the prompt, and receive a text response.
Bedrock does not require a separate SDK; it follows standard AWS SigV4 authentication. For brevity we’ll use the aws4 package to sign the request. You could also use the official @aws-sdk/signature-v4 helpers.
npm install aws4
import aws4 from "aws4";
import { readFileSync } from "fs";
const BEDROCK_ENDPOINT = "bedrock-runtime.us-east-1.amazonaws.com";
const MODEL_ID = "anthropic.claude-v2"; // example model
async function callClaude(prompt) {
const body = JSON.stringify({
prompt,
max_tokens_to_sample: 1024,
temperature: 0.2,
// other model‑specific parameters...
});
const request = {
host: BEDROCK_ENDPOINT,
path: `/model/${MODEL_ID}/invoke`,
method: "POST",
headers: {
"Content-Type": "application/json",
},
body,
};
// Sign the request with the task role's credentials (available via env vars).
const signed = aws4.sign(request, {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
sessionToken: process.env.AWS_SESSION_TOKEN,
});
const response = await fetch(`https://${BEDROCK_ENDPOINT}${request.path}`, {
method: request.method,
headers: signed.headers,
body,
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Claude request failed: ${response.status} ${err}`);
}
const data = await response.json();
// Claude returns `{ "completion": "..." }`
return data.completion;
}
// Example usage inside the IIFE from the previous section:
(async () => {
const prompt = await buildPrompt();
const suggestion = await callClaude(prompt);
console.log("Claude suggestion received:", suggestion.slice(0, 200), "...");
// Next: write suggestion back to GitHub.
})();
Step‑by‑step
- Build a JSON payload containing the prompt and model parameters.
- Construct a raw HTTP request object.
- Sign the request with SigV4 (AWS’s request‑signing protocol) using the task role credentials that are automatically injected into the container.
-
fetchthe signed request to the Bedrock endpoint. - Parse the JSON response and extract the
completionfield, which holds the edited code.
Analogy
Think of the container as a post‑office clerk. The clerk writes a letter (the prompt), stamps it with a secret seal (the SigV4 signature), and drops it into the mailbox (the Bedrock endpoint). Bedrock reads the sealed letter, writes a reply, and puts it back in the mailbox. The clerk then reads the reply.
Gotchas for Bedrock calls
-
IAM permission mix‑up: The task role needs
bedrock:InvokeModel. If you mistakenly grant it to the execution role, the request will be rejected with a 403 error that looks like a network failure. - Cold‑start delay: The first invocation of a new model may take several seconds because Bedrock needs to spin up the model container. Design your pipe with a modest timeout (e.g., 30 seconds) and consider a retry strategy.
- Response size limit: Bedrock caps the response at a few megabytes. For very large multi‑file diffs you may need to chunk the request.
Writing the Suggested Changes Back to GitHub
Tip: Use the official
@octokit/restlibrary; it handles pagination and retries for you.
The container now has a text block that contains the edited files. We need to turn that into a new branch and a PR (or push directly to the existing PR). The simplest approach:
- Create a new branch based on the PR’s head SHA.
- For each file in the suggestion, call
PUT /repos/:owner/:repo/contents/:path. - Open a new PR that targets the original branch.
Below is a compact, fully commented script that does exactly that.
npm install @octokit/rest
import { Octokit } from "@octokit/rest";
const octokit = new Octokit({
auth: process.env.GITHUB_TOKEN, // a fine‑grained PAT with repo access
});
/**
* Parse Claude's output into a map of file paths → new content.
* Expected format:
* --- src/index.ts ---
* <new content>
* --- src/util.js ---
* <new content>
*/
function parseClaudeOutput(output) {
const fileMap = {};
const sections = output.split(/^---\s+/m); // split on lines starting with "--- "
for (const sec of sections) {
if (!sec.trim()) continue;
const [header, ...body] = sec.split("\n");
const path = header.trim().replace("---", "").trim(); // e.g., src/index.ts
fileMap[path] = body.join("\n").trim();
}
return fileMap;
}
async function pushChanges(prNumber, suggestion) {
// 1️⃣ Get PR details to know repo, owner, and base SHA.
const { data: pr } = await octokit.rest.pulls.get({
owner: "my-org",
repo: "my-repo",
pull_number: prNumber,
});
const newBranch = `ai/suggested-${prNumber}`;
// 2️⃣ Create the branch if it does not exist.
try {
await octokit.rest.git.createRef({
owner: "my-org",
repo: "my-repo",
ref: `refs/heads/${newBranch}`,
sha: pr.head.sha, // start from the current PR head
});
console.log(`Created branch ${newBranch}`);
} catch (e) {
if (e.status === 422) {
console.log(`Branch ${newBranch} already exists, proceeding.`);
} else {
throw e;
}
}
// 3️⃣ Parse Claude's output.
const files = parseClaudeOutput(suggestion);
// 4️⃣ For each file, push the new content.
for (const [path, content] of Object.entries(files)) {
// Get the blob SHA of the current file (optional, helps with optimistic locking).
let sha;
try {
const { data: fileInfo } = await octokit.rest.repos.getContent({
owner: "my-org",
repo: "my-repo",
path,
ref: pr.head.sha,
});
sha = fileInfo.sha;
} catch (e) {
// If the file does not exist yet, leave sha undefined – GitHub will create it.
}
await octokit.rest.repos.createOrUpdateFileContents({
owner: "my-org",
repo: "my-repo",
path,
message: `AI: apply suggested changes for PR #${prNumber}`,
content: Buffer.from(content, "utf-8").toString("base64"),
branch: newBranch,
sha, // optional, helps avoid race conditions
});
console.log(`Updated ${path}`);
}
// 5️⃣ Open a PR from the AI branch to the original base branch.
const { data: newPR } = await octokit.rest.pulls.create({
owner: "my-org",
repo: "my-repo",
title: `[AI] Suggested changes for #${prNumber}`,
head: newBranch,
base: pr.base.ref,
body: "Automated suggestions generated by Claude via EventBridge Pipe.",
});
console.log(`Created AI PR #${newPR.number}`);
}
// In the IIFE from the previous sections:
(async () => {
const prompt = await buildPrompt();
const suggestion = await callClaude(prompt);
await pushChanges(event.detail.pull_request.number, suggestion);
})();
Explanation
-
parseClaudeOutputturns the raw text into a dictionary of file paths and new content. - We first fetch PR metadata to know the repo, owner, and current head SHA.
-
createRefmakes a new branch namedai/suggested-<pr-number>. If the branch already exists (perhaps because the pipe ran before), we reuse it. - For each file we read the current SHA (if any) and then call
createOrUpdateFileContents, which both creates new files and updates existing ones. - Finally we open a new PR that points the AI branch to the original base branch. Reviewers can merge it or request changes just like any other PR.
Gotchas for the GitHub step
-
Permission scope: The GitHub token must have
contents:writeandpull_requests:write. A token that only hasrepo:statuswill fail silently. - Rate limits: If many PRs open at once, you may hit GitHub’s API limits. Implement exponential back‑off (Octokit does it automatically for 429 responses).
- Branch naming collisions: Including the PR number in the branch name avoids clashes across multiple concurrent runs.
The Takeaway
In plain English: You can turn the act of “asking an AI for a code fix” into a fully automated, auditable pipeline that runs the moment a pull request appears.
- EventBridge Pipes let you connect a GitHub webhook directly to an ECS Fargate task without writing glue code.
- A tiny S3 bucket works as a version‑aware prompt cache, keeping the container’s input fast and consistent.
- Calling Claude via Bedrock requires only a signed
fetchrequest; no special SDK is needed. - After Claude returns edited code, the container uses Octokit to create a new branch and PR, preserving the change history.
- Remember the hidden pitfalls: filter timeouts, dead‑letter queues for ECS failures, S3 version staleness, IAM role confusion, and GitHub rate limits.
By wiring these pieces together you get a CI‑native AI assistant that:
- Reacts instantly to PR creation.
- Generates multi‑file edits in a single, reproducible run.
- Stores the result as a proper Git commit, ready for human review.
Give it a try on a low‑risk repository, watch the logs, and iterate on the prompt format. Once the pipeline is stable, you’ll find that the manual copy‑paste step disappears, freeing reviewers to focus on design and logic instead of tedious typing. Happy automating!
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-10-01 · Primary focus: EventBridge
All code blocks are intended to be correct and runnable, but please verify them
against the official docs for the tools mentioned before using in production.Find an error? Drop a comment — corrections are always welcome.
Top comments (0)