Harden respond-to-comment.yml against fork PRs #15
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: Auto PR Review | |
| on: | |
| pull_request: | |
| types: [opened, ready_for_review] | |
| jobs: | |
| review_pr: | |
| if: github.event.pull_request.head.repo.full_name == github.repository | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| issues: write | |
| steps: | |
| - name: Checkout Repository | |
| uses: actions/checkout@v4 | |
| - name: Review PR with Oz agent | |
| uses: warpdotdev/oz-agent-action@ce1621abf6a8ed8afdd4e4cc994545ede8fe1c6f # v1.0.12 | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| with: | |
| warp_api_key: ${{ secrets.WARP_API_KEY }} | |
| skill: review-docs-pr | |
| - name: Post Review | |
| uses: actions/github-script@v7 | |
| if: always() | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const { owner, repo } = context.repo; | |
| const prNumber = context.payload.pull_request.number; | |
| const commitSha = context.payload.pull_request.head.sha; | |
| try { | |
| if (!fs.existsSync('review.json')) { | |
| console.log('No review.json found. Skipping review posting.'); | |
| return; | |
| } | |
| const reviewContent = fs.readFileSync('review.json', 'utf8'); | |
| let review; | |
| try { | |
| review = JSON.parse(reviewContent); | |
| } catch (parseError) { | |
| core.warning(`Failed to parse review.json: ${parseError.message}`); | |
| const sanitized = reviewContent.replace(/[\u0000-\u001F]+/g, ' '); | |
| try { | |
| review = JSON.parse(sanitized); | |
| } catch (sanitizedError) { | |
| core.setFailed(`Failed to parse review.json even after sanitizing: ${sanitizedError.message}`); | |
| return; | |
| } | |
| } | |
| const decodeNewlines = (text) => { | |
| if (typeof text !== 'string') return text; | |
| return text.replace(/\r\n/g, '\n').replace(/\\n/g, '\n'); | |
| }; | |
| const rawComments = Array.isArray(review.comments) ? review.comments : []; | |
| // Fetch valid file paths from the PR | |
| const prFiles = await github.paginate( | |
| github.rest.pulls.listFiles, | |
| { owner, repo, pull_number: prNumber } | |
| ); | |
| const validPaths = new Set(prFiles.map(f => f.filename)); | |
| // Build a map of valid diff line numbers per file and side. | |
| // GitHub's createReview API only accepts line numbers that appear | |
| // in the PR diff; comments targeting other lines cause a 422. | |
| const validLines = new Map(); // key: "path:side" -> Set of line numbers | |
| for (const file of prFiles) { | |
| if (!file.patch) continue; | |
| const rightLines = new Set(); | |
| const leftLines = new Set(); | |
| let oldLine = 0; | |
| let newLine = 0; | |
| for (const raw of file.patch.split('\n')) { | |
| const hunkHeader = raw.match(/^@@ -(?:\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); | |
| if (hunkHeader) { | |
| const parts = raw.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); | |
| oldLine = parseInt(parts[1], 10); | |
| newLine = parseInt(parts[2], 10); | |
| continue; | |
| } | |
| if (raw.startsWith('+')) { | |
| rightLines.add(newLine); | |
| newLine++; | |
| } else if (raw.startsWith('-')) { | |
| leftLines.add(oldLine); | |
| oldLine++; | |
| } else { | |
| // Context line — valid on both sides | |
| rightLines.add(newLine); | |
| leftLines.add(oldLine); | |
| newLine++; | |
| oldLine++; | |
| } | |
| } | |
| validLines.set(`${file.filename}:RIGHT`, rightLines); | |
| validLines.set(`${file.filename}:LEFT`, leftLines); | |
| } | |
| const comments = []; | |
| const displaced = []; // comments whose lines aren't in the diff | |
| for (const c of rawComments) { | |
| if (!c || typeof c !== 'object') continue; | |
| if (typeof c.body !== 'string' || !c.body.trim()) continue; | |
| if (typeof c.path !== 'string' || !c.path.trim()) continue; | |
| // Normalize path | |
| const normalizedPath = c.path.trim() | |
| .replace(/^([ab]\/)*/, '') | |
| .replace(/^\.\//, ''); | |
| if (!validPaths.has(normalizedPath)) { | |
| console.log(`Skipping comment with invalid path: ${c.path} -> ${normalizedPath}`); | |
| continue; | |
| } | |
| const line = Number(c.line); | |
| if (!Number.isInteger(line) || line <= 0) { | |
| console.log('Skipping comment with invalid line:', c); | |
| continue; | |
| } | |
| let side = (c.side || 'RIGHT').toString().toUpperCase(); | |
| if (side !== 'LEFT' && side !== 'RIGHT') { | |
| console.log(`Invalid side '${c.side}', defaulting to RIGHT`); | |
| side = 'RIGHT'; | |
| } | |
| const body = decodeNewlines(c.body); | |
| // Validate that this line number exists in the diff for this file/side | |
| const key = `${normalizedPath}:${side}`; | |
| const lineSet = validLines.get(key); | |
| if (!lineSet || !lineSet.has(line)) { | |
| console.log(`Comment targets line ${line} (${side}) in ${normalizedPath} which is outside the diff — moving to summary.`); | |
| displaced.push({ path: normalizedPath, line, side, body }); | |
| continue; | |
| } | |
| comments.push({ path: normalizedPath, line, side, body }); | |
| } | |
| let summary = typeof review.summary === 'string' ? decodeNewlines(review.summary).trim() : ''; | |
| // Append displaced comments to the summary so feedback isn't lost | |
| if (displaced.length > 0) { | |
| const extra = displaced.map(d => | |
| `**${d.path}** (line ${d.line}):\n${d.body}` | |
| ).join('\n\n---\n\n'); | |
| const header = '\n\n---\n\n**Additional comments** (targeting lines outside the diff):\n\n'; | |
| summary = summary ? summary + header + extra : header.trimStart() + extra; | |
| } | |
| const hasSummary = summary.length > 0; | |
| if (!hasSummary && comments.length === 0) { | |
| console.log('No valid summary or inline comments found. Skipping review posting.'); | |
| return; | |
| } | |
| const payload = { | |
| owner, | |
| repo, | |
| pull_number: prNumber, | |
| commit_id: commitSha, | |
| event: 'COMMENT', | |
| }; | |
| if (hasSummary) { | |
| payload.body = summary; | |
| } else { | |
| payload.body = 'Automated review by Oz Agent'; | |
| } | |
| if (comments.length > 0) { | |
| payload.comments = comments; | |
| } | |
| await github.rest.pulls.createReview(payload); | |
| console.log('Review posted successfully.'); | |
| } catch (error) { | |
| console.error('Failed to post review:', error); | |
| core.setFailed(`Failed to post review: ${error.message}`); | |
| } |