diff --git a/.github/workflow-scripts/__tests__/checkForReproducer-test.js b/.github/workflow-scripts/__tests__/checkForReproducer-test.js new file mode 100644 index 000000000000..1ed0bf754653 --- /dev/null +++ b/.github/workflow-scripts/__tests__/checkForReproducer-test.js @@ -0,0 +1,149 @@ +/** + * 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 checkForReproducer = require('../checkForReproducer'); + +const AUTHOR = 'issue-author'; +const BOT = 'react-native-bot'; +const MAINTAINER = 'some-maintainer'; + +function labelEvent(event, login, type, name = 'Needs: Repro') { + return {event, label: {name}, actor: {login, type}}; +} + +function buildGithub({body, comments = [], timeline = []}) { + return { + rest: { + issues: { + get: jest.fn().mockResolvedValue({ + data: { + user: {login: AUTHOR}, + created_at: '2026-01-01T00:00:00Z', + body, + }, + }), + listComments: jest.fn().mockResolvedValue({data: comments}), + listEventsForTimeline: jest.fn().mockResolvedValue({data: timeline}), + removeLabel: jest.fn().mockResolvedValue({}), + addLabels: jest.fn().mockResolvedValue({}), + }, + }, + }; +} + +const context = { + payload: {issue: {number: 1}}, + repo: {owner: 'react', repo: 'react-native'}, +}; + +const REPRO_LINK = `Repro: https://github.com/${AUTHOR}/rn-repro`; + +describe('checkForReproducer', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('adds "Needs: Repro" and "Needs: Author Feedback" when no reproducer is present', async () => { + const github = buildGithub({body: 'It crashes.'}); + + await checkForReproducer(github, context); + + expect(github.rest.issues.addLabels).toHaveBeenCalledWith( + expect.objectContaining({ + labels: ['Needs: Repro', 'Needs: Author Feedback'], + }), + ); + expect(github.rest.issues.removeLabel).not.toHaveBeenCalled(); + }); + + it('removes "Needs: Repro" when the author links a repository they own', async () => { + const github = buildGithub({body: REPRO_LINK}); + + await checkForReproducer(github, context); + + expect(github.rest.issues.removeLabel).toHaveBeenCalledWith( + expect.objectContaining({name: 'Needs: Repro'}), + ); + expect(github.rest.issues.addLabels).not.toHaveBeenCalled(); + }); + + it('removes "Needs: Repro" after the author edits in a reproducer, even though react-native-bot applied the label', async () => { + const github = buildGithub({ + body: REPRO_LINK, + timeline: [ + labelEvent('labeled', BOT, 'User', 'Needs: Author Feedback'), + labelEvent('labeled', BOT, 'User'), + labelEvent( + 'unlabeled', + 'github-actions[bot]', + 'Bot', + 'Needs: Author Feedback', + ), + ], + }); + + await checkForReproducer(github, context); + + expect(github.rest.issues.removeLabel).toHaveBeenCalledWith( + expect.objectContaining({name: 'Needs: Repro'}), + ); + expect(github.rest.issues.addLabels).not.toHaveBeenCalled(); + }); + + it('does nothing when a maintainer has changed the "Needs: Repro" label', async () => { + const github = buildGithub({ + body: REPRO_LINK, + timeline: [ + labelEvent('labeled', BOT, 'User'), + labelEvent('unlabeled', MAINTAINER, 'User'), + labelEvent('labeled', MAINTAINER, 'User'), + ], + }); + + await checkForReproducer(github, context); + + expect(github.rest.issues.removeLabel).not.toHaveBeenCalled(); + expect(github.rest.issues.addLabels).not.toHaveBeenCalled(); + }); + + it('ignores label changes on other labels when deciding whether a maintainer intervened', async () => { + const github = buildGithub({ + body: REPRO_LINK, + timeline: [ + labelEvent('labeled', BOT, 'User'), + labelEvent('labeled', MAINTAINER, 'User', 'Platform: iOS'), + ], + }); + + await checkForReproducer(github, context); + + expect(github.rest.issues.removeLabel).toHaveBeenCalledWith( + expect.objectContaining({name: 'Needs: Repro'}), + ); + }); + + it('accepts a reproducer link posted in a comment by its own author', async () => { + const github = buildGithub({ + body: 'It crashes.', + comments: [ + { + user: {login: AUTHOR}, + body: `Here you go: https://github.com/${AUTHOR}/rn-repro`, + }, + ], + timeline: [labelEvent('labeled', BOT, 'User')], + }); + + await checkForReproducer(github, context); + + expect(github.rest.issues.removeLabel).toHaveBeenCalledWith( + expect.objectContaining({name: 'Needs: Repro'}), + ); + }); +}); diff --git a/.github/workflow-scripts/checkForReproducer.js b/.github/workflow-scripts/checkForReproducer.js index 75d7ab7bf807..8c8eb445522e 100644 --- a/.github/workflow-scripts/checkForReproducer.js +++ b/.github/workflow-scripts/checkForReproducer.js @@ -10,6 +10,8 @@ const NEEDS_REPRO_LABEL = 'Needs: Repro'; const NEEDS_AUTHOR_FEEDBACK_LABEL = 'Needs: Author Feedback'; const SKIP_ISSUES_OLDER_THAN = '2023-07-01T00:00:00Z'; +// The account this workflow runs as; a user account, so actor.type is 'User'. +const REACT_NATIVE_BOT_LOGIN = 'react-native-bot'; module.exports = async (github, context) => { const issueData = { @@ -82,16 +84,22 @@ function containsPattern(body, pattern) { return body.search(regexp) !== -1; } -// Prevents the bot from responding when maintainer has changed the 'Needs: Repro' label +// Prevents the bot from responding when a maintainer has changed the +// 'Needs: Repro' label. Events from GitHub Apps and react-native-bot itself +// are automation, not maintainer decisions. async function hasMaintainerChangedLabel(github, issueData, author) { const timeline = await github.rest.issues.listEventsForTimeline(issueData); const labeledEvents = timeline.data.filter( event => event.event === 'labeled' || event.event === 'unlabeled', ); - const userEvents = labeledEvents.filter(event => event.actor.type !== 'Bot'); + const maintainerEvents = labeledEvents.filter( + event => + event.actor.type !== 'Bot' && + event.actor.login !== REACT_NATIVE_BOT_LOGIN, + ); - return userEvents.some( + return maintainerEvents.some( event => event.actor.login !== author && event.label.name === NEEDS_REPRO_LABEL, );