diff --git a/.github/workflow-scripts/__tests__/reportFormattingErrors-test.js b/.github/workflow-scripts/__tests__/reportFormattingErrors-test.js new file mode 100644 index 00000000000..4a9bb9024e4 --- /dev/null +++ b/.github/workflow-scripts/__tests__/reportFormattingErrors-test.js @@ -0,0 +1,110 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +'use strict'; + +const { + changedFilesFromPatch, + commentableRightLines, + parsePatch, +} = require('../reportFormattingErrors'); + +describe('reportFormattingErrors', () => { + test('converts formatter hunks into minimal suggestions', () => { + const patch = `diff --git a/example.js b/example.js +--- a/example.js ++++ b/example.js +@@ -10,3 +10,3 @@ + unchanged +-const value={answer:42}; ++const value = {answer: 42}; + unchanged +`; + + expect(parsePatch(patch)).toEqual([ + { + path: 'example.js', + startLine: 11, + endLine: 11, + replacement: 'const value = {answer: 42};', + }, + ]); + }); + + test('tracks lines that can receive right-side review comments', () => { + const lines = commentableRightLines(`@@ -4,2 +4,3 @@ + context +-old ++new ++added +`); + + expect([...lines]).toEqual([4, 5, 6]); + }); + + test.each(['../../../etc/passwd', '/absolute/path', `\0evil`])( + 'rejects unsafe patch path %p', + unsafePath => { + const patch = `diff --git a/file b/file +--- a/file ++++ b/${unsafePath} +@@ -1 +1 @@ +-old ++new +`; + + expect(parsePatch(patch)).toEqual([]); + expect(changedFilesFromPatch(patch)).toEqual([]); + }, + ); + + test('parses multiple files without carrying hunk state across headers', () => { + const patch = `diff --git a/one.js b/one.js +--- a/one.js ++++ b/one.js +@@ -1 +1 @@ +-one ++first +diff --git a/two.js b/two.js +--- a/two.js ++++ b/two.js +@@ -2 +2 @@ +-two ++second +`; + + expect(parsePatch(patch)).toEqual([ + {path: 'one.js', startLine: 1, endLine: 1, replacement: 'first'}, + {path: 'two.js', startLine: 2, endLine: 2, replacement: 'second'}, + ]); + expect(changedFilesFromPatch(patch)).toEqual(['one.js', 'two.js']); + }); + + test('reports files even when a hunk cannot become a suggestion', () => { + const patch = `diff --git a/example.js b/example.js +--- a/example.js ++++ b/example.js +@@ -1 +1 @@ +-old ++\`\`\`unsafe suggestion fence +`; + + expect(parsePatch(patch)).toEqual([]); + expect(changedFilesFromPatch(patch)).toEqual(['example.js']); + }); + + test('ignores non-hunk lines when collecting commentable lines', () => { + const lines = commentableRightLines(`@@ -4 +4 @@ ++new +index 123..456 100644 +`); + + expect([...lines]).toEqual([4]); + }); +}); diff --git a/.github/workflow-scripts/reportFormattingErrors.js b/.github/workflow-scripts/reportFormattingErrors.js new file mode 100644 index 00000000000..6d755a43095 --- /dev/null +++ b/.github/workflow-scripts/reportFormattingErrors.js @@ -0,0 +1,331 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @noflow + * @format + */ + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const MARKER = ''; +const MAX_ARTIFACT_BYTES = 1024 * 1024; +const MAX_COMMENTS = 20; +const MAX_REPLACEMENT_LINES = 100; + +function readBoundedFile(file) { + return fs.readFileSync(file, 'utf8').slice(0, MAX_ARTIFACT_BYTES); +} + +function safePatchPath(candidate) { + return candidate !== '' && + !candidate.includes('\0') && + !candidate.split('/').includes('..') && + !path.posix.isAbsolute(candidate) + ? candidate + : null; +} + +function changedFilesFromPatch(patch) { + return [ + ...new Set( + patch + .split('\n') + .filter(line => line.startsWith('+++ b/')) + .map(line => safePatchPath(line.slice(6))) + .filter(Boolean), + ), + ]; +} + +function parsePatch(patch) { + const changes = []; + let file = null; + let hunk = null; + + function finishHunk() { + const completedHunk = hunk; + hunk = null; + if (file == null || completedHunk == null) { + return; + } + let prefix = 0; + while ( + prefix < completedHunk.oldLines.length && + prefix < completedHunk.newLines.length && + completedHunk.oldLines[prefix] === completedHunk.newLines[prefix] + ) { + prefix++; + } + let suffix = 0; + while ( + suffix < completedHunk.oldLines.length - prefix && + suffix < completedHunk.newLines.length - prefix && + completedHunk.oldLines[completedHunk.oldLines.length - suffix - 1] === + completedHunk.newLines[completedHunk.newLines.length - suffix - 1] + ) { + suffix++; + } + const oldLines = completedHunk.oldLines.slice( + prefix, + completedHunk.oldLines.length - suffix, + ); + const newLines = completedHunk.newLines.slice( + prefix, + completedHunk.newLines.length - suffix, + ); + if ( + oldLines.length > 0 && + oldLines.length <= MAX_REPLACEMENT_LINES && + newLines.length <= MAX_REPLACEMENT_LINES && + !newLines.some(line => line.includes('```')) + ) { + const startLine = completedHunk.oldStart + prefix; + changes.push({ + path: file, + startLine, + endLine: startLine + oldLines.length - 1, + replacement: newLines.join('\n'), + }); + } + } + + for (const line of patch.split('\n')) { + if (line.startsWith('diff --git ')) { + finishHunk(); + file = null; + } else if (line.startsWith('+++ b/')) { + const candidate = line.slice(6); + file = safePatchPath(candidate); + } else if (line.startsWith('@@ ')) { + finishHunk(); + const match = /^@@ -(\d+)(?:,(\d+))? \+\d+(?:,(\d+))? @@/.exec(line); + hunk = + match == null + ? null + : { + oldStart: Number(match[1]), + oldRemaining: Number(match[2] ?? 1), + newRemaining: Number(match[3] ?? 1), + oldLines: [], + newLines: [], + }; + } else if (hunk != null && !line.startsWith('\\ No newline')) { + if (line.startsWith(' ')) { + hunk.oldLines.push(line.slice(1)); + hunk.newLines.push(line.slice(1)); + hunk.oldRemaining--; + hunk.newRemaining--; + } else if (line.startsWith('-')) { + hunk.oldLines.push(line.slice(1)); + hunk.oldRemaining--; + } else if (line.startsWith('+')) { + hunk.newLines.push(line.slice(1)); + hunk.newRemaining--; + } + if (hunk.oldRemaining === 0 && hunk.newRemaining === 0) { + finishHunk(); + } + } + } + finishHunk(); + return changes; +} + +function commentableRightLines(patch) { + const lines = new Set(); + let newLine = 0; + for (const line of (patch ?? '').split('\n')) { + const match = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line); + if (match != null) { + newLine = Number(match[1]); + } else if (line.startsWith('+') || line.startsWith(' ')) { + lines.add(newLine++); + } + } + return lines; +} + +async function deletePreviousComments(github, owner, repo, pullNumber) { + const issueComments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pullNumber, + per_page: 100, + }); + for (const comment of issueComments) { + if ( + comment.user?.login === 'github-actions[bot]' && + comment.body?.includes(MARKER) + ) { + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: comment.id, + }); + } + } + + const reviewComments = await github.paginate( + github.rest.pulls.listReviewComments, + {owner, repo, pull_number: pullNumber, per_page: 100}, + ); + for (const comment of reviewComments) { + if ( + comment.user?.login === 'github-actions[bot]' && + comment.body?.includes(MARKER) + ) { + await github.rest.pulls.deleteReviewComment({ + owner, + repo, + comment_id: comment.id, + }); + } + } +} + +module.exports = async function reportFormattingErrors({ + github, + context, + core, +}) { + const run = context.payload.workflow_run; + const pullRequests = run.pull_requests ?? []; + + const metadata = JSON.parse(readBoundedFile('.format-results/metadata.json')); + const pullNumber = Number(metadata.PR_NUMBER); + const headSha = metadata.HEAD_SHA; + if ( + metadata.EVENT_NAME !== 'pull_request' || + !Number.isSafeInteger(pullNumber) || + !/^[0-9a-f]{40}$/.test(headSha) + ) { + throw new Error('Formatting artifact metadata does not match this run.'); + } + if ( + pullRequests.length > 0 && + !pullRequests.some(pullRequest => pullRequest.number === pullNumber) + ) { + throw new Error( + 'Formatting artifact pull request does not match this run.', + ); + } + + const {owner, repo} = context.repo; + const {data: pullRequest} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if (pullRequest.head.sha !== headSha) { + core.info( + 'Ignoring a stale formatting result for an older pull request revision.', + ); + return; + } + + await deletePreviousComments(github, owner, repo, pullNumber); + if (run.conclusion !== 'failure') { + return; + } + + const patchFile = '.format-results/format.patch'; + const patch = fs.existsSync(patchFile) ? readBoundedFile(patchFile) : ''; + const outputFile = '.format-results/output.txt'; + const output = fs.existsSync(outputFile) ? readBoundedFile(outputFile) : ''; + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: pullNumber, + per_page: 100, + }); + const pullPatches = new Map(files.map(file => [file.filename, file.patch])); + const parsedChanges = parsePatch(patch); + const eligibleSuggestions = parsedChanges.filter(change => { + const commentable = commentableRightLines(pullPatches.get(change.path)); + for (let line = change.startLine; line <= change.endLine; line++) { + if (!commentable.has(line)) { + return false; + } + } + return true; + }); + const suggestions = eligibleSuggestions.slice(0, MAX_COMMENTS); + + let postedSuggestions = 0; + for (const suggestion of suggestions) { + try { + const location = + suggestion.startLine === suggestion.endLine + ? {} + : { + start_line: suggestion.startLine, + start_side: 'RIGHT', + }; + await github.rest.pulls.createReviewComment({ + owner, + repo, + pull_number: pullNumber, + commit_id: headSha, + path: suggestion.path, + ...location, + line: suggestion.endLine, + side: 'RIGHT', + body: `${MARKER}\n\`yarn format\` suggests:\n\n\`\`\`suggestion\n${suggestion.replacement}\n\`\`\``, + }); + postedSuggestions++; + } catch (error) { + core.warning( + `Could not attach a suggestion to ${suggestion.path}: ${error}`, + ); + } + } + + const changedFiles = changedFilesFromPatch(patch).filter(file => + pullPatches.has(file), + ); + const details = + changedFiles.length > 0 + ? changedFiles + .map( + file => + `- \`${file.replaceAll('`', '\\`').replaceAll('\n', ' ')}\``, + ) + .join('\n') + : 'The formatter stopped before producing a patch. See the workflow log.'; + const outputExcerpt = output + .slice(-4000) + .replaceAll('```', '``\\`') + .replaceAll('<', '<'); + const body = `${MARKER} +## Formatting required + +Run \`yarn format\` from the repository root and commit the result. + +${details} + +${postedSuggestions} inline suggestion${postedSuggestions === 1 ? '' : 's'} posted${eligibleSuggestions.length > MAX_COMMENTS ? ` (${eligibleSuggestions.length - MAX_COMMENTS} more omitted)` : ''}. Suggestions can only be attached to lines visible in the pull request diff. + +
Formatter output + +\`\`\`text +${outputExcerpt} +\`\`\` +
`; + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pullNumber, + body, + }); +}; + +module.exports.parsePatch = parsePatch; +module.exports.commentableRightLines = commentableRightLines; +module.exports.changedFilesFromPatch = changedFilesFromPatch; diff --git a/.github/workflows/format-report.yml b/.github/workflows/format-report.yml new file mode 100644 index 00000000000..63c6cdc07c3 --- /dev/null +++ b/.github/workflows/format-report.yml @@ -0,0 +1,38 @@ +name: Format Report + +on: + workflow_run: + workflows: [Format] + types: [completed] + +permissions: + actions: read + contents: read + issues: write + pull-requests: write + +jobs: + report: + runs-on: ubuntu-latest + if: >- + github.repository == 'react/react-native' && + github.event.workflow_run.event == 'pull_request' + steps: + - name: Check out trusted reporter + uses: actions/checkout@v6 + - name: Download formatting report + id: download + continue-on-error: true + uses: actions/download-artifact@v7 + with: + name: format-results + path: .format-results + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - name: Comment on formatting failures + if: steps.download.outcome == 'success' + uses: actions/github-script@v8 + with: + script: | + const reportFormattingErrors = require('./.github/workflow-scripts/reportFormattingErrors'); + await reportFormattingErrors({github, context, core}); diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml new file mode 100644 index 00000000000..d1a3397159f --- /dev/null +++ b/.github/workflows/format.yml @@ -0,0 +1,78 @@ +name: Format + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + - '*-stable' + +permissions: + contents: read + +jobs: + format: + runs-on: macos-15 + if: github.repository == 'react/react-native' + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - name: Setup Node.js + uses: ./.github/actions/setup-node + - name: Initialize report + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + PR_NUMBER: ${{ github.event.pull_request.number || '' }} + run: | + mkdir -p .format-results + node -e "const {EVENT_NAME, HEAD_SHA, PR_NUMBER} = process.env; require('fs').writeFileSync('.format-results/metadata.json', JSON.stringify({EVENT_NAME, HEAD_SHA, PR_NUMBER}))" + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install dependencies + uses: ./.github/actions/yarn-install + - name: Check formatting + id: format + continue-on-error: true + shell: bash + run: | + set +e + { + status=0 + for formatter in javascript cpp kotlin java python swift; do + yarn "format-$formatter" || status=1 + done + exit "$status" + } 2>&1 | tee .format-results/output.txt + format_status=${PIPESTATUS[0]} + set -e + git diff --no-ext-diff --no-color --unified=3 -- . \ + ':(exclude)packages/react-native/package.json' \ + ':(exclude)yarn.lock' > .format-results/format.patch + if [[ $format_status -ne 0 || -s .format-results/format.patch ]]; then + exit 1 + fi + - name: Upload formatting report + if: always() && github.event_name == 'pull_request' + uses: actions/upload-artifact@v6 + with: + name: format-results + path: .format-results/ + retention-days: 1 + - name: Report formatting failure + if: steps.format.outcome == 'failure' + shell: bash + run: | + echo '::error::Formatting is invalid. Run `yarn format` and commit the result.' + exit 1 diff --git a/.github/workflows/test-all.yml b/.github/workflows/test-all.yml index 237f4ad66f7..501a90b59c9 100644 --- a/.github/workflows/test-all.yml +++ b/.github/workflows/test-all.yml @@ -553,9 +553,6 @@ jobs: - name: Run shellcheck shell: bash run: ./.github/workflow-scripts/analyze_scripts.sh - - name: Prettier - shell: bash - run: yarn run format-check - name: markdownlint shell: bash run: yarn run lint-markdown