GitHub Actions to Auto-Write PR Descriptions

Crafting a good pull request description is crucial. It’s the primary way you communicate your changes to reviewers, explain the "why" behind your code, outline a test plan, and flag potential risks. A well-written description speeds up reviews, improves code quality, and serves as valuable documentation for future reference.

Yet, let's be honest: writing detailed PR descriptions often feels like a chore. In the rush to merge, it's easy to default to a single line or even leave it blank. This isn't ideal for anyone involved in the development process.

What if you could automate this process? What if your PR description could be generated automatically, summarizing your changes, suggesting a test plan, and highlighting risks, all before you even type a single character? This isn't science fiction; it's entirely achievable using GitHub Actions and the power of large language models (LLMs).

The Core Idea: Automating with Diffs and AI

The fundamental concept is straightforward: 1. Trigger: A GitHub Action listens for pull_request events (e.g., when a PR is opened or updated). 2. Extract Diff: The action fetches the complete diff for the pull request. This diff is the source of truth for all changes. 3. Process with AI: The diff is then sent to an LLM, which is prompted to generate a structured PR description based on the code changes. 4. Update PR: The generated description is then used to update the pull request's body via the GitHub API.

This approach leverages the strengths of both GitHub Actions for automation and LLMs for intelligent content generation.

Setting Up Your GitHub Action

Let's walk through setting up a basic GitHub Action. You'll need a .github/workflows/auto-pr-description.yml file in your repository.

Here's the initial structure for an action that triggers on PR creation and updates, and simply fetches the diff:

name: Auto-Write PR Description

on:
  pull_request:
    types: [opened, synchronize] # Trigger on PR open and new commits

jobs:
  generate_description:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write # Grant permission to write to PRs
      contents: read       # Grant permission to read repository contents

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0 # Fetch all history for accurate diffing

      - name: Get PR diff
        id: get_diff
        run: |
          # Get the diff between the base branch and the head branch of the PR
          PR_DIFF=$(git diff ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }})
          echo "PR_DIFF_RAW<<EOF" >> $GITHUB_OUTPUT
          echo "$PR_DIFF" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT
        shell: bash

In this example: * We use actions/checkout@v4 to get your repository's code. fetch-depth: 0 is important to ensure git diff can correctly compare against the base branch, even if the base has a long history. * The Get PR diff step uses git diff to extract the changes. github.event.pull_request.base.sha and github.event.pull_request.head.sha provide the exact commit SHAs for the comparison. * We store the diff in a multi-line output variable PR_DIFF_RAW using a delimiter (EOF) to handle its potentially large size and special characters.

Integrating with an LLM

Now for the interesting part: sending this diff to an LLM. For this example, we'll use a curl command to interact with the OpenAI API. You'd typically use a dedicated action or a more robust script for production, but curl demonstrates the core principle.

You'll need an OpenAI API key, which should be stored securely as a GitHub Secret (e.g., OPENAI_API_KEY).

Let's modify the previous action to include an LLM call:

# ... (previous YAML for name, on, jobs, runs-on, permissions) ...

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Get PR diff
        id: get_diff
        run: |
          PR_DIFF=$(git diff ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }})
          echo "PR_DIFF_RAW<<EOF" >> $GITHUB_OUTPUT
          echo "$PR_DIFF" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT
        shell: bash

      - name: Generate PR description with LLM
        id: generate_description
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          PR_DIFF: ${{ steps.get_diff.outputs.PR_DIFF_RAW }}
        run: |
          # Prepare the prompt for the LLM
          PROMPT="You are an expert software engineer. Review the following code changes (diff) and generate a concise, structured pull request description. Include a summary of the changes, a suggested test plan, and any potential risks or breaking changes. Format it as Markdown.

          Diff:
          \`\`\`diff
          $PR_DIFF
          \`\`\`

          Please provide the description in the following format:
          ## Summary
          [Your summary here]

          ## Test Plan
          [Steps to test the changes]

          ## Risks & Breaking Changes
          [Any risks or breaking changes]"

          # Call the OpenAI API
          LLM_RESPONSE=$(curl -s -X POST "https://api.openai.com/v1/chat/completions" \
            -H "Content-Type: application/json" \
            -H "Authorization: Bearer $OPENAI_API_KEY" \
            -d '{
              "model": "gpt-3.5-turbo",
              "messages": [
                {"role": "system", "content": "You are a helpful assistant."},
                {"role": "user", "content": "'"${PROMPT}"'"}
              ],
              "temperature": 0.7
            }')

          # Extract the content from the LLM response
          GENERATED_DESCRIPTION=$(echo "$LLM_RESPONSE" | jq -r '.choices[0].message.content')

          # Handle cases where the LLM might not return content or has an error
          if [ -z "$GENERATED_DESCRIPTION" ] || echo "$GENERATED_DESCRIPTION" | grep -q "error"; then
            echo "LLM failed to generate description or returned an error."
            GENERATED_DESCRIPTION="Automated description failed. Please write manually."
          fi

          echo "GENERATED_DESCRIPTION<<EOF" >> $GITHUB_OUTPUT
          echo "$GENERATED_DESCRIPTION" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT
        shell: bash

In this new step: * We pass the OPENAI_API_KEY and the PR_DIFF as environment variables. * The PROMPT is carefully crafted to instruct the LLM on what kind of output we expect, including the desired Markdown structure. Good prompting is key to getting useful results. * We use curl to send a POST request to the OpenAI chat/completions endpoint, specifying gpt-3.5-turbo (or gpt-4 for better quality/cost). * jq is used to parse the JSON response and extract the content of the LLM's message. * Basic error handling is included for when the LLM might fail or return an empty response.

Updating the PR Description

Finally, we need to take the generated description and apply it to the pull request. The gh CLI (GitHub CLI) is perfect for this, as it's pre-installed on GitHub Actions runners and handles authentication automatically when the pull-requests: write permission is granted.

Add this final step to your workflow:

```yaml

... (previous YAML for name, on, jobs, runs-on, permissions, and previous steps) ...

  - name: Update PR description
    env:
      GENERATED_DESCRIPTION: ${{ steps.generate_description.outputs.GENERATED_DESCRIPTION }}
    run: |
      PR_BODY_CURRENT=$(gh pr view ${{ github.event.pull_request.number }} --json body --jq .body)

      # Only update if the current PR body is empty or contains the default GitHub template
      # This prevents overwriting a description a developer might have already started writing.
      if [ -z "$PR_BODY_CURRENT" ]