[pull] main from Stirling-Tools:main #140
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 | |
| with: | |
| egress-policy: audit | |
| - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 | |
| 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<<EOF" >> $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@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8 | |
| 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": "<ai generated title>" | |
| } | |
| - name: Validate and set SCRIPT_OUTPUT | |
| if: steps.actor.outputs.is_repo_dev == 'true' | |
| run: | | |
| cat <<EOF > 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 <<EOF | |
| ## 🤖 AI PR Title Suggestion | |
| **PR-Title Rating**: $IMPROVED_RATING/10 | |
| ### ⬇️ Suggested Title (copy & paste): | |
| \`\`\` | |
| $IMPROVED_TITLE | |
| \`\`\` | |
| --- | |
| *Generated by GitHub Models AI* | |
| EOF | |
| ) | |
| echo "$COMMENT" > /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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 | |
| 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 |