diff --git a/.github/actions/diff-js-api-changes/action.yml b/.github/actions/diff-js-api-changes/action.yml index 9e186d27cc01..b1055e2ed379 100644 --- a/.github/actions/diff-js-api-changes/action.yml +++ b/.github/actions/diff-js-api-changes/action.yml @@ -1,24 +1,31 @@ name: diff-js-api-changes description: Check for breaking changes in the public React Native JS API +outputs: + message: + description: "Formatted message for PR comment, empty if no API changes" + value: ${{ steps.format.outputs.message }} runs: using: composite steps: - - name: Compute merge base with main + - name: Fetch PR and main, compute merge base id: merge_base shell: bash run: | git fetch origin main - git fetch --deepen=500 - echo "merge_base=$(git merge-base HEAD origin/main)" >> $GITHUB_OUTPUT + git fetch origin ${{ github.event.pull_request.head.sha }} --depth=500 + echo "merge_base=$(git merge-base ${{ github.event.pull_request.head.sha }} origin/main)" >> $GITHUB_OUTPUT - - name: Output snapshot before state for comparison + - name: Extract before and after API snapshots shell: bash env: SCRATCH_DIR: ${{ runner.temp }}/diff-js-api-changes run: | + rm -rf $SCRATCH_DIR mkdir -p $SCRATCH_DIR git show ${{ steps.merge_base.outputs.merge_base }}:packages/react-native/ReactNativeApi.d.ts > $SCRATCH_DIR/ReactNativeApi-before.d.ts \ || echo "" > $SCRATCH_DIR/ReactNativeApi-before.d.ts + git show ${{ github.event.pull_request.head.sha }}:packages/react-native/ReactNativeApi.d.ts > $SCRATCH_DIR/ReactNativeApi-after.d.ts \ + || echo "" > $SCRATCH_DIR/ReactNativeApi-after.d.ts - name: Run breaking change detection shell: bash @@ -27,5 +34,37 @@ runs: run: | node ./scripts/js-api/diff-api-snapshot \ $SCRATCH_DIR/ReactNativeApi-before.d.ts \ - ./packages/react-native/ReactNativeApi.d.ts \ + $SCRATCH_DIR/ReactNativeApi-after.d.ts \ > $SCRATCH_DIR/output.json + + - name: Format output message + id: format + shell: bash + env: + SCRATCH_DIR: ${{ runner.temp }}/diff-js-api-changes + run: | + if [ ! -f "$SCRATCH_DIR/output.json" ]; then + echo "message=" >> $GITHUB_OUTPUT + exit 0 + fi + + RESULT=$(cat $SCRATCH_DIR/output.json | jq -r '.result // empty') + if [ -z "$RESULT" ]; then + echo "message=" >> $GITHUB_OUTPUT + exit 0 + fi + + # Use delimiter for multiline output + { + echo "message< [!WARNING]" + echo "> **JavaScript API change detected**" + echo ">" + echo "> This PR commits an update to \`ReactNativeApi.d.ts\`, indicating a change to React Native's public JavaScript API." + echo ">" + echo "> - Please include a **clear changelog message**." + echo "> - This change will be subject to additional review." + echo ">" + echo "> This change was flagged as: \`${RESULT}\`" + echo "EOF" + } >> $GITHUB_OUTPUT diff --git a/.github/workflow-scripts/__tests__/postPRComment-test.js b/.github/workflow-scripts/__tests__/postPRComment-test.js new file mode 100644 index 000000000000..a62bf478f1fb --- /dev/null +++ b/.github/workflow-scripts/__tests__/postPRComment-test.js @@ -0,0 +1,128 @@ +/** + * 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 + */ + +const postPRComment = require('../postPRComment'); +const {_COMMENT_MARKER} = postPRComment; + +describe('postPRComment', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + const mockGithub = { + rest: { + issues: { + listComments: jest.fn(), + createComment: jest.fn(), + updateComment: jest.fn(), + deleteComment: jest.fn(), + }, + }, + }; + + const mockContext = { + repo: {owner: 'facebook', repo: 'react-native'}, + payload: {pull_request: {number: 123}}, + }; + + beforeEach(() => { + mockGithub.rest.issues.listComments.mockResolvedValue({data: []}); + mockGithub.rest.issues.createComment.mockResolvedValue({}); + mockGithub.rest.issues.updateComment.mockResolvedValue({}); + mockGithub.rest.issues.deleteComment.mockResolvedValue({}); + }); + + it('does nothing when no messages and no existing comment', async () => { + await postPRComment(mockGithub, mockContext, {messages: []}); + + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); + expect(mockGithub.rest.issues.deleteComment).not.toHaveBeenCalled(); + }); + + it('filters out empty and null messages', async () => { + await postPRComment(mockGithub, mockContext, { + messages: ['', null, ' ', undefined], + }); + + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); + }); + + it('deletes existing comment when no messages to report', async () => { + const existingComment = { + id: 456, + body: `${_COMMENT_MARKER}\nOld content`, + }; + mockGithub.rest.issues.listComments.mockResolvedValue({ + data: [existingComment], + }); + + await postPRComment(mockGithub, mockContext, {messages: []}); + + expect(mockGithub.rest.issues.deleteComment).toHaveBeenCalledWith({ + owner: 'facebook', + repo: 'react-native', + comment_id: 456, + }); + }); + + it('creates new comment when there are messages', async () => { + await postPRComment(mockGithub, mockContext, { + messages: ['### Test Message\n\nSome content'], + }); + + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ + owner: 'facebook', + repo: 'react-native', + issue_number: 123, + body: expect.stringContaining(_COMMENT_MARKER), + }); + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ + owner: 'facebook', + repo: 'react-native', + issue_number: 123, + body: expect.stringContaining('Test Message'), + }); + }); + + it('updates existing comment when there are messages', async () => { + const existingComment = { + id: 789, + body: `${_COMMENT_MARKER}\nOld content`, + }; + mockGithub.rest.issues.listComments.mockResolvedValue({ + data: [existingComment], + }); + + await postPRComment(mockGithub, mockContext, { + messages: ['### Updated Message'], + }); + + expect(mockGithub.rest.issues.updateComment).toHaveBeenCalledWith({ + owner: 'facebook', + repo: 'react-native', + comment_id: 789, + body: expect.stringContaining(_COMMENT_MARKER), + }); + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); + }); + + it('combines multiple messages with double newlines', async () => { + await postPRComment(mockGithub, mockContext, { + messages: ['Message 1', 'Message 2'], + }); + + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ + owner: 'facebook', + repo: 'react-native', + issue_number: 123, + body: expect.stringContaining('Message 1\n\nMessage 2'), + }); + }); +}); diff --git a/.github/workflow-scripts/postPRComment.js b/.github/workflow-scripts/postPRComment.js new file mode 100644 index 000000000000..ec49f6bead72 --- /dev/null +++ b/.github/workflow-scripts/postPRComment.js @@ -0,0 +1,77 @@ +/** + * 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 + */ + +const COMMENT_MARKER = ''; + +/** + * Posts a PR validation comment, updates an existing one, or deletes it if there's nothing to report. + * + * @param {Object} github - The octokit client from actions/github-script + * @param {Object} context - The GitHub Actions context + * @param {Object} options - Options for the comment + * @param {string[]} [options.messages] - Array of message strings to include in the comment + */ +async function postPRComment(github, context, options) { + const {owner, repo} = context.repo; + const prNumber = context.payload.pull_request.number; + + const sections = (options.messages || []).filter( + msg => msg != null && msg.trim() !== '', + ); + + const {data: comments} = await github.rest.issues.listComments({ + owner, + repo, + issue_number: prNumber, + }); + + const existingComment = comments.find(comment => + comment.body.includes(COMMENT_MARKER), + ); + + if (sections.length === 0) { + console.log('No issues to report'); + if (existingComment) { + console.log(`Deleting existing comment ${existingComment.id}`); + await github.rest.issues.deleteComment({ + owner, + repo, + comment_id: existingComment.id, + }); + } + return; + } + + const commentBody = `${COMMENT_MARKER} +## PR Validation + +${sections.join('\n\n')}`; + + if (existingComment) { + console.log(`Updating existing comment ${existingComment.id}`); + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existingComment.id, + body: commentBody, + }); + } else { + console.log('Creating new comment'); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: commentBody, + }); + } +} + +module.exports = postPRComment; +// Exported for testing purposes +module.exports._COMMENT_MARKER = COMMENT_MARKER; diff --git a/.github/workflows/annotate-pr.yml b/.github/workflows/annotate-pr.yml new file mode 100644 index 000000000000..d1743c897182 --- /dev/null +++ b/.github/workflows/annotate-pr.yml @@ -0,0 +1,41 @@ +name: Annotate PR + +on: + pull_request_target: + types: [opened, edited, reopened, synchronize] + +permissions: + pull-requests: write + +jobs: + annotate-pr: + runs-on: ubuntu-latest + # if: github.repository == 'facebook/react-native' temp + steps: + - name: Check out main branch + uses: actions/checkout@v6 + - name: Setup Node.js + uses: ./.github/actions/setup-node + - name: Run yarn install + uses: ./.github/actions/yarn-install + - name: Run diff-js-api-changes + id: diff-js-api + uses: ./.github/actions/diff-js-api-changes + # - name: Check PR body + # todo + # uses: ./.github/actions/check-pr-body + # - name: Branch Targetting + # todo + # uses: ./.github/actions/check-branch-target + - name: Post PR comment + uses: actions/github-script@v8 + env: + API_CHANGES_MESSAGE: ${{ steps.diff-js-api.outputs.message }} + with: + script: | + const postPRComment = require('./.github/workflow-scripts/postPRComment.js'); + await postPRComment(github, context, { + messages: [ + process.env.API_CHANGES_MESSAGE, + ], + }); diff --git a/.github/workflows/danger-pr.yml b/.github/workflows/danger-pr.yml index ce41f6f602cd..f56daaffa826 100644 --- a/.github/workflows/danger-pr.yml +++ b/.github/workflows/danger-pr.yml @@ -15,7 +15,7 @@ permissions: jobs: danger: runs-on: ubuntu-latest - if: github.repository == 'facebook/react-native' + # if: github.repository == 'facebook/react-native' steps: - name: Check out PR branch uses: actions/checkout@v6 @@ -25,8 +25,11 @@ jobs: uses: ./.github/actions/yarn-install - name: Run diff-js-api-changes uses: ./.github/actions/diff-js-api-changes - - name: Danger - run: yarn danger ci --use-github-checks --failOnErrors - working-directory: private/react-native-bots - env: - DANGER_GITHUB_API_TOKEN: ${{ secrets.REACT_NATIVE_BOT_GITHUB_TOKEN }} + - name: Debug - Show API diff output + run: cat ${{ runner.temp }}/diff-js-api-changes/output.json + # Danger step removed for fork testing + # - name: Danger + # run: yarn danger ci --use-github-checks --failOnErrors + # working-directory: private/react-native-bots + # env: + # DANGER_GITHUB_API_TOKEN: ${{ secrets.REACT_NATIVE_BOT_GITHUB_TOKEN }}