From 56f622705ac93916ac6db9676384205e68e280a7 Mon Sep 17 00:00:00 2001 From: Emily Brown Date: Mon, 19 Jan 2026 12:18:07 +0000 Subject: [PATCH 01/11] Fork adjustments to run danger-pr DO NOT MERGE --- .github/workflows/danger-pr.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 }} From e84c550f7257cb535bf2f003e19a211bf810254e Mon Sep 17 00:00:00 2001 From: Emily Brown Date: Mon, 19 Jan 2026 13:28:28 +0000 Subject: [PATCH 02/11] diff-js-api-changes testing --- .github/actions/diff-js-api-changes/action.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/actions/diff-js-api-changes/action.yml b/.github/actions/diff-js-api-changes/action.yml index 9e186d27cc01..6e99b1bcb7b5 100644 --- a/.github/actions/diff-js-api-changes/action.yml +++ b/.github/actions/diff-js-api-changes/action.yml @@ -3,22 +3,25 @@ description: Check for breaking changes in the public React Native JS API 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 +30,5 @@ 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 From 778378c51a202985896b959caa297293f2ff3027 Mon Sep 17 00:00:00 2001 From: Emily Brown Date: Mon, 19 Jan 2026 13:46:56 +0000 Subject: [PATCH 03/11] Revert "diff-js-api-changes testing" This reverts commit e84c550f7257cb535bf2f003e19a211bf810254e. --- .github/actions/diff-js-api-changes/action.yml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/actions/diff-js-api-changes/action.yml b/.github/actions/diff-js-api-changes/action.yml index 6e99b1bcb7b5..9e186d27cc01 100644 --- a/.github/actions/diff-js-api-changes/action.yml +++ b/.github/actions/diff-js-api-changes/action.yml @@ -3,25 +3,22 @@ description: Check for breaking changes in the public React Native JS API runs: using: composite steps: - - name: Fetch PR and main, compute merge base + - name: Compute merge base with main id: merge_base shell: bash run: | git fetch origin main - 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 + git fetch --deepen=500 + echo "merge_base=$(git merge-base HEAD origin/main)" >> $GITHUB_OUTPUT - - name: Extract before and after API snapshots + - name: Output snapshot before state for comparison 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 @@ -30,5 +27,5 @@ runs: run: | node ./scripts/js-api/diff-api-snapshot \ $SCRATCH_DIR/ReactNativeApi-before.d.ts \ - $SCRATCH_DIR/ReactNativeApi-after.d.ts \ + ./packages/react-native/ReactNativeApi.d.ts \ > $SCRATCH_DIR/output.json From 9cae97f1aeabd59741186b5b352fc7f74b3c0aff Mon Sep 17 00:00:00 2001 From: Emily Brown Date: Tue, 20 Jan 2026 10:44:58 +0000 Subject: [PATCH 04/11] initial annotate pr comment commit --- .github/workflows/annotate-pr.yml | 55 +++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 .github/workflows/annotate-pr.yml diff --git a/.github/workflows/annotate-pr.yml b/.github/workflows/annotate-pr.yml new file mode 100644 index 000000000000..681e6144af1f --- /dev/null +++ b/.github/workflows/annotate-pr.yml @@ -0,0 +1,55 @@ +name: Annotate PR + +on: + pull_request_target: + types: [opened, edited, reopened, synchronize] + +permissions: + pull-requests: write + +jobs: + annotate-pr: + runs-on: ubuntu-latest + # TODO uncomment + # if: github.repository == 'facebook/react-native' + 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 + 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 + env: + GH_TOKEN: ${{ github.token }} + COMMENT_BODY: | + ## PR Validation + *This is a test message - checks will be added incrementally.* + run: | + MARKER="" + PR_NUMBER="${{ github.event.pull_request.number }}" + REPO="${{ github.repository }}" + + # Find existing comment + COMMENT_ID=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ + --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" | head -1) + + FULL_BODY="${MARKER} + ${COMMENT_BODY}" + + if [ -n "$COMMENT_ID" ]; then + echo "Updating existing comment ${COMMENT_ID}" + gh api "repos/${REPO}/issues/comments/${COMMENT_ID}" \ + -X PATCH -f body="$FULL_BODY" + else + echo "Creating new comment" + gh pr comment "$PR_NUMBER" --body "$FULL_BODY" + fi From 0017609a1b67d9bcfc0e7251560e1eb6c9f8b6d1 Mon Sep 17 00:00:00 2001 From: Emily Brown Date: Tue, 20 Jan 2026 10:53:44 +0000 Subject: [PATCH 05/11] commented out invalid steps in new workflow --- .github/workflows/annotate-pr.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/annotate-pr.yml b/.github/workflows/annotate-pr.yml index 681e6144af1f..133d80e1af68 100644 --- a/.github/workflows/annotate-pr.yml +++ b/.github/workflows/annotate-pr.yml @@ -21,12 +21,12 @@ jobs: uses: ./.github/actions/yarn-install - name: Run diff-js-api-changes 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: Check PR body + # # todo + # # uses: ./.github/actions/check-pr-body + # - name: Branch Targetting + # # todo + # # uses: ./.github/actions/check-branch-target - name: Post PR comment env: GH_TOKEN: ${{ github.token }} From 2f9604ddd5de45e50dde4a258143f26702456e5d Mon Sep 17 00:00:00 2001 From: Emily Brown Date: Tue, 20 Jan 2026 11:24:22 +0000 Subject: [PATCH 06/11] updated comment script --- .github/workflow-scripts/postPRComment.js | 110 ++++++++++++++++++++++ .github/workflows/annotate-pr.yml | 41 +++----- 2 files changed, 122 insertions(+), 29 deletions(-) create mode 100644 .github/workflow-scripts/postPRComment.js diff --git a/.github/workflow-scripts/postPRComment.js b/.github/workflow-scripts/postPRComment.js new file mode 100644 index 000000000000..df602d4302d6 --- /dev/null +++ b/.github/workflow-scripts/postPRComment.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 + */ + +const fs = require('fs'); +const path = require('path'); + +const COMMENT_MARKER = ''; + +/** + * Reads the API changes output file if it exists. + * @param {string} scratchDir - Path to the scratch directory + * @returns {string|null} - The formatted API changes message, or null if no changes + */ +function getApiChangesMessage(scratchDir) { + const outputPath = path.join(scratchDir, 'output.json'); + if (!fs.existsSync(outputPath)) { + return null; + } + + const content = fs.readFileSync(outputPath, 'utf8').trim(); + if (!content) { + return null; + } + + try { + const data = JSON.parse(content); + if (!data.breakingChanges || data.breakingChanges.length === 0) { + return null; + } + return `### API Changes Detected\n\n${JSON.stringify(data, null, 2)}`; + } catch { + return content || null; + } +} + +/** + * 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.scratchDir] - Path to the scratch directory containing check outputs + */ +async function postPRComment(github, context, options) { + const {owner, repo} = context.repo; + const prNumber = context.payload.pull_request.number; + + const sections = []; + + if (options.scratchDir) { + const apiChanges = getApiChangesMessage(options.scratchDir); + if (apiChanges) { + sections.push(apiChanges); + } + } + + 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; diff --git a/.github/workflows/annotate-pr.yml b/.github/workflows/annotate-pr.yml index 133d80e1af68..3455fcc8ab59 100644 --- a/.github/workflows/annotate-pr.yml +++ b/.github/workflows/annotate-pr.yml @@ -10,7 +10,6 @@ permissions: jobs: annotate-pr: runs-on: ubuntu-latest - # TODO uncomment # if: github.repository == 'facebook/react-native' steps: - name: Check out main branch @@ -22,34 +21,18 @@ jobs: - name: Run diff-js-api-changes uses: ./.github/actions/diff-js-api-changes # - name: Check PR body - # # todo - # # uses: ./.github/actions/check-pr-body + # todo + # uses: ./.github/actions/check-pr-body # - name: Branch Targetting - # # todo - # # uses: ./.github/actions/check-branch-target + # todo + # uses: ./.github/actions/check-branch-target - name: Post PR comment + uses: actions/github-script@v8 env: - GH_TOKEN: ${{ github.token }} - COMMENT_BODY: | - ## PR Validation - *This is a test message - checks will be added incrementally.* - run: | - MARKER="" - PR_NUMBER="${{ github.event.pull_request.number }}" - REPO="${{ github.repository }}" - - # Find existing comment - COMMENT_ID=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ - --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" | head -1) - - FULL_BODY="${MARKER} - ${COMMENT_BODY}" - - if [ -n "$COMMENT_ID" ]; then - echo "Updating existing comment ${COMMENT_ID}" - gh api "repos/${REPO}/issues/comments/${COMMENT_ID}" \ - -X PATCH -f body="$FULL_BODY" - else - echo "Creating new comment" - gh pr comment "$PR_NUMBER" --body "$FULL_BODY" - fi + SCRATCH_DIR: ${{ runner.temp }}/diff-js-api-changes + with: + script: | + const postPRComment = require('./.github/workflow-scripts/postPRComment.js'); + await postPRComment(github, context, { + scratchDir: process.env.SCRATCH_DIR, + }); From c20770807c7a5e7958f3f8316125cb8a91e7a8de Mon Sep 17 00:00:00 2001 From: Emily Brown Date: Tue, 20 Jan 2026 11:33:54 +0000 Subject: [PATCH 07/11] diff js api changes fix so it compares pr to pr base not main to main --- .github/actions/diff-js-api-changes/action.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/actions/diff-js-api-changes/action.yml b/.github/actions/diff-js-api-changes/action.yml index 9e186d27cc01..6e99b1bcb7b5 100644 --- a/.github/actions/diff-js-api-changes/action.yml +++ b/.github/actions/diff-js-api-changes/action.yml @@ -3,22 +3,25 @@ description: Check for breaking changes in the public React Native JS API 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 +30,5 @@ 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 From 61857bba2368b4028a2a24edfabfe03d9aee9fc2 Mon Sep 17 00:00:00 2001 From: Emily Brown Date: Tue, 20 Jan 2026 12:06:56 +0000 Subject: [PATCH 08/11] tests for pr commenting --- .../__tests__/postPRComment-test.js | 180 ++++++++++++++++++ .github/workflow-scripts/postPRComment.js | 5 +- 2 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 .github/workflow-scripts/__tests__/postPRComment-test.js diff --git a/.github/workflow-scripts/__tests__/postPRComment-test.js b/.github/workflow-scripts/__tests__/postPRComment-test.js new file mode 100644 index 000000000000..c5ccfcd93d27 --- /dev/null +++ b/.github/workflow-scripts/__tests__/postPRComment-test.js @@ -0,0 +1,180 @@ +/** + * 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 fs = require('fs'); +const path = require('path'); +const postPRComment = require('../postPRComment'); +const {_getApiChangesMessage, _COMMENT_MARKER} = postPRComment; + +jest.mock('fs'); + +describe('postPRComment', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + describe('_getApiChangesMessage', () => { + it('returns null if output.json does not exist', () => { + fs.existsSync.mockReturnValue(false); + + const result = _getApiChangesMessage('/tmp/scratch'); + + expect(result).toBeNull(); + expect(fs.existsSync).toHaveBeenCalledWith('/tmp/scratch/output.json'); + }); + + it('returns null if output.json is empty', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(' '); + + const result = _getApiChangesMessage('/tmp/scratch'); + + expect(result).toBeNull(); + }); + + it('returns null if changedApis is empty array', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue( + JSON.stringify({result: 'NO_CHANGES', changedApis: []}), + ); + + const result = _getApiChangesMessage('/tmp/scratch'); + + expect(result).toBeNull(); + }); + + it('returns null if changedApis is missing', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(JSON.stringify({otherField: 'value'})); + + const result = _getApiChangesMessage('/tmp/scratch'); + + expect(result).toBeNull(); + }); + + it('returns formatted message when changedApis has items', () => { + const data = { + result: 'POTENTIALLY_NON_BREAKING', + changedApis: ['TestType'], + }; + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(JSON.stringify(data)); + + const result = _getApiChangesMessage('/tmp/scratch'); + + expect(result).toContain('### API Changes Detected'); + expect(result).toContain('TestType'); + }); + + it('returns raw content if JSON parsing fails', () => { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue('not valid json'); + + const result = _getApiChangesMessage('/tmp/scratch'); + + expect(result).toBe('not valid json'); + }); + }); + + describe('postPRComment', () => { + 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 scratchDir and no existing comment', async () => { + await postPRComment(mockGithub, mockContext, {}); + + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); + expect(mockGithub.rest.issues.deleteComment).not.toHaveBeenCalled(); + }); + + it('deletes existing comment when nothing to report', async () => { + const existingComment = { + id: 456, + body: `${_COMMENT_MARKER}\nOld content`, + }; + mockGithub.rest.issues.listComments.mockResolvedValue({ + data: [existingComment], + }); + fs.existsSync.mockReturnValue(false); + + await postPRComment(mockGithub, mockContext, { + scratchDir: '/tmp/scratch', + }); + + expect(mockGithub.rest.issues.deleteComment).toHaveBeenCalledWith({ + owner: 'facebook', + repo: 'react-native', + comment_id: 456, + }); + }); + + it('creates new comment when there are issues to report', async () => { + const data = {result: 'POTENTIALLY_NON_BREAKING', changedApis: ['Test']}; + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(JSON.stringify(data)); + + await postPRComment(mockGithub, mockContext, { + scratchDir: '/tmp/scratch', + }); + + expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ + owner: 'facebook', + repo: 'react-native', + issue_number: 123, + body: expect.stringContaining(_COMMENT_MARKER), + }); + }); + + it('updates existing comment when there are issues to report', async () => { + const existingComment = { + id: 789, + body: `${_COMMENT_MARKER}\nOld content`, + }; + mockGithub.rest.issues.listComments.mockResolvedValue({ + data: [existingComment], + }); + const data = {result: 'POTENTIALLY_NON_BREAKING', changedApis: ['Test']}; + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(JSON.stringify(data)); + + await postPRComment(mockGithub, mockContext, { + scratchDir: '/tmp/scratch', + }); + + 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(); + }); + }); +}); diff --git a/.github/workflow-scripts/postPRComment.js b/.github/workflow-scripts/postPRComment.js index df602d4302d6..cc69be9d70cd 100644 --- a/.github/workflow-scripts/postPRComment.js +++ b/.github/workflow-scripts/postPRComment.js @@ -30,7 +30,7 @@ function getApiChangesMessage(scratchDir) { try { const data = JSON.parse(content); - if (!data.breakingChanges || data.breakingChanges.length === 0) { + if (!data.changedApis || data.changedApis.length === 0) { return null; } return `### API Changes Detected\n\n${JSON.stringify(data, null, 2)}`; @@ -108,3 +108,6 @@ ${sections.join('\n\n')}`; } module.exports = postPRComment; +// Exported for testing purposes +module.exports._getApiChangesMessage = getApiChangesMessage; +module.exports._COMMENT_MARKER = COMMENT_MARKER; From 63e1e495a3897a611295e792f2fdd8f98a994e82 Mon Sep 17 00:00:00 2001 From: Emily Brown Date: Tue, 20 Jan 2026 15:36:53 +0000 Subject: [PATCH 09/11] changed annotations for change js api --- .../actions/diff-js-api-changes/action.yml | 35 +++ .../__tests__/postPRComment-test.js | 214 +++++++----------- .github/workflow-scripts/postPRComment.js | 44 +--- .github/workflows/annotate-pr.yml | 7 +- 4 files changed, 125 insertions(+), 175 deletions(-) diff --git a/.github/actions/diff-js-api-changes/action.yml b/.github/actions/diff-js-api-changes/action.yml index 6e99b1bcb7b5..f2d6c99ae415 100644 --- a/.github/actions/diff-js-api-changes/action.yml +++ b/.github/actions/diff-js-api-changes/action.yml @@ -1,5 +1,9 @@ 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: @@ -32,3 +36,34 @@ runs: $SCRATCH_DIR/ReactNativeApi-before.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 + + MESSAGE="### JavaScript API change detected + + This PR commits an update to \`ReactNativeApi.d.ts\`, indicating a change to React Native's public JavaScript API. + + - Please include a **clear changelog message**. + - This change will be subject to additional review. + + This change was flagged as: **\`${RESULT}\`**" + + # Use delimiter for multiline output + echo "message<> $GITHUB_OUTPUT + echo "$MESSAGE" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT diff --git a/.github/workflow-scripts/__tests__/postPRComment-test.js b/.github/workflow-scripts/__tests__/postPRComment-test.js index c5ccfcd93d27..a62bf478f1fb 100644 --- a/.github/workflow-scripts/__tests__/postPRComment-test.js +++ b/.github/workflow-scripts/__tests__/postPRComment-test.js @@ -7,12 +7,8 @@ * @format */ -const fs = require('fs'); -const path = require('path'); const postPRComment = require('../postPRComment'); -const {_getApiChangesMessage, _COMMENT_MARKER} = postPRComment; - -jest.mock('fs'); +const {_COMMENT_MARKER} = postPRComment; describe('postPRComment', () => { beforeEach(() => { @@ -20,161 +16,113 @@ describe('postPRComment', () => { jest.spyOn(console, 'log').mockImplementation(() => {}); }); - describe('_getApiChangesMessage', () => { - it('returns null if output.json does not exist', () => { - fs.existsSync.mockReturnValue(false); + const mockGithub = { + rest: { + issues: { + listComments: jest.fn(), + createComment: jest.fn(), + updateComment: jest.fn(), + deleteComment: jest.fn(), + }, + }, + }; - const result = _getApiChangesMessage('/tmp/scratch'); + const mockContext = { + repo: {owner: 'facebook', repo: 'react-native'}, + payload: {pull_request: {number: 123}}, + }; - expect(result).toBeNull(); - expect(fs.existsSync).toHaveBeenCalledWith('/tmp/scratch/output.json'); - }); + beforeEach(() => { + mockGithub.rest.issues.listComments.mockResolvedValue({data: []}); + mockGithub.rest.issues.createComment.mockResolvedValue({}); + mockGithub.rest.issues.updateComment.mockResolvedValue({}); + mockGithub.rest.issues.deleteComment.mockResolvedValue({}); + }); - it('returns null if output.json is empty', () => { - fs.existsSync.mockReturnValue(true); - fs.readFileSync.mockReturnValue(' '); + it('does nothing when no messages and no existing comment', async () => { + await postPRComment(mockGithub, mockContext, {messages: []}); - const result = _getApiChangesMessage('/tmp/scratch'); + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); + expect(mockGithub.rest.issues.deleteComment).not.toHaveBeenCalled(); + }); - expect(result).toBeNull(); + it('filters out empty and null messages', async () => { + await postPRComment(mockGithub, mockContext, { + messages: ['', null, ' ', undefined], }); - it('returns null if changedApis is empty array', () => { - fs.existsSync.mockReturnValue(true); - fs.readFileSync.mockReturnValue( - JSON.stringify({result: 'NO_CHANGES', changedApis: []}), - ); - - const result = _getApiChangesMessage('/tmp/scratch'); + expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); + }); - expect(result).toBeNull(); + 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], }); - it('returns null if changedApis is missing', () => { - fs.existsSync.mockReturnValue(true); - fs.readFileSync.mockReturnValue(JSON.stringify({otherField: 'value'})); + await postPRComment(mockGithub, mockContext, {messages: []}); - const result = _getApiChangesMessage('/tmp/scratch'); - - expect(result).toBeNull(); + expect(mockGithub.rest.issues.deleteComment).toHaveBeenCalledWith({ + owner: 'facebook', + repo: 'react-native', + comment_id: 456, }); + }); - it('returns formatted message when changedApis has items', () => { - const data = { - result: 'POTENTIALLY_NON_BREAKING', - changedApis: ['TestType'], - }; - fs.existsSync.mockReturnValue(true); - fs.readFileSync.mockReturnValue(JSON.stringify(data)); - - const result = _getApiChangesMessage('/tmp/scratch'); - - expect(result).toContain('### API Changes Detected'); - expect(result).toContain('TestType'); + it('creates new comment when there are messages', async () => { + await postPRComment(mockGithub, mockContext, { + messages: ['### Test Message\n\nSome content'], }); - it('returns raw content if JSON parsing fails', () => { - fs.existsSync.mockReturnValue(true); - fs.readFileSync.mockReturnValue('not valid json'); - - const result = _getApiChangesMessage('/tmp/scratch'); - - expect(result).toBe('not valid json'); + 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'), }); }); - describe('postPRComment', () => { - const mockGithub = { - rest: { - issues: { - listComments: jest.fn(), - createComment: jest.fn(), - updateComment: jest.fn(), - deleteComment: jest.fn(), - }, - }, + it('updates existing comment when there are messages', async () => { + const existingComment = { + id: 789, + body: `${_COMMENT_MARKER}\nOld content`, }; - - 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({}); + mockGithub.rest.issues.listComments.mockResolvedValue({ + data: [existingComment], }); - it('does nothing when no scratchDir and no existing comment', async () => { - await postPRComment(mockGithub, mockContext, {}); - - expect(mockGithub.rest.issues.createComment).not.toHaveBeenCalled(); - expect(mockGithub.rest.issues.deleteComment).not.toHaveBeenCalled(); + await postPRComment(mockGithub, mockContext, { + messages: ['### Updated Message'], }); - it('deletes existing comment when nothing to report', async () => { - const existingComment = { - id: 456, - body: `${_COMMENT_MARKER}\nOld content`, - }; - mockGithub.rest.issues.listComments.mockResolvedValue({ - data: [existingComment], - }); - fs.existsSync.mockReturnValue(false); - - await postPRComment(mockGithub, mockContext, { - scratchDir: '/tmp/scratch', - }); - - expect(mockGithub.rest.issues.deleteComment).toHaveBeenCalledWith({ - owner: 'facebook', - repo: 'react-native', - comment_id: 456, - }); + 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('creates new comment when there are issues to report', async () => { - const data = {result: 'POTENTIALLY_NON_BREAKING', changedApis: ['Test']}; - fs.existsSync.mockReturnValue(true); - fs.readFileSync.mockReturnValue(JSON.stringify(data)); - - await postPRComment(mockGithub, mockContext, { - scratchDir: '/tmp/scratch', - }); - - expect(mockGithub.rest.issues.createComment).toHaveBeenCalledWith({ - owner: 'facebook', - repo: 'react-native', - issue_number: 123, - body: expect.stringContaining(_COMMENT_MARKER), - }); + it('combines multiple messages with double newlines', async () => { + await postPRComment(mockGithub, mockContext, { + messages: ['Message 1', 'Message 2'], }); - it('updates existing comment when there are issues to report', async () => { - const existingComment = { - id: 789, - body: `${_COMMENT_MARKER}\nOld content`, - }; - mockGithub.rest.issues.listComments.mockResolvedValue({ - data: [existingComment], - }); - const data = {result: 'POTENTIALLY_NON_BREAKING', changedApis: ['Test']}; - fs.existsSync.mockReturnValue(true); - fs.readFileSync.mockReturnValue(JSON.stringify(data)); - - await postPRComment(mockGithub, mockContext, { - scratchDir: '/tmp/scratch', - }); - - 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(); + 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 index cc69be9d70cd..ec49f6bead72 100644 --- a/.github/workflow-scripts/postPRComment.js +++ b/.github/workflow-scripts/postPRComment.js @@ -7,58 +7,23 @@ * @format */ -const fs = require('fs'); -const path = require('path'); - const COMMENT_MARKER = ''; -/** - * Reads the API changes output file if it exists. - * @param {string} scratchDir - Path to the scratch directory - * @returns {string|null} - The formatted API changes message, or null if no changes - */ -function getApiChangesMessage(scratchDir) { - const outputPath = path.join(scratchDir, 'output.json'); - if (!fs.existsSync(outputPath)) { - return null; - } - - const content = fs.readFileSync(outputPath, 'utf8').trim(); - if (!content) { - return null; - } - - try { - const data = JSON.parse(content); - if (!data.changedApis || data.changedApis.length === 0) { - return null; - } - return `### API Changes Detected\n\n${JSON.stringify(data, null, 2)}`; - } catch { - return content || null; - } -} - /** * 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.scratchDir] - Path to the scratch directory containing check outputs + * @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 = []; - - if (options.scratchDir) { - const apiChanges = getApiChangesMessage(options.scratchDir); - if (apiChanges) { - sections.push(apiChanges); - } - } + const sections = (options.messages || []).filter( + msg => msg != null && msg.trim() !== '', + ); const {data: comments} = await github.rest.issues.listComments({ owner, @@ -109,5 +74,4 @@ ${sections.join('\n\n')}`; module.exports = postPRComment; // Exported for testing purposes -module.exports._getApiChangesMessage = getApiChangesMessage; module.exports._COMMENT_MARKER = COMMENT_MARKER; diff --git a/.github/workflows/annotate-pr.yml b/.github/workflows/annotate-pr.yml index 3455fcc8ab59..707f978183cf 100644 --- a/.github/workflows/annotate-pr.yml +++ b/.github/workflows/annotate-pr.yml @@ -19,6 +19,7 @@ jobs: - 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 @@ -29,10 +30,12 @@ jobs: - name: Post PR comment uses: actions/github-script@v8 env: - SCRATCH_DIR: ${{ runner.temp }}/diff-js-api-changes + API_CHANGES_MESSAGE: ${{ steps.diff-js-api.outputs.message }} with: script: | const postPRComment = require('./.github/workflow-scripts/postPRComment.js'); await postPRComment(github, context, { - scratchDir: process.env.SCRATCH_DIR, + messages: [ + process.env.API_CHANGES_MESSAGE, + ], }); From f134ca66bdecb0b5fcf802941853c6c01e845eb6 Mon Sep 17 00:00:00 2001 From: Emily Brown Date: Tue, 20 Jan 2026 16:20:10 +0000 Subject: [PATCH 10/11] update message --- .../actions/diff-js-api-changes/action.yml | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/actions/diff-js-api-changes/action.yml b/.github/actions/diff-js-api-changes/action.yml index f2d6c99ae415..b1055e2ed379 100644 --- a/.github/actions/diff-js-api-changes/action.yml +++ b/.github/actions/diff-js-api-changes/action.yml @@ -54,16 +54,17 @@ runs: exit 0 fi - MESSAGE="### JavaScript API change detected - - This PR commits an update to \`ReactNativeApi.d.ts\`, indicating a change to React Native's public JavaScript API. - - - Please include a **clear changelog message**. - - This change will be subject to additional review. - - This change was flagged as: **\`${RESULT}\`**" - # Use delimiter for multiline output - echo "message<> $GITHUB_OUTPUT - echo "$MESSAGE" >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_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 From 42a5cc235ee84742f87b61b2b88f10f30126be4a Mon Sep 17 00:00:00 2001 From: Emily Brown Date: Tue, 20 Jan 2026 16:27:04 +0000 Subject: [PATCH 11/11] test change for dangerfile testing --- .github/workflows/annotate-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/annotate-pr.yml b/.github/workflows/annotate-pr.yml index 707f978183cf..d1743c897182 100644 --- a/.github/workflows/annotate-pr.yml +++ b/.github/workflows/annotate-pr.yml @@ -10,7 +10,7 @@ permissions: jobs: annotate-pr: runs-on: ubuntu-latest - # if: github.repository == 'facebook/react-native' + # if: github.repository == 'facebook/react-native' temp steps: - name: Check out main branch uses: actions/checkout@v6