|
| 1 | +name: Claude Create PR |
| 2 | + |
| 3 | +on: |
| 4 | + workflow_dispatch: |
| 5 | + inputs: |
| 6 | + source_branch: |
| 7 | + description: "Source branch to create PR from (defaults to current branch)" |
| 8 | + required: false |
| 9 | + type: string |
| 10 | + target_branch: |
| 11 | + description: "Target branch to merge into" |
| 12 | + required: true |
| 13 | + type: choice |
| 14 | + options: |
| 15 | + - main |
| 16 | + - staging |
| 17 | + default: main |
| 18 | + pr_type: |
| 19 | + description: "Type of PR" |
| 20 | + required: true |
| 21 | + type: choice |
| 22 | + options: |
| 23 | + - release |
| 24 | + - feature |
| 25 | + - bugfix |
| 26 | + - hotfix |
| 27 | + default: release |
| 28 | + claude_review: |
| 29 | + description: "Request Claude review automatically" |
| 30 | + required: false |
| 31 | + type: boolean |
| 32 | + default: true |
| 33 | + |
| 34 | +jobs: |
| 35 | + action: |
| 36 | + runs-on: ubuntu-latest # Use GitHub-hosted runners for memory-intensive git/Claude operations |
| 37 | + timeout-minutes: 10 |
| 38 | + env: |
| 39 | + GH_TOKEN: ${{ secrets.ACTIONS_TOKEN }} |
| 40 | + steps: |
| 41 | + - name: Checkout |
| 42 | + uses: actions/checkout@v4 |
| 43 | + with: |
| 44 | + ref: ${{ github.ref }} |
| 45 | + token: ${{ secrets.ACTIONS_TOKEN }} |
| 46 | + fetch-depth: 0 |
| 47 | + |
| 48 | + - name: Determine source branch |
| 49 | + id: source-branch |
| 50 | + run: | |
| 51 | + if [ -n "${{ inputs.source_branch }}" ]; then |
| 52 | + SOURCE_BRANCH="${{ inputs.source_branch }}" |
| 53 | + echo "Using specified source branch: $SOURCE_BRANCH" |
| 54 | + git checkout "$SOURCE_BRANCH" |
| 55 | + else |
| 56 | + SOURCE_BRANCH=$(git branch --show-current) |
| 57 | + echo "Using current branch: $SOURCE_BRANCH" |
| 58 | + fi |
| 59 | + echo "source_branch=$SOURCE_BRANCH" >> $GITHUB_OUTPUT |
| 60 | +
|
| 61 | + - name: Validate branches |
| 62 | + id: validate |
| 63 | + run: | |
| 64 | + SOURCE_BRANCH="${{ steps.source-branch.outputs.source_branch }}" |
| 65 | + TARGET_BRANCH="${{ inputs.target_branch }}" |
| 66 | +
|
| 67 | + echo "Source branch: $SOURCE_BRANCH" |
| 68 | + echo "Target branch: $TARGET_BRANCH" |
| 69 | +
|
| 70 | + # Check if source branch exists |
| 71 | + if ! git show-ref --verify --quiet refs/heads/$SOURCE_BRANCH && ! git show-ref --verify --quiet refs/remotes/origin/$SOURCE_BRANCH; then |
| 72 | + echo "❌ Source branch $SOURCE_BRANCH does not exist" |
| 73 | + exit 1 |
| 74 | + fi |
| 75 | +
|
| 76 | + # Check if target branch exists |
| 77 | + if ! git show-ref --verify --quiet refs/heads/$TARGET_BRANCH && ! git show-ref --verify --quiet refs/remotes/origin/$TARGET_BRANCH; then |
| 78 | + echo "❌ Target branch $TARGET_BRANCH does not exist" |
| 79 | + exit 1 |
| 80 | + fi |
| 81 | +
|
| 82 | + # Check if branches are different |
| 83 | + if [ "$SOURCE_BRANCH" = "$TARGET_BRANCH" ]; then |
| 84 | + echo "❌ Source and target branches cannot be the same" |
| 85 | + exit 1 |
| 86 | + fi |
| 87 | +
|
| 88 | + echo "✅ Branch validation passed" |
| 89 | +
|
| 90 | + - name: Analyze changes with Claude |
| 91 | + id: analyze |
| 92 | + run: | |
| 93 | + SOURCE_BRANCH="${{ steps.source-branch.outputs.source_branch }}" |
| 94 | + TARGET_BRANCH="${{ inputs.target_branch }}" |
| 95 | + PR_TYPE="${{ inputs.pr_type }}" |
| 96 | +
|
| 97 | + # Get commit range for analysis |
| 98 | + git fetch origin $TARGET_BRANCH |
| 99 | + COMMIT_RANGE="origin/$TARGET_BRANCH...$SOURCE_BRANCH" |
| 100 | +
|
| 101 | + # Get diff stats (limit output to prevent memory issues) |
| 102 | + DIFF_STATS=$(git diff --stat --no-color $COMMIT_RANGE | head -50) # Limit to 50 lines |
| 103 | + FILES_CHANGED=$(git diff --name-only $COMMIT_RANGE | wc -l) |
| 104 | + COMMITS_COUNT=$(git rev-list --count $COMMIT_RANGE) |
| 105 | +
|
| 106 | + # Get commit messages (limit to recent commits) |
| 107 | + COMMIT_MESSAGES=$(git log --oneline --no-color $COMMIT_RANGE | head -20) # Limit to 20 commits |
| 108 | +
|
| 109 | + # Get detailed changes for analysis |
| 110 | + DETAILED_CHANGES=$(git diff $COMMIT_RANGE --name-status --no-color | head -100) # Limit to 100 files |
| 111 | +
|
| 112 | + # Create analysis prompt for Claude using envsubst |
| 113 | + export SOURCE_BRANCH TARGET_BRANCH PR_TYPE FILES_CHANGED COMMITS_COUNT COMMIT_MESSAGES DIFF_STATS DETAILED_CHANGES |
| 114 | +
|
| 115 | + cat << 'EOF' > /tmp/pr_analysis_template.txt |
| 116 | + I need you to analyze the following git changes and create a comprehensive PR title and description. |
| 117 | +
|
| 118 | + **Context:** |
| 119 | + - Repository: Application codebase |
| 120 | + - Source Branch: $SOURCE_BRANCH |
| 121 | + - Target Branch: $TARGET_BRANCH |
| 122 | + - PR Type: $PR_TYPE |
| 123 | + - Files Changed: $FILES_CHANGED |
| 124 | + - Commits: $COMMITS_COUNT |
| 125 | +
|
| 126 | + **Commit Messages:** |
| 127 | + $COMMIT_MESSAGES |
| 128 | +
|
| 129 | + **Diff Statistics:** |
| 130 | + $DIFF_STATS |
| 131 | +
|
| 132 | + **Detailed Changes:** |
| 133 | + $DETAILED_CHANGES |
| 134 | +
|
| 135 | + Please provide: |
| 136 | + 1. A concise, descriptive PR title (50-72 characters) |
| 137 | + 2. A comprehensive PR description with: |
| 138 | + - Summary of changes |
| 139 | + - Key accomplishments |
| 140 | + - Breaking changes (if any) |
| 141 | + - Testing notes |
| 142 | + - Infrastructure considerations (avoid mentioning specific script paths or commands) |
| 143 | +
|
| 144 | + Format the response as: |
| 145 | + TITLE: [your title here] |
| 146 | +
|
| 147 | + DESCRIPTION: |
| 148 | + [your description here] |
| 149 | + EOF |
| 150 | +
|
| 151 | + # Create the final prompt by writing directly to a file (avoiding envsubst issues) |
| 152 | + cat > /tmp/pr_analysis_prompt.txt << PROMPT_EOF |
| 153 | + I need you to analyze the following git changes and create a comprehensive PR title and description. |
| 154 | +
|
| 155 | + **Context:** |
| 156 | + - Source Branch: $SOURCE_BRANCH |
| 157 | + - Target Branch: $TARGET_BRANCH |
| 158 | + - PR Type: $PR_TYPE |
| 159 | + - Files Changed: $FILES_CHANGED |
| 160 | + - Commits: $COMMITS_COUNT |
| 161 | +
|
| 162 | + **Commit Messages:** |
| 163 | + $COMMIT_MESSAGES |
| 164 | +
|
| 165 | + **Diff Statistics:** |
| 166 | + $DIFF_STATS |
| 167 | +
|
| 168 | + **Detailed Changes:** |
| 169 | + $DETAILED_CHANGES |
| 170 | +
|
| 171 | + Please provide: |
| 172 | + 1. A concise, descriptive PR title (50-72 characters) |
| 173 | + 2. A comprehensive PR description with: |
| 174 | + - Summary of changes |
| 175 | + - Key accomplishments |
| 176 | + - Breaking changes (if any) |
| 177 | + - Testing notes |
| 178 | + - Infrastructure considerations (avoid mentioning specific script paths or commands) |
| 179 | +
|
| 180 | + Format the response as: |
| 181 | + TITLE: [your title here] |
| 182 | +
|
| 183 | + DESCRIPTION: |
| 184 | + [your description here] |
| 185 | + PROMPT_EOF |
| 186 | +
|
| 187 | + echo "analysis_prompt_file=/tmp/pr_analysis_prompt.txt" >> $GITHUB_OUTPUT |
| 188 | + echo "commit_range=$COMMIT_RANGE" >> $GITHUB_OUTPUT |
| 189 | +
|
| 190 | + - name: Call Claude API |
| 191 | + id: claude |
| 192 | + run: | |
| 193 | + # Call Claude API with the analysis prompt |
| 194 | + PROMPT=$(cat /tmp/pr_analysis_prompt.txt) |
| 195 | +
|
| 196 | + # Create JSON payload using jq to properly escape content |
| 197 | + RESPONSE=$(jq -n \ |
| 198 | + --arg model "claude-sonnet-4-20250514" \ |
| 199 | + --arg content "$PROMPT" \ |
| 200 | + '{ |
| 201 | + "model": $model, |
| 202 | + "max_tokens": 4000, |
| 203 | + "messages": [ |
| 204 | + { |
| 205 | + "role": "user", |
| 206 | + "content": $content |
| 207 | + } |
| 208 | + ] |
| 209 | + }' | curl -s -X POST "https://api.anthropic.com/v1/messages" \ |
| 210 | + -H "Content-Type: application/json" \ |
| 211 | + -H "x-api-key: ${{ secrets.ANTHROPIC_API_KEY }}" \ |
| 212 | + -H "anthropic-version: 2023-06-01" \ |
| 213 | + -d @-) |
| 214 | +
|
| 215 | + # Check for API errors first |
| 216 | + if echo "$RESPONSE" | jq -e '.error' > /dev/null; then |
| 217 | + echo "❌ Claude API error: $(echo "$RESPONSE" | jq -r '.error.message')" |
| 218 | + exit 1 |
| 219 | + fi |
| 220 | +
|
| 221 | + # Extract title and description from Claude's response |
| 222 | + CLAUDE_OUTPUT=$(echo "$RESPONSE" | jq -r '.content[0].text') |
| 223 | +
|
| 224 | + # Parse the title and description |
| 225 | + PR_TITLE=$(echo "$CLAUDE_OUTPUT" | grep "^TITLE:" | sed 's/^TITLE: //') |
| 226 | + PR_DESCRIPTION=$(echo "$CLAUDE_OUTPUT" | sed -n '/^DESCRIPTION:/,$ p' | sed '1d') |
| 227 | +
|
| 228 | + # Validate that Claude returned meaningful content |
| 229 | + if [ -z "$PR_TITLE" ] || [ -z "$PR_DESCRIPTION" ]; then |
| 230 | + echo "❌ Claude returned empty title or description" |
| 231 | + echo "Raw Claude output: $CLAUDE_OUTPUT" |
| 232 | + exit 1 |
| 233 | + fi |
| 234 | +
|
| 235 | + # Save to outputs (escape for JSON) |
| 236 | + { |
| 237 | + echo "pr_title<<EOF" |
| 238 | + echo "$PR_TITLE" |
| 239 | + echo "EOF" |
| 240 | + } >> $GITHUB_OUTPUT |
| 241 | +
|
| 242 | + { |
| 243 | + echo "pr_description<<EOF" |
| 244 | + echo "$PR_DESCRIPTION" |
| 245 | + echo "EOF" |
| 246 | + } >> $GITHUB_OUTPUT |
| 247 | +
|
| 248 | + echo "✅ Claude analysis completed" |
| 249 | +
|
| 250 | + - name: Create Pull Request |
| 251 | + id: create-pr |
| 252 | + run: | |
| 253 | + SOURCE_BRANCH="${{ steps.source-branch.outputs.source_branch }}" |
| 254 | + TARGET_BRANCH="${{ inputs.target_branch }}" |
| 255 | + PR_TITLE="${{ steps.claude.outputs.pr_title }}" |
| 256 | +
|
| 257 | + # Create PR description with Claude analysis + footer |
| 258 | + cat > /tmp/pr_description.txt << EOF |
| 259 | + ${{ steps.claude.outputs.pr_description }} |
| 260 | +
|
| 261 | + --- |
| 262 | + 🤖 Generated with [Claude Code](https://claude.ai/code) |
| 263 | +
|
| 264 | + **Branch Info:** |
| 265 | + - Source: \`$SOURCE_BRANCH\` |
| 266 | + - Target: \`$TARGET_BRANCH\` |
| 267 | + - Type: ${{ inputs.pr_type }} |
| 268 | +
|
| 269 | + Co-Authored-By: Claude <noreply@anthropic.com> |
| 270 | + EOF |
| 271 | +
|
| 272 | + PR_DESCRIPTION=$(cat /tmp/pr_description.txt) |
| 273 | +
|
| 274 | + # Create the PR |
| 275 | + PR_URL=$(gh pr create \ |
| 276 | + --title "$PR_TITLE" \ |
| 277 | + --body "$PR_DESCRIPTION" \ |
| 278 | + --base "$TARGET_BRANCH" \ |
| 279 | + --head "$SOURCE_BRANCH") |
| 280 | +
|
| 281 | + echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT |
| 282 | + echo "✅ Pull Request created: $PR_URL" |
| 283 | +
|
| 284 | + - name: Request Claude review |
| 285 | + if: ${{ inputs.claude_review }} |
| 286 | + run: | |
| 287 | + PR_NUMBER=$(echo "${{ steps.create-pr.outputs.pr_url }}" | sed 's/.*pull\///') |
| 288 | +
|
| 289 | + gh pr comment "$PR_NUMBER" --body "@claude please review this PR" |
| 290 | + echo "✅ Claude review requested" |
| 291 | +
|
| 292 | + - name: Create summary |
| 293 | + run: | |
| 294 | + SOURCE_BRANCH="${{ steps.source-branch.outputs.source_branch }}" |
| 295 | + TARGET_BRANCH="${{ inputs.target_branch }}" |
| 296 | + PR_URL="${{ steps.create-pr.outputs.pr_url }}" |
| 297 | +
|
| 298 | + echo "## 🚀 Claude-Powered PR Created" >> $GITHUB_STEP_SUMMARY |
| 299 | + echo "" >> $GITHUB_STEP_SUMMARY |
| 300 | + echo "**PR URL:** [$PR_URL]($PR_URL)" >> $GITHUB_STEP_SUMMARY |
| 301 | + echo "**Source:** \`$SOURCE_BRANCH\`" >> $GITHUB_STEP_SUMMARY |
| 302 | + echo "**Target:** \`$TARGET_BRANCH\`" >> $GITHUB_STEP_SUMMARY |
| 303 | + echo "**Type:** ${{ inputs.pr_type }}" >> $GITHUB_STEP_SUMMARY |
| 304 | + echo "" >> $GITHUB_STEP_SUMMARY |
| 305 | + echo "### PR Details" >> $GITHUB_STEP_SUMMARY |
| 306 | + echo "**Title:** ${{ steps.claude.outputs.pr_title }}" >> $GITHUB_STEP_SUMMARY |
| 307 | + echo "" >> $GITHUB_STEP_SUMMARY |
| 308 | + echo "**Commits:** ${{ steps.analyze.outputs.commit_range }}" >> $GITHUB_STEP_SUMMARY |
0 commit comments