From 2928c12f9cc3cac632016cf8fe0e6d0609216355 Mon Sep 17 00:00:00 2001 From: Landon Sherwood Date: Thu, 12 Feb 2026 11:11:02 -0600 Subject: [PATCH 1/2] IM-2221 add auto fill template workflow --- .github/workflows/auto-fill-pr.yml | 270 +++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 .github/workflows/auto-fill-pr.yml diff --git a/.github/workflows/auto-fill-pr.yml b/.github/workflows/auto-fill-pr.yml new file mode 100644 index 0000000..570288e --- /dev/null +++ b/.github/workflows/auto-fill-pr.yml @@ -0,0 +1,270 @@ +name: Auto-fill PR Template + +on: + pull_request: + types: [opened] + +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 [[ "$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: | + # Get the diff (limited to avoid token overflow) + DIFF=$(git diff origin/${{ github.base_ref }}...HEAD -- . ':!package-lock.json' ':!yarn.lock' ':!*.min.js' ':!*.min.css' | head -c 30000) + + # Get file list + FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) + STATS=$(git diff --shortstat origin/${{ github.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'; + + // 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()); + + // Get description (Claude or fallback) + let description; + if (claudeSuccess) { + description = fs.readFileSync('/tmp/description.txt', 'utf8').trim(); + } else { + // Fallback: list changed files + const fileList = files.split('\n').slice(0, 10).map(f => `- \`${f}\``).join('\n'); + const moreFiles = fileCount > 10 ? `\n- ... and ${fileCount - 10} more files` : ''; + description = `\n\n**Files changed**\n${fileList}${moreFiles}`; + } + + // Get testing suggestions (Claude or fallback) + let testing; + if (testingSuccess) { + testing = fs.readFileSync('/tmp/testing.txt', 'utf8').trim(); + } else { + testing = ''; + } + + // Build ticket section + const ticketSection = ticketFound + ? `[${ticket}](https://twentyht.atlassian.net/browse/${ticket})` + : '\n[IM-XXX](https://twentyht.atlassian.net/browse/IM-XXX)'; + + // 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 = key === changeType ? 'x' : ' '; + typeChecklist += `- [${checked}] ${label}\n`; + } + + // Footer + const footer = claudeSuccess + ? '---\n_✨ Auto-generated by Claude_' + : '---\n_📝 Auto-filled (Claude unavailable)_'; + + const body = [ + '## Description', + '', + description, + '', + `_${stats}_`, + '', + '## Ticket', + '', + ticketSection, + '', + '## Type of Change', + '', + typeChecklist, + '## Testing Done', + '', + testing, + '', + '## Checklist', + '', + '- [ ] My code follows the project\'s style guidelines', + '- [ ] I have performed a self-review of my code', + '- [ ] I have added tests that prove my fix/feature works', + '- [ ] New and existing tests pass locally', + '- [ ] I have updated documentation as needed', + '', + footer + ].join('\n'); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + body: body + }); \ No newline at end of file From 4022e326da608f98189e8ae95a4e5f41d642d640 Mon Sep 17 00:00:00 2001 From: Landon Sherwood Date: Thu, 12 Feb 2026 12:17:40 -0600 Subject: [PATCH 2/2] leave empty to have Claude auto-generate --- .github/pull_request_template.md | 7 +- .github/workflows/auto-fill-pr.yml | 147 ++++++++++++++++++----------- 2 files changed, 97 insertions(+), 57 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 302eaea..70db09a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,11 +1,10 @@ ## Description - + ## Ticket - -[IM-123](https://twentyht.atlassian.net/browse/IM-123) + ## Type of Change @@ -18,7 +17,7 @@ ## Testing Done - + ## Checklist diff --git a/.github/workflows/auto-fill-pr.yml b/.github/workflows/auto-fill-pr.yml index 570288e..341efb3 100644 --- a/.github/workflows/auto-fill-pr.yml +++ b/.github/workflows/auto-fill-pr.yml @@ -183,34 +183,64 @@ jobs: const claudeSuccess = '${{ steps.claude.outputs.success }}' === 'true'; const testingSuccess = '${{ steps.testing.outputs.success }}' === 'true'; + // Get existing PR body + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number + }); + const existingBody = pr.body || ''; + + // Helper to check if a section is empty (only has comment placeholder) + const isSectionEmpty = (body, sectionHeader, nextHeader) => { + const sectionRegex = new RegExp(`## ${sectionHeader}\\s*([\\s\\S]*?)(?=## ${nextHeader}|$)`, '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]*?(?=## |$)/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', 'Ticket'); + const fillTicket = isSectionEmpty(existingBody, 'Ticket', 'Type of Change'); + const fillType = !hasCheckedType(existingBody) && changeType !== 'unknown'; + const fillTesting = isSectionEmpty(existingBody, 'Testing Done', 'Checklist'); + // Get description (Claude or fallback) let description; - if (claudeSuccess) { - description = fs.readFileSync('/tmp/description.txt', 'utf8').trim(); - } else { - // Fallback: list changed files - const fileList = files.split('\n').slice(0, 10).map(f => `- \`${f}\``).join('\n'); - const moreFiles = fileCount > 10 ? `\n- ... and ${fileCount - 10} more files` : ''; - description = `\n\n**Files changed**\n${fileList}${moreFiles}`; + 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 = `\n\n**Files changed**\n${fileList}${moreFiles}`; + } } - // Get testing suggestions (Claude or fallback) + // Get testing suggestions let testing; - if (testingSuccess) { + if (fillTesting && testingSuccess) { testing = fs.readFileSync('/tmp/testing.txt', 'utf8').trim(); - } else { - testing = ''; } // Build ticket section const ticketSection = ticketFound ? `[${ticket}](https://twentyht.atlassian.net/browse/${ticket})` - : '\n[IM-XXX](https://twentyht.atlassian.net/browse/IM-XXX)'; + : null; // Build type checkboxes const types = { @@ -224,47 +254,58 @@ jobs: let typeChecklist = ''; for (const [key, label] of Object.entries(types)) { - const checked = key === changeType ? 'x' : ' '; + const checked = (fillType && key === changeType) ? 'x' : ' '; typeChecklist += `- [${checked}] ${label}\n`; } - // Footer - const footer = claudeSuccess - ? '---\n_✨ Auto-generated by Claude_' - : '---\n_📝 Auto-filled (Claude unavailable)_'; - - const body = [ - '## Description', - '', - description, - '', - `_${stats}_`, - '', - '## Ticket', - '', - ticketSection, - '', - '## Type of Change', - '', - typeChecklist, - '## Testing Done', - '', - testing, - '', - '## Checklist', - '', - '- [ ] My code follows the project\'s style guidelines', - '- [ ] I have performed a self-review of my code', - '- [ ] I have added tests that prove my fix/feature works', - '- [ ] New and existing tests pass locally', - '- [ ] I have updated documentation as needed', - '', - footer - ].join('\n'); - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number, - body: body - }); \ No newline at end of file + // Build new body, preserving user content where filled + let newBody = existingBody; + + // Replace description if empty + if (fillDescription && description) { + newBody = newBody.replace( + /(## Description\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*)/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*)/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_✨ Auto-generated by Claude_' : '\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: context.payload.pull_request.number, + body: newBody + }); + } \ No newline at end of file