PR Comment #1089
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: PR Comment | |
| on: | |
| workflow_run: | |
| workflows: ["PR Check"] | |
| types: [completed] | |
| pull_request_review: | |
| types: [submitted] | |
| pull_request_target: | |
| types: [opened, edited, reopened] | |
| permissions: | |
| actions: read | |
| checks: read | |
| contents: read | |
| pull-requests: write | |
| jobs: | |
| comment: | |
| name: Post PR comment | |
| runs-on: ubuntu-latest | |
| if: github.event.workflow_run.event == 'pull_request' | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Download PR metadata | |
| id: download | |
| continue-on-error: true | |
| uses: actions/download-artifact@v7 | |
| with: | |
| name: pr-meta | |
| path: pr-meta | |
| run-id: ${{ github.event.workflow_run.id }} | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Resolve PR context | |
| id: context | |
| uses: actions/github-script@v8 | |
| with: | |
| github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} | |
| script: | | |
| const fs = require('fs'); | |
| // Determine the PR number | |
| let prNumber; | |
| const numberFile = 'pr-meta/number.txt'; | |
| if (fs.existsSync(numberFile)) { | |
| prNumber = parseInt(fs.readFileSync(numberFile, 'utf8').trim()); | |
| } | |
| if (!prNumber) { | |
| const prs = context.payload.workflow_run.pull_requests; | |
| if (prs && prs.length > 0) { | |
| prNumber = prs[0].number; | |
| } else { | |
| const headSha = context.payload.workflow_run.head_sha; | |
| const { data: prList } = await github.rest.pulls.list({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| state: 'open', | |
| sort: 'updated', | |
| direction: 'desc', | |
| per_page: 100, | |
| }); | |
| const match = prList.find(pr => pr.head.sha === headSha); | |
| if (!match) { | |
| console.log(`No open PR found for SHA ${headSha} — skipping.`); | |
| return; | |
| } | |
| prNumber = match.number; | |
| } | |
| } | |
| // Fetch existing bot comment (if any) and write to file for Python | |
| const marker = '<!-- pr-check-bot -->'; | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| per_page: 100, | |
| }); | |
| const existing = comments.find(c => c.body.includes(marker)); | |
| if (existing) { | |
| fs.mkdirSync('pr-meta', { recursive: true }); | |
| fs.writeFileSync('pr-meta/existing-comment.md', existing.body); | |
| fs.writeFileSync('pr-meta/existing-comment-id.txt', String(existing.id)); | |
| } | |
| core.setOutput('pr_number', prNumber); | |
| - name: Prepare comment | |
| if: steps.context.outputs.pr_number | |
| env: | |
| CHECK_RUN_ID: ${{ github.event.workflow_run.id }} | |
| GITHUB_REPOSITORY: ${{ github.repository }} | |
| run: python lib/checks/prepare-comment.py | |
| - name: Post or update comment | |
| if: steps.context.outputs.pr_number | |
| uses: actions/github-script@v8 | |
| with: | |
| github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} | |
| script: | | |
| const fs = require('fs'); | |
| const actionFile = 'pr-meta/action.txt'; | |
| if (!fs.existsSync(actionFile)) return; | |
| const action = fs.readFileSync(actionFile, 'utf8').trim(); | |
| if (action === 'skip') { | |
| console.log('Nothing to do — skipping.'); | |
| return; | |
| } | |
| const bodyFile = 'pr-meta/final-comment.md'; | |
| if (!fs.existsSync(bodyFile)) { | |
| console.log('No comment body found — skipping.'); | |
| return; | |
| } | |
| const body = fs.readFileSync(bodyFile, 'utf8').trim(); | |
| const prNumber = parseInt('${{ steps.context.outputs.pr_number }}'); | |
| if (action === 'create') { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body, | |
| }); | |
| console.log('Created bot comment.'); | |
| } else if (action === 'update') { | |
| const commentId = parseInt(fs.readFileSync('pr-meta/existing-comment-id.txt', 'utf8').trim()); | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: commentId, | |
| body, | |
| }); | |
| console.log('Updated bot comment.'); | |
| } | |
| // Auto-assign Lissy93 for review when appropriate (non-critical) | |
| try { | |
| const readInt = (f) => fs.existsSync(f) ? parseInt(fs.readFileSync(f, 'utf8').trim()) : NaN; | |
| const errors = readInt('pr-meta/error-count.txt'); | |
| const total = readInt('pr-meta/findings-count.txt'); | |
| const shouldAssign = | |
| (action === 'create' && errors === 0 && total === 0) || | |
| (action === 'update' && errors === 0); | |
| if (shouldAssign) { | |
| await github.rest.issues.addAssignees({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| assignees: ['Lissy93'], | |
| }); | |
| console.log('Assigned Lissy93 for review.'); | |
| } | |
| } catch (err) { | |
| console.log(`Failed to auto-assign: ${err.message}`); | |
| } | |
| review-notify: | |
| name: Notify maintainer | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'pull_request_review' | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Fetch review and CI context | |
| id: context | |
| uses: actions/github-script@v8 | |
| with: | |
| github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} | |
| script: | | |
| const fs = require('fs'); | |
| const pr = context.payload.pull_request; | |
| const { owner, repo } = context.repo; | |
| // Fetch reviews — extract user login and state | |
| const { data: reviews } = await github.rest.pulls.listReviews({ | |
| owner, repo, pull_number: pr.number, per_page: 100, | |
| }); | |
| const reviewData = reviews.map(r => ({ | |
| user: r.user.login, | |
| state: r.state, | |
| })); | |
| // Fetch check runs for the PR head SHA | |
| const { data: { check_runs } } = await github.rest.checks.listForRef({ | |
| owner, repo, ref: pr.head.sha, per_page: 100, | |
| }); | |
| const checkData = check_runs.map(cr => ({ | |
| status: cr.status, | |
| conclusion: cr.conclusion, | |
| })); | |
| // Check if we already posted the notification | |
| const marker = '<!-- pr-review-ready -->'; | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner, repo, issue_number: pr.number, per_page: 100, | |
| }); | |
| const alreadyNotified = comments.some(c => c.body.includes(marker)); | |
| // Write data for Python | |
| fs.mkdirSync('pr-meta', { recursive: true }); | |
| fs.writeFileSync('pr-meta/reviews.json', JSON.stringify(reviewData)); | |
| fs.writeFileSync('pr-meta/check-runs.json', JSON.stringify(checkData)); | |
| fs.writeFileSync('pr-meta/already-notified.txt', String(alreadyNotified)); | |
| core.setOutput('pr_number', pr.number); | |
| - name: Check review readiness | |
| run: python lib/checks/check-review-ready.py | |
| - name: Post notification | |
| uses: actions/github-script@v8 | |
| with: | |
| github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} | |
| script: | | |
| const fs = require('fs'); | |
| const actionFile = 'pr-meta/action.txt'; | |
| if (!fs.existsSync(actionFile)) return; | |
| const action = fs.readFileSync(actionFile, 'utf8').trim(); | |
| if (action !== 'notify') { | |
| console.log('Not ready for review — skipping.'); | |
| return; | |
| } | |
| const body = [ | |
| '<!-- pr-review-ready -->', | |
| 'This PR is now ready to be merged, pending maintainer review. All checks are passing and it has been peer-reviewed.', | |
| '', | |
| '@Lissy93 - Please evaluate, and either merge or leave feedback.', | |
| ].join('\n'); | |
| const prNumber = parseInt('${{ steps.context.outputs.pr_number }}'); | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| body, | |
| }); | |
| // Once all checks pass, assign the PR to myself, so i get notiifcation to review | |
| try { | |
| await github.rest.issues.addAssignees({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: prNumber, | |
| assignees: ['Lissy93'], | |
| }); | |
| } catch (err) { | |
| console.log(`Failed to assign Lissy93: ${err.message}`); | |
| } | |
| # Applies a label matching the "Type" picked in the PR template. Non-critical and | |
| # fully self-contained: it never fails the run, and runs independently of the checks. | |
| label-type: | |
| name: Apply type label | |
| runs-on: ubuntu-latest | |
| if: github.event_name == 'pull_request_target' | |
| permissions: | |
| issues: write | |
| pull-requests: write | |
| concurrency: | |
| group: label-type-${{ github.event.pull_request.number }} | |
| cancel-in-progress: true | |
| steps: | |
| - name: Apply label from PR type | |
| continue-on-error: true | |
| uses: actions/github-script@v8 | |
| with: | |
| github-token: ${{ secrets.BOT_TOKEN || secrets.GITHUB_TOKEN }} | |
| script: | | |
| try { | |
| const { owner, repo } = context.repo; | |
| const pr = context.payload.pull_request; | |
| // Template "Type" token -> label to apply | |
| const TYPES = [ | |
| ['Addition', 'Addition'], | |
| ['Amendment', 'Amendment'], | |
| ['Removal', 'Removal'], | |
| ['Spelling or Grammar', 'Grammar'], | |
| ['Website', 'Website'], | |
| ['Scripts', 'Scripts'], | |
| ['Misc', 'Misc'], | |
| ]; | |
| const managed = [...TYPES.map(t => t[1]), 'Uncategorized']; | |
| // Isolate the "### Type" section, drop HTML comments, lowercase for matching | |
| const body = pr.body || ''; | |
| const m = body.match(/###\s*Type\b([\s\S]*?)(?:\n\s*###|\n\s*---|$)/i); | |
| const section = (m ? m[1] : '').replace(/<!--[\s\S]*?-->/g, '').toLowerCase(); | |
| // A correctly filled template leaves exactly one type; otherwise Uncategorized | |
| const matched = TYPES.filter(t => section.includes(t[0].toLowerCase())).map(t => t[1]); | |
| const desired = matched.length === 1 ? matched[0] : 'Uncategorized'; | |
| const current = (pr.labels || []).map(l => l.name); | |
| for (const name of current.filter(l => managed.includes(l) && l !== desired)) { | |
| await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name }) | |
| .catch(e => console.log(`Skip remove ${name}: ${e.message}`)); | |
| } | |
| if (!current.includes(desired)) { | |
| await github.rest.issues.getLabel({ owner, repo, name: desired }).catch(async e => { | |
| if (e.status === 404) { | |
| await github.rest.issues.createLabel({ owner, repo, name: desired, color: 'ededed' }); | |
| } | |
| }); | |
| await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [desired] }); | |
| } | |
| console.log(`Type label: ${desired} (matched ${matched.length})`); | |
| } catch (err) { | |
| console.log(`Type labeling skipped: ${err.message}`); | |
| } |