Skip to content

Fix section detection and footer spacing in auto-fill-pr #8

Fix section detection and footer spacing in auto-fill-pr

Fix section detection and footer spacing in auto-fill-pr #8

Workflow file for this run

name: Auto-fill PR Template
on:
pull_request:
types: [opened]
workflow_call:
secrets:
ANTHROPIC_API_KEY:
required: false
permissions:
contents: read
pull-requests: write
jobs:
auto-fill:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract ticket from branch name
id: ticket
run: |
BRANCH="${{ github.head_ref }}"
if [ -z "$BRANCH" ]; then
BRANCH="${{ github.ref }}"
BRANCH="${BRANCH#refs/heads/}"
fi
if [[ "$BRANCH" =~ ([Ii][Mm]-[0-9]+) ]]; then
TICKET="${BASH_REMATCH[1]^^}"
echo "ticket=$TICKET" >> $GITHUB_OUTPUT
echo "found=true" >> $GITHUB_OUTPUT
else
echo "found=false" >> $GITHUB_OUTPUT
fi
- name: Get diff for Claude
id: diff
run: |
# Determine base ref (github.base_ref is empty when triggered via workflow_call)
BASE_REF="${{ github.base_ref }}"
if [ -z "$BASE_REF" ]; then
# Fetch default branch from GitHub API
BASE_REF=$(curl -s -H "Authorization: Bearer ${{ github.token }}" \
"https://api.github.com/repos/${{ github.repository }}" | jq -r '.default_branch // "main"')
fi
# Ensure the base ref is fetched locally
git fetch origin "${BASE_REF}"
# Get the diff (limited to avoid token overflow)
DIFF=$(git diff origin/${BASE_REF}...HEAD -- . ':!package-lock.json' ':!yarn.lock' ':!*.min.js' ':!*.min.css' | head -c 30000)
# Get file list
FILES=$(git diff --name-only origin/${BASE_REF}...HEAD)
STATS=$(git diff --shortstat origin/${BASE_REF}...HEAD)
FILE_COUNT=$(echo "$FILES" | wc -l)
# Write to files to avoid escaping issues
echo "$DIFF" > /tmp/diff.txt
echo "$FILES" > /tmp/files.txt
echo "$STATS" > /tmp/stats.txt
echo "$FILE_COUNT" > /tmp/file_count.txt
- name: Detect change type
id: type
run: |
BRANCH="${{ github.head_ref }}"
BRANCH_LOWER=$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]')
if [[ "$BRANCH_LOWER" =~ ^fix|bugfix|hotfix ]]; then
echo "type=bug" >> $GITHUB_OUTPUT
elif [[ "$BRANCH_LOWER" =~ ^feat|feature ]]; then
echo "type=feature" >> $GITHUB_OUTPUT
elif [[ "$BRANCH_LOWER" =~ ^refactor ]]; then
echo "type=refactor" >> $GITHUB_OUTPUT
elif [[ "$BRANCH_LOWER" =~ ^docs ]]; then
echo "type=docs" >> $GITHUB_OUTPUT
elif [[ "$BRANCH_LOWER" =~ ^chore|deps|dependencies ]]; then
echo "type=chore" >> $GITHUB_OUTPUT
else
echo "type=unknown" >> $GITHUB_OUTPUT
fi
- name: Generate description with Claude
id: claude
continue-on-error: true
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
# Skip if no API key
if [ -z "$ANTHROPIC_API_KEY" ]; then
echo "No API key found, skipping Claude"
echo "success=false" >> $GITHUB_OUTPUT
exit 0
fi
DIFF=$(cat /tmp/diff.txt)
FILES=$(cat /tmp/files.txt)
STATS=$(cat /tmp/stats.txt)
PROMPT="Analyze this git diff and generate a concise PR description. Focus on WHAT changed and WHY it matters. Be specific but brief (2-4 sentences max).
Files changed:
$FILES
Stats: $STATS
Diff:
$DIFF
Respond with ONLY the description text, no headers or formatting."
RESPONSE=$(curl -s --max-time 30 https://api.anthropic.com/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-d "$(jq -n \
--arg prompt "$PROMPT" \
'{
model: "claude-sonnet-4-20250514",
max_tokens: 500,
messages: [{role: "user", content: $prompt}]
}')")
# Check for errors
ERROR=$(echo "$RESPONSE" | jq -r '.error.message // empty')
if [ -n "$ERROR" ]; then
echo "Claude API error: $ERROR"
echo "success=false" >> $GITHUB_OUTPUT
exit 0
fi
DESCRIPTION=$(echo "$RESPONSE" | jq -r '.content[0].text // empty')
if [ -z "$DESCRIPTION" ]; then
echo "Empty response from Claude"
echo "success=false" >> $GITHUB_OUTPUT
exit 0
fi
echo "$DESCRIPTION" > /tmp/description.txt
echo "success=true" >> $GITHUB_OUTPUT
- name: Generate testing suggestions with Claude
id: testing
continue-on-error: true
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
# Skip if description generation failed
if [ "${{ steps.claude.outputs.success }}" != "true" ]; then
echo "Skipping testing suggestions (description failed)"
echo "success=false" >> $GITHUB_OUTPUT
exit 0
fi
DIFF=$(cat /tmp/diff.txt)
FILES=$(cat /tmp/files.txt)
PROMPT="Based on this diff, suggest 2-3 specific things that should be tested. Be concise - one line per suggestion.
Files changed:
$FILES
Diff:
$DIFF
Respond with ONLY bullet points, no intro text."
RESPONSE=$(curl -s --max-time 30 https://api.anthropic.com/v1/messages \
-H "Content-Type: application/json" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-d "$(jq -n \
--arg prompt "$PROMPT" \
'{
model: "claude-sonnet-4-20250514",
max_tokens: 300,
messages: [{role: "user", content: $prompt}]
}')")
TESTING=$(echo "$RESPONSE" | jq -r '.content[0].text // empty')
if [ -n "$TESTING" ]; then
echo "$TESTING" > /tmp/testing.txt
echo "success=true" >> $GITHUB_OUTPUT
else
echo "success=false" >> $GITHUB_OUTPUT
fi
- name: Build and update PR body
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const ticket = '${{ steps.ticket.outputs.ticket }}';
const ticketFound = '${{ steps.ticket.outputs.found }}' === 'true';
const changeType = '${{ steps.type.outputs.type }}';
const claudeSuccess = '${{ steps.claude.outputs.success }}' === 'true';
const testingSuccess = '${{ steps.testing.outputs.success }}' === 'true';
// Get PR number - context.payload.pull_request is undefined for workflow_call
let prNumber;
if (context.payload.pull_request) {
prNumber = context.payload.pull_request.number;
} else {
const ref = context.ref.replace('refs/heads/', '');
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${context.repo.owner}:${ref}`
});
if (prs.length === 0) {
core.warning('No open PR found for this branch, skipping PR update');
return;
}
prNumber = prs[0].number;
}
// Get existing PR body
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
const existingBody = pr.body || '';
// Helper to check if a section is empty (only has comment placeholder)
const isSectionEmpty = (body, sectionHeader) => {
const sectionRegex = new RegExp(`## ${sectionHeader}\\s*([\\s\\S]*?)(?=\\n## |$)`, 'i');
const match = body.match(sectionRegex);
if (!match) return true;
const content = match[1].trim();
// Empty if only whitespace, empty, or just an HTML comment
return !content || /^<!--.*-->$/.test(content);
};
// Helper to check if any checkbox is checked in Type of Change
const hasCheckedType = (body) => {
const typeSection = body.match(/## Type of Change[\s\S]*?(?=\n## |$)/i);
if (!typeSection) return false;
return /\[x\]/i.test(typeSection[0]);
};
// Read file data
const files = fs.readFileSync('/tmp/files.txt', 'utf8').trim();
const stats = fs.readFileSync('/tmp/stats.txt', 'utf8').trim();
const fileCount = parseInt(fs.readFileSync('/tmp/file_count.txt', 'utf8').trim());
// Determine what to fill
const fillDescription = isSectionEmpty(existingBody, 'Description');
const fillTicket = isSectionEmpty(existingBody, 'Ticket');
const fillType = !hasCheckedType(existingBody) && changeType !== 'unknown';
const fillTesting = isSectionEmpty(existingBody, 'Testing Done');
// Get description (Claude or fallback)
let description;
if (fillDescription) {
if (claudeSuccess) {
description = fs.readFileSync('/tmp/description.txt', 'utf8').trim();
} else {
const fileList = files.split('\n').slice(0, 10).map(f => `- \`${f}\``).join('\n');
const moreFiles = fileCount > 10 ? `\n- ... and ${fileCount - 10} more files` : '';
description = `<!-- Claude unavailable -->\n\n**Files changed**\n${fileList}${moreFiles}`;
}
}
// Get testing suggestions
let testing;
if (fillTesting && testingSuccess) {
testing = fs.readFileSync('/tmp/testing.txt', 'utf8').trim();
}
// Build ticket section
const ticketSection = ticketFound
? `[${ticket}](https://twentyht.atlassian.net/browse/${ticket})`
: null;
// Build type checkboxes
const types = {
bug: 'Bug fix (non-breaking change that fixes an issue)',
feature: 'New feature (non-breaking change that adds functionality)',
breaking: 'Breaking change (fix or feature that would cause existing functionality to change)',
refactor: 'Refactor (code change that neither fixes a bug nor adds a feature)',
docs: 'Documentation update',
chore: 'Chore (dependency updates, config changes, etc.)'
};
let typeChecklist = '';
for (const [key, label] of Object.entries(types)) {
const checked = (fillType && key === changeType) ? 'x' : ' ';
typeChecklist += `- [${checked}] ${label}\n`;
}
// Build new body, preserving user content where filled
let newBody = existingBody;
// Replace description if empty
if (fillDescription && description) {
newBody = newBody.replace(
/(## Description\s*)(<!--[\s\S]*?-->)?(\s*)/i,
`$1\n${description}\n\n_${stats}_\n\n`
);
}
// Replace ticket if empty and found
if (fillTicket && ticketSection) {
newBody = newBody.replace(
/(## Ticket\s*)(<!--[\s\S]*?-->)?(\s*)/i,
`$1\n${ticketSection}\n\n`
);
}
// Replace type checkboxes if none checked
if (fillType) {
newBody = newBody.replace(
/## Type of Change[\s\S]*?(?=## Testing Done)/i,
`## Type of Change\n\n${typeChecklist}\n`
);
}
// Replace testing if empty
if (fillTesting && testing) {
newBody = newBody.replace(
/(## Testing Done\s*)(<!--[\s\S]*?-->)?(\s*)/i,
`$1\n${testing}\n\n`
);
}
// Add footer if we changed anything
const madeChanges = fillDescription || (fillTicket && ticketSection) || fillType || (fillTesting && testing);
if (madeChanges && !newBody.includes('Auto-generated by Claude') && !newBody.includes('Auto-filled')) {
const footer = claudeSuccess ? '\n\n---\n_✨ Auto-generated by Claude_' : '\n\n---\n_📝 Auto-filled_';
newBody = newBody.trim() + footer;
}
// Only update if body changed
if (newBody !== existingBody) {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
body: newBody
});
}