Writing pull request descriptions is tedious. You know it, your team knows it. But AI can handle it for you. In this tutorial, you'll build a practical CI workflow that reads your diff, asks an AI model to summarize it, and posts the result as a PR description โ using structured outputs to ensure the AI responds in a format your script can parse.
What you'll build: A GitHub Actions workflow triggered on every pull request. It generates a PR title, summary, test plan, and potential risks, then updates the PR description via the GitHub API.
Prerequisites
- A GitHub repository with at least one pull request
- An OpenAI API key (or any LLM API with structured output support)
- Basic familiarity with GitHub Actions and Node.js
Step 1: Create the Workflow File
Add a new file at .github/workflows/pr-description.yml. This workflow triggers when a PR is opened or synchronized.
name: AI PR Description
on:
pull_request:
types: [opened, synchronize]
permissions:
contents: read
pull-requests: write
jobs:
generate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm install openai
- name: Generate PR Description
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: node .github/scripts/generate-pr-description.mjs
Important: The pull-requests: write permission is required to update the PR description. Keep the token scoped narrowly.
Step 2: Write the AI Script
Create a Node.js script at .github/scripts/generate-pr-description.mjs. This script:
- Fetches the diff via GitHub's API.
- Sends the diff to the AI model with a structured output schema.
- Updates the PR description using the parsed response.
import { Octokit } from "@octokit/rest";
import OpenAI from "openai";
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const [owner, repo] = process.env.REPO.split("/");
const prNumber = Number(process.env.PR_NUMBER);
// 1. Fetch the diff
const { data: pr } = await octokit.rest.pulls.get({ owner, repo, pull_number: prNumber });
const { data: diff } = await octokit.rest.pulls.get({
owner, repo, pull_number: prNumber, mediaType: { format: "diff" }
});
// 2. Ask the AI for a structured PR description
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
response_format: { type: "json_object" },
messages: [
{ role: "system", content: "You are a senior dev reviewer. Analyze the diff and return JSON." },
{ role: "user", content: `Generate PR description for ${pr.title}\n\nDiff:\n${diff.slice(0, 8000)}` }
],
tools: [{
type: "function",
function: {
name: "set_pr_description",
description: "Set the PR description fields",
parameters: {
type: "object",
properties: {
summary: { type: "string" },
test_plan: { type: "string" },
risks: { type: "array", items: { type: "string" } }
},
required: ["summary", "test_plan", "risks"]
}
}
}]
});
// 3. Parse the structured output
const toolCall = completion.choices[0].message.tool_calls[0];
const parsed = JSON.parse(toolCall.function.arguments);
const description = `## Summary\n${parsed.summary}\n\n## Test Plan\n${parsed.test_plan}\n\n## Risks\n${parsed.risks.map(r => "- " + r).join("\n")}`;
// 4. Update the PR description
await octokit.rest.issues.update({ owner, repo, issue_number: prNumber, body: description });
console.log("PR description updated successfully!");
Why structured outputs? By forcing the model to use a function tool, you guarantee valid JSON. No more regex-parsing Markdown, no more hallucinations about field names. This pattern is directly reusable in any CI pipeline.
Step 3: Add Dependencies
You need @octokit/rest and openai as dependencies. Add a minimal package.json in your .github/scripts folder:
{
"name": "pr-description-generator",
"private": true,
"type": "module",
"dependencies": {
"@octokit/rest": "^20.0.2",
"openai": "^4.52.0"
}
}
Step 4: Test It
Commit the workflow and script to a branch, then open a pull request. You should see two things:
- The workflow running in the PR's checks.
- The PR description being replaced within seconds.
Success! Your team will never write a PR description from scratch again.
Next Steps
- Add a fallback if the AI call fails (use the original PR body).
- Truncate the diff to avoid token limits โ we already do this with
slice(0, 8000), but consider usingdiff --statfor large PRs. - Use a model that supports
response_format: { type: "json_object" }to guarantee JSON even without tools.
This workflow is a template. Swap the model for Claude, Gemini, or a local LLM, and adjust the schema to match your team's PR template. The key insight: structured outputs turn AI from a toy into a reliable automation tool.
The diff-based approach makes sense, but I'm curious how it handles PRs with mostly dependency or lockfile changes. Does the AI still produce useful context?