name: AI - PR Title Review on: pull_request: types: [opened, edited] branches: [main] permissions: # required for secure-repo hardening contents: read jobs: ai-title-review: permissions: contents: read pull-requests: write models: read runs-on: ubuntu-latest steps: - name: Harden Runner uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: fetch-depth: 0 - name: Configure Git to suppress detached HEAD warning run: git config --global advice.detachedHead false - name: Setup GitHub App Bot if: github.actor != 'dependabot[bot]' id: setup-bot uses: ./.github/actions/setup-bot continue-on-error: true with: app-id: ${{ secrets.GH_APP_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Check if actor is repo developer id: actor run: | if [[ "${{ github.actor }}" == *"[bot]" ]]; then echo "PR opened by a bot – skipping AI title review." echo "is_repo_dev=false" >> $GITHUB_OUTPUT exit 0 fi if [ ! -f .github/config/repo_devs.json ]; then echo "Error: .github/config/repo_devs.json not found" >&2 exit 1 fi # Validate JSON and extract repo_devs REPO_DEVS=$(jq -r '.repo_devs[]' .github/config/repo_devs.json 2>/dev/null || { echo "Error: Invalid JSON in repo_devs.json" >&2; exit 1; }) # Convert developer list into Bash array mapfile -t DEVS_ARRAY <<< "$REPO_DEVS" if [[ " ${DEVS_ARRAY[*]} " == *" ${{ github.actor }} "* ]]; then echo "is_repo_dev=true" >> $GITHUB_OUTPUT else echo "is_repo_dev=false" >> $GITHUB_OUTPUT fi - name: Get PR diff if: steps.actor.outputs.is_repo_dev == 'true' id: get_diff run: | git fetch origin ${{ github.base_ref }} git diff origin/${{ github.base_ref }}...HEAD | head -n 10000 | grep -vP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x{202E}\x{200B}]' > pr.diff echo "diff<> $GITHUB_OUTPUT cat pr.diff >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - name: Check and sanitize PR title if: steps.actor.outputs.is_repo_dev == 'true' id: sanitize_pr_title env: PR_TITLE_RAW: ${{ github.event.pull_request.title }} run: | # Sanitize PR title: max 72 characters, only printable characters PR_TITLE=$(echo "$PR_TITLE_RAW" | tr -d '\n\r' | head -c 72 | sed 's/[^[:print:]]//g') if [[ ${#PR_TITLE} -lt 5 ]]; then echo "PR title is too short. Must be at least 5 characters." >&2 fi echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT - name: AI PR Title Analysis if: steps.actor.outputs.is_repo_dev == 'true' id: ai-title-analysis uses: actions/ai-inference@d645f067d89ee1d5d736a5990e327e504d1c5a4a # v1.1.0 with: model: openai/gpt-4o system-prompt-file: ".github/config/system-prompt.txt" prompt: | Based on the following input data: { "diff": "${{ steps.get_diff.outputs.diff }}", "pr_title": "${{ steps.sanitize_pr_title.outputs.pr_title }}" } Respond ONLY with valid JSON in the format: { "improved_rating": <0-10>, "improved_ai_title_rating": <0-10>, "improved_title": "" } - name: Validate and set SCRIPT_OUTPUT if: steps.actor.outputs.is_repo_dev == 'true' run: | cat < ai_response.json ${{ steps.ai-title-analysis.outputs.response }} EOF # Validate JSON structure jq -e ' (keys | sort) == ["improved_ai_title_rating", "improved_rating", "improved_title"] and (.improved_rating | type == "number" and . >= 0 and . <= 10) and (.improved_ai_title_rating | type == "number" and . >= 0 and . <= 10) and (.improved_title | type == "string") ' ai_response.json if [ $? -ne 0 ]; then echo "Invalid AI response format" >&2 cat ai_response.json >&2 exit 1 fi # Parse JSON fields IMPROVED_RATING=$(jq -r '.improved_rating' ai_response.json) IMPROVED_TITLE=$(jq -r '.improved_title' ai_response.json) # Limit comment length to 1000 characters COMMENT=$(cat < /tmp/ai-title-comment.md # Log input and output to the GitHub Step Summary echo "### šŸ¤– AI PR Title Analysis" >> $GITHUB_STEP_SUMMARY echo "### Input PR Title" >> $GITHUB_STEP_SUMMARY echo '```bash' >> $GITHUB_STEP_SUMMARY echo "${{ steps.sanitize_pr_title.outputs.pr_title }}" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY echo '### AI Response (raw JSON)' >> $GITHUB_STEP_SUMMARY echo '```json' >> $GITHUB_STEP_SUMMARY cat ai_response.json >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - name: Post comment on PR if needed if: steps.actor.outputs.is_repo_dev == 'true' uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 continue-on-error: true with: github-token: ${{ steps.setup-bot.outputs.token }} script: | const fs = require('fs'); const body = fs.readFileSync('/tmp/ai-title-comment.md', 'utf8'); const { GITHUB_REPOSITORY } = process.env; const [owner, repo] = GITHUB_REPOSITORY.split('/'); const issue_number = context.issue.number; const ratingMatch = body.match(/\*\*PR-Title Rating\*\*: (\d+)\/10/); const rating = ratingMatch ? parseInt(ratingMatch[1], 10) : null; const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]"; const comments = await github.rest.issues.listComments({ owner, repo, issue_number }); const existing = comments.data.find(c => c.user?.login === expectedActor && c.body.includes("## šŸ¤– AI PR Title Suggestion") ); if (rating === null) { console.log("No rating found in AI response – skipping."); return; } if (rating <= 5) { if (existing) { await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); console.log("Updated existing suggestion comment."); } else { await github.rest.issues.createComment({ owner, repo, issue_number, body }); console.log("Created new suggestion comment."); } } else { const praise = `## šŸ¤– AI PR Title Suggestion\n\nGreat job! The current PR title is clear and well-structured.\n\nāœ… No suggestions needed.\n\n---\n*Generated by GitHub Models AI*`; if (existing) { await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: praise }); console.log("Replaced suggestion with praise."); } else { console.log("Rating > 5 and no existing comment – skipping comment."); } } - name: is not repo dev if: steps.actor.outputs.is_repo_dev != 'true' run: | exit 0 # Skip the AI title review for non-repo developers - name: Clean up if: always() run: | rm -f pr.diff ai_response.json /tmp/ai-title-comment.md echo "Cleaned up temporary files." continue-on-error: true # Ensure cleanup runs even if previous steps fail