DEV Community

Dinesh_gowtham
Dinesh_gowtham

Posted on

How to Automate Pull‑Request Code Reviews with ChatGPT’s Function Calling and AWS Lambda (Node.js/TypeScript)

AI code reviewers sound risky, but OpenAI’s function‑calling lets you turn a language model into a deterministic reviewer. In a few lines of TypeScript you can parse a PR diff, ask ChatGPT to generate a structured review, and post it back to GitHub—all inside a secure Lambda function. No custom prompt‑hacking required, just clean schema‑driven calls.

Why Function Calling Changes the Game

When you ask a language model to “review this code”, it replies with free‑form text. That text is great for conversation but terrible for automation because the format can drift over time. Function calling is a feature that forces the model to return data that matches a schema you define.

In plain English: Think of the model as a helpful intern. Instead of letting the intern write a loose essay, you give them a fill‑in‑the‑blank form. The intern still uses its knowledge, but the result is a predictable JSON object you can hand to other tools.

Why does this matter for code review?

  1. Determinism – Your CI/CD pipeline can treat the output as a contract (summary string, issues array) rather than parsing prose.
  2. Testability – You can write unit tests that compare the returned JSON to an expected shape.
  3. Safety – If the model tries to “hallucinate” extra fields, the API will reject the call, preventing malformed data from reaching GitHub.

The only extra work is writing a function definition that describes the JSON shape. The model does the heavy lifting of filling it in, while you stay in control of the surrounding logic.

Setting Up the Lambda Function

A Lambda function is a tiny piece of code that runs in AWS without you managing servers. For a PR reviewer we need a function that:

  1. Receives a GitHub webhook (HTTP POST) when a pull request is opened or updated.
  2. Extracts the list of changed files and builds a diff string.
  3. Calls OpenAI’s chat completion endpoint with a function definition.
  4. Posts the JSON result back to GitHub as a comment.

Minimal package.json

{
  "name": "pr-review-lambda",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "dependencies": {
    "openai": "^4.20.0",
    "@aws-sdk/client-lambda": "^3.540.0",
    "axios": "^1.7.2"
  },
  "devDependencies": {
    "typescript": "^5.5.2"
  }
}
Enter fullscreen mode Exit fullscreen mode

Why type: "module"? Node 22 runs ES modules by default; this avoids the classic require(esm) pitfall that silently breaks Lambda layers.

Tip: When you add a new layer to a Lambda, double‑check that the layer also uses ES modules. Mixing CommonJS (require) and ESM (import) can cause cryptic runtime errors.

Basic handler skeleton

// src/index.ts
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import axios from 'axios';
import { OpenAI } from 'openai';

// Environment variables set in the Lambda configuration
const OPENAI_API_KEY = process.env.OPENAI_API_KEY!;
const GITHUB_COMMENT_URL = process.env.GITHUB_COMMENT_URL!; // e.g. https://api.github.com/repos/owner/repo/issues/123/comments

const openai = new OpenAI({ apiKey: OPENAI_API_KEY });

export const handler = async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
  try {
    // 1️⃣ Parse GitHub webhook payload
    const payload = JSON.parse(event.body ?? '{}');
    const diff = await buildDiffFromPayload(payload);

    // 2️⃣ Ask ChatGPT for a structured review
    const review = await getReviewFromOpenAI(diff);

    // 3️⃣ Send the review back to GitHub
    await postReviewToGitHub(review);

    // 4️⃣ Lambda must return a proper HTTP response
    return {
      statusCode: 200,
      body: JSON.stringify({ message: 'Review posted' })
    };
  } catch (err) {
    console.error('Error in PR review Lambda', err);
    return {
      statusCode: 500,
      body: JSON.stringify({ error: (err as Error).message })
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

The handler function is the entry point AWS calls. It follows the same input → work → output pattern you already know from Express or Fastify.

Key takeaway: Keeping the Lambda thin (only orchestration) makes it easier to test each step in isolation.

Defining the Review Function Schema

OpenAI’s function calling expects a JSON schema that follows the OpenAPI style. The schema tells the model exactly which fields it may return and what types they have. For our reviewer we need:

{
  "name": "generate_review",
  "description": "Create a concise code‑review summary and list of issues found in the diff.",
  "parameters": {
    "type": "object",
    "properties": {
      "summary": {
        "type": "string",
        "description": "One‑sentence overview of the overall health of the PR."
      },
      "issues": {
        "type": "array",
        "description": "List of strings, each describing a single problem or suggestion.",
        "items": { "type": "string" }
      }
    },
    "required": ["summary", "issues"]
  }
}
Enter fullscreen mode Exit fullscreen mode

Where the schema lives in code

// src/openaiSchema.ts
export const reviewFunction = {
  name: 'generate_review',
  description: 'Create a concise code‑review summary and list of issues found in the diff.',
  parameters: {
    type: 'object' as const,
    properties: {
      summary: {
        type: 'string' as const,
        description: 'One‑sentence overview of the overall health of the PR.'
      },
      issues: {
        type: 'array' as const,
        description: 'List of strings, each describing a single problem or suggestion.',
        items: { type: 'string' as const }
      }
    },
    required: ['summary', 'issues']
  }
};
Enter fullscreen mode Exit fullscreen mode

Using as const tells TypeScript to treat the literal values as exact types, which later gives us type‑safe handling of the model’s response.

Analogy: The schema is like a blueprint for a LEGO set. The model has many bricks (knowledge) but can only build according to the instructions you provide.

The parsing gotcha

OpenAI sometimes packs the function call payload as a JSON string inside the function_call.arguments field, rather than as an object. If you try to access arguments.summary directly, you’ll get undefined and the Lambda crashes.

// src/openaiHelper.ts
import { ChatCompletionMessageParam } from 'openai/resources/chat/completions';

export async function getReviewFromOpenAI(diff: string) {
  const response = await openai.chat.completions.create({
    model: 'gpt-4o-mini-2024-07-18',
    messages: [{ role: 'user', content: diff }],
    functions: [reviewFunction],
    function_call: { name: 'generate_review' }
  });

  // The model may return arguments as a string; we must detect and parse safely.
  const fc = response.choices[0].message?.function_call;
  if (!fc) throw new Error('OpenAI did not return a function call');

  let args: any;
  try {
    // If `fc.arguments` is already an object, JSON.parse will throw – so we catch both cases.
    args = typeof fc.arguments === 'string' ? JSON.parse(fc.arguments) : fc.arguments;
  } catch (e) {
    console.error('Failed to parse function arguments', fc.arguments);
    throw new Error('Malformed function call payload');
  }

  // At this point `args` matches the schema { summary: string, issues: string[] }
  return args as { summary: string; issues: string[] };
}
Enter fullscreen mode Exit fullscreen mode

Tip: Wrap the parsing in a try / catch block and log the raw payload. That way you can see exactly what OpenAI sent when the format changes.

Calling OpenAI from Node.js with Type‑Safe Types

Now that the schema is defined and the parsing logic is guarded, we can focus on the type‑safety that prevents runtime surprises.

Strongly typed OpenAI request

import { ChatCompletionCreateParams } from 'openai/resources/chat/completions';

// Build the request with explicit types so the compiler checks required fields.
const request: ChatCompletionCreateParams = {
  model: 'gpt-4o-mini-2024-07-18',
  messages: [{ role: 'user', content: diff }],
  functions: [reviewFunction],
  function_call: { name: 'generate_review' }
};

const response = await openai.chat.completions.create(request);
Enter fullscreen mode Exit fullscreen mode

If you accidentally miss function_call, TypeScript will flag the error before you run the code.

Why typing matters for production pipelines

  • Early feedback – Your IDE instantly tells you when you forget a required property.
  • Clear contracts – Anyone reading the Lambda later knows exactly what shape the OpenAI request and response have.
  • Refactor safety – Renaming summary to overview in the schema automatically updates the type definition, so you don’t end up with mismatched keys.

In plain English: Think of TypeScript as a spell‑checker for your code’s grammar, catching mistakes before they become bugs in a live review.

Putting It All Together: Deploy and Test

1️⃣ Build the diff from the GitHub webhook

GitHub’s payload contains pull_request.changed_files and a files_url. We fetch each file’s patch and concatenate them.

// src/diffBuilder.ts
import axios from 'axios';

export async function buildDiffFromPayload(payload: any): Promise<string> {
  const pr = payload.pull_request;
  const filesUrl = pr.url + '/files';
  const token = process.env.GITHUB_TOKEN!; // needs repo scope

  const res = await axios.get(filesUrl, {
    headers: { Authorization: `token ${token}` }
  });

  // `res.data` is an array of file objects; we keep only the `patch` strings.
  const patches = res.data
    .filter((f: any) => !!f.patch) // ignore binary files
    .map((f: any) => `--- ${f.filename}\n${f.patch}`);

  // Join patches with a clear delimiter so the model knows where one file ends.
  return patches.join('\n\n');
}
Enter fullscreen mode Exit fullscreen mode

If the webhook arrives for a “draft” PR, you might skip the review by returning an empty string early. This keeps the Lambda cheap.

2️⃣ Post the review back to GitHub

// src/githubPoster.ts
export async function postReviewToGitHub(review: { summary: string; issues: string[] }) {
  const commentBody = `**Automated Review**\n\n**Summary:** ${review.summary}\n\n**Issues:**\n${review.issues
    .map(issue => `- ${issue}`)
    .join('\n')}`;

  await axios.post(
    GITHUB_COMMENT_URL,
    { body: commentBody },
    { headers: { Authorization: `token ${process.env.GITHUB_TOKEN!}` } }
  );
}
Enter fullscreen mode Exit fullscreen mode

GitHub expects a JSON payload { body: "string" }. By building the comment string in code we avoid markdown mistakes that would otherwise be hard to debug.

Key takeaway: Keeping the comment assembly in its own function makes it trivial to adjust formatting later (e.g., add emojis or check‑lists).

3️⃣ Deploy the Lambda

# 1️⃣ Compile TypeScript
npm run build

# 2️⃣ Zip the bundle (exclude devDependencies)
zip -r pr-review.zip dist node_modules

# 3️⃣ Create or update the function (example using AWS CLI)
aws lambda create-function \
  --function-name PRReview \
  --runtime nodejs22.x \
  --handler dist/index.handler \
  --zip-file fileb://pr-review.zip \
  --role arn:aws:iam::123456789012:role/lambda-execution \
  --environment Variables={OPENAI_API_KEY=...,GITHUB_TOKEN=...,GITHUB_COMMENT_URL=...}
Enter fullscreen mode Exit fullscreen mode

Gotcha: If you enable SnapStart on a Lambda that sits in a VPC, the cold‑start time will still be dominated by the VPC attachment, so SnapStart gives no real benefit. For a PR reviewer you typically don’t need VPC access, so keep the function out of a VPC to stay fast and cheap.

4️⃣ Test locally before pushing

You can simulate a GitHub webhook with a JSON file:

aws lambda invoke \
  --function-name PRReview \
  --payload file://sample-payload.json \
  response.json
Enter fullscreen mode Exit fullscreen mode

Inspect response.json. If you see a 500 error, check CloudWatch logs. The most common failure is the JSON‑string parsing issue described earlier.

Tip: Add a console.log(JSON.stringify(response, null, 2)) inside getReviewFromOpenAI just for local debugging. Remove or guard it in production to avoid leaking model data.

The Takeaway

  • Function calling turns a free‑form LLM output into a predictable JSON contract you can trust in CI pipelines.
  • TypeScript types enforce that contract at compile time, catching missing fields before they hit a live Lambda.
  • Parsing the function_call.arguments field safely avoids crashes when OpenAI returns a string instead of an object.
  • A thin Lambda orchestration (parse webhook → build diff → call OpenAI → post comment) keeps the codebase maintainable and cheap to run.
  • AWS-specific gotchas (SnapStart with VPC, ESM vs. CommonJS, provisioning costs) are easy to sidestep with the patterns shown above.

With these pieces in place you now have a repeatable, testable, and production‑ready automated code‑review step—without resorting to brittle prompt hacks or opaque black‑box behavior. Happy reviewing!


Transparency notice

This article was written with the help of an AI system — Groq (GPT OSS 120B).

Published: 2026-08-28 · Primary focus: ChatGPTForEngineers

All code blocks are intended to be correct and runnable, but please verify them
against OpenAI's official docs before using in production.

Find an error? Drop a comment — corrections are always welcome.

Top comments (0)