diff --git a/.github/actions/javascript/authorChecklist/index.js b/.github/actions/javascript/authorChecklist/index.js index 528a0a11498a..b20cc83498ba 100644 --- a/.github/actions/javascript/authorChecklist/index.js +++ b/.github/actions/javascript/authorChecklist/index.js @@ -255,7 +255,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -268,7 +268,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -281,85 +281,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -393,6 +397,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/awaitStagingDeploys/index.js b/.github/actions/javascript/awaitStagingDeploys/index.js index f042dbb38a91..6b8401a08d6d 100644 --- a/.github/actions/javascript/awaitStagingDeploys/index.js +++ b/.github/actions/javascript/awaitStagingDeploys/index.js @@ -367,7 +367,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -380,7 +380,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -393,85 +393,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -505,6 +509,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/checkDeployBlockers/index.js b/.github/actions/javascript/checkDeployBlockers/index.js index 8e10f8b1d8b6..dffc089ea5c5 100644 --- a/.github/actions/javascript/checkDeployBlockers/index.js +++ b/.github/actions/javascript/checkDeployBlockers/index.js @@ -334,7 +334,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -347,7 +347,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -360,85 +360,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -472,6 +476,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.js b/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.js index 4441348a3c36..63398614fd11 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.js +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.js @@ -40,8 +40,11 @@ async function run() { // Next, we generate the checklist body let checklistBody = ''; + let checklistAssignees = []; if (shouldCreateNewDeployChecklist) { - checklistBody = await GithubUtils.generateStagingDeployCashBody(newVersionTag, _.map(mergedPRs, GithubUtils.getPullRequestURLFromNumber)); + const {issueBody, issueAssignees} = await GithubUtils.generateStagingDeployCashBodyAndAssignees(newVersionTag, _.map(mergedPRs, GithubUtils.getPullRequestURLFromNumber)); + checklistBody = issueBody; + checklistAssignees = issueAssignees; } else { // Generate the updated PR list, preserving the previous state of `isVerified` for existing PRs const PRList = _.reduce( @@ -94,7 +97,7 @@ async function run() { } const didVersionChange = newVersionTag !== currentChecklistData.tag; - checklistBody = await GithubUtils.generateStagingDeployCashBody( + const {issueBody, issueAssignees} = await GithubUtils.generateStagingDeployCashBodyAndAssignees( newVersionTag, _.pluck(PRList, 'url'), _.pluck(_.where(PRList, {isVerified: true}), 'url'), @@ -105,6 +108,8 @@ async function run() { didVersionChange ? false : currentChecklistData.isFirebaseChecked, didVersionChange ? false : currentChecklistData.isGHStatusChecked, ); + checklistBody = issueBody; + checklistAssignees = issueAssignees; } // Finally, create or update the checklist @@ -119,7 +124,7 @@ async function run() { ...defaultPayload, title: `Deploy Checklist: New Expensify ${format(new Date(), CONST.DATE_FORMAT_STRING)}`, labels: [CONST.LABELS.STAGING_DEPLOY], - assignees: [CONST.APPLAUSE_BOT], + assignees: [CONST.APPLAUSE_BOT].concat(checklistAssignees), }); console.log(`Successfully created new StagingDeployCash! 🎉 ${newChecklist.html_url}`); return newChecklist; diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js index 154dacbdc3c3..60ec0b9f0ae3 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js @@ -49,8 +49,11 @@ async function run() { // Next, we generate the checklist body let checklistBody = ''; + let checklistAssignees = []; if (shouldCreateNewDeployChecklist) { - checklistBody = await GithubUtils.generateStagingDeployCashBody(newVersionTag, _.map(mergedPRs, GithubUtils.getPullRequestURLFromNumber)); + const {issueBody, issueAssignees} = await GithubUtils.generateStagingDeployCashBodyAndAssignees(newVersionTag, _.map(mergedPRs, GithubUtils.getPullRequestURLFromNumber)); + checklistBody = issueBody; + checklistAssignees = issueAssignees; } else { // Generate the updated PR list, preserving the previous state of `isVerified` for existing PRs const PRList = _.reduce( @@ -103,7 +106,7 @@ async function run() { } const didVersionChange = newVersionTag !== currentChecklistData.tag; - checklistBody = await GithubUtils.generateStagingDeployCashBody( + const {issueBody, issueAssignees} = await GithubUtils.generateStagingDeployCashBodyAndAssignees( newVersionTag, _.pluck(PRList, 'url'), _.pluck(_.where(PRList, {isVerified: true}), 'url'), @@ -114,6 +117,8 @@ async function run() { didVersionChange ? false : currentChecklistData.isFirebaseChecked, didVersionChange ? false : currentChecklistData.isGHStatusChecked, ); + checklistBody = issueBody; + checklistAssignees = issueAssignees; } // Finally, create or update the checklist @@ -128,7 +133,7 @@ async function run() { ...defaultPayload, title: `Deploy Checklist: New Expensify ${format(new Date(), CONST.DATE_FORMAT_STRING)}`, labels: [CONST.LABELS.STAGING_DEPLOY], - assignees: [CONST.APPLAUSE_BOT], + assignees: [CONST.APPLAUSE_BOT].concat(checklistAssignees), }); console.log(`Successfully created new StagingDeployCash! 🎉 ${newChecklist.html_url}`); return newChecklist; @@ -406,7 +411,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -419,7 +424,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -432,85 +437,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -544,6 +553,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/getArtifactInfo/index.js b/.github/actions/javascript/getArtifactInfo/index.js index ea56ff5f4ebd..b8cb062feba5 100644 --- a/.github/actions/javascript/getArtifactInfo/index.js +++ b/.github/actions/javascript/getArtifactInfo/index.js @@ -293,7 +293,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -306,7 +306,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -319,85 +319,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -431,6 +435,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index f272929d536a..c57ebf0fefe4 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -349,7 +349,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -362,7 +362,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -375,85 +375,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -487,6 +491,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/getPullRequestDetails/index.js b/.github/actions/javascript/getPullRequestDetails/index.js index b8d7d821d64e..9eb608a8cc05 100644 --- a/.github/actions/javascript/getPullRequestDetails/index.js +++ b/.github/actions/javascript/getPullRequestDetails/index.js @@ -301,7 +301,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -314,7 +314,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -327,85 +327,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -439,6 +443,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/getReleaseBody/index.js b/.github/actions/javascript/getReleaseBody/index.js index cc1321ce5cd5..ea12ef5f0df0 100644 --- a/.github/actions/javascript/getReleaseBody/index.js +++ b/.github/actions/javascript/getReleaseBody/index.js @@ -301,7 +301,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -314,7 +314,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -327,85 +327,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -439,6 +443,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/isStagingDeployLocked/index.js b/.github/actions/javascript/isStagingDeployLocked/index.js index 8124c5795a5a..32b8b64d7b82 100644 --- a/.github/actions/javascript/isStagingDeployLocked/index.js +++ b/.github/actions/javascript/isStagingDeployLocked/index.js @@ -285,7 +285,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -298,7 +298,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -311,85 +311,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -423,6 +427,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/index.js b/.github/actions/javascript/markPullRequestsAsDeployed/index.js index 36cd0aaefe4a..d275edf9d789 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/index.js +++ b/.github/actions/javascript/markPullRequestsAsDeployed/index.js @@ -450,7 +450,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -463,7 +463,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -476,85 +476,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -588,6 +592,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/postTestBuildComment/index.js b/.github/actions/javascript/postTestBuildComment/index.js index 329e0d3aad5d..3bd3e6121be8 100644 --- a/.github/actions/javascript/postTestBuildComment/index.js +++ b/.github/actions/javascript/postTestBuildComment/index.js @@ -360,7 +360,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -373,7 +373,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -386,85 +386,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -498,6 +502,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/reopenIssueWithComment/index.js b/.github/actions/javascript/reopenIssueWithComment/index.js index 6a5f89badb5e..9c740914dc1b 100644 --- a/.github/actions/javascript/reopenIssueWithComment/index.js +++ b/.github/actions/javascript/reopenIssueWithComment/index.js @@ -255,7 +255,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -268,7 +268,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -281,85 +281,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -393,6 +397,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/reviewerChecklist/index.js b/.github/actions/javascript/reviewerChecklist/index.js index 322b529b89bf..7b162f06840d 100644 --- a/.github/actions/javascript/reviewerChecklist/index.js +++ b/.github/actions/javascript/reviewerChecklist/index.js @@ -255,7 +255,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -268,7 +268,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -281,85 +281,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -393,6 +397,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/actions/javascript/verifySignedCommits/index.js b/.github/actions/javascript/verifySignedCommits/index.js index ba188d3a2b86..07173cb19bc5 100644 --- a/.github/actions/javascript/verifySignedCommits/index.js +++ b/.github/actions/javascript/verifySignedCommits/index.js @@ -255,7 +255,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -268,7 +268,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -281,85 +281,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -393,6 +397,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/.github/libs/GithubUtils.js b/.github/libs/GithubUtils.js index 0cd407c78153..47ad2440c165 100644 --- a/.github/libs/GithubUtils.js +++ b/.github/libs/GithubUtils.js @@ -222,7 +222,7 @@ class GithubUtils { } /** - * Generate the issue body for a StagingDeployCash. + * Generate the issue body and assignees for a StagingDeployCash. * * @param {String} tag * @param {Array} PRList - The list of PR URLs which are included in this StagingDeployCash @@ -235,7 +235,7 @@ class GithubUtils { * @param {Boolean} [isGHStatusChecked] * @returns {Promise} */ - static generateStagingDeployCashBody( + static generateStagingDeployCashBodyAndAssignees( tag, PRList, verifiedPRList = [], @@ -248,85 +248,89 @@ class GithubUtils { ) { return this.fetchAllPullRequests(_.map(PRList, this.getPullRequestNumberFromURL)) .then((data) => { - // The format of this map is following: - // { - // 'https://github.com/Expensify/App/pull/9641': [ 'PauloGasparSv', 'kidroca' ], - // 'https://github.com/Expensify/App/pull/9642': [ 'mountiny', 'kidroca' ] - // } - const internalQAPRMap = _.reduce( - _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))), - (map, pr) => { - // eslint-disable-next-line no-param-reassign - map[pr.html_url] = _.compact(_.pluck(pr.assignees, 'login')); - return map; - }, - {}, - ); - console.log('Found the following Internal QA PRs:', internalQAPRMap); - - const noQAPRs = _.pluck( - _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), - 'html_url', - ); - console.log('Found the following NO QA PRs:', noQAPRs); - const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); - - const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); - const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); - - // Tag version and comparison URL - // eslint-disable-next-line max-len - let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; - - // PR list - if (!_.isEmpty(sortedPRList)) { - issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; - _.each(sortedPRList, (URL) => { - issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; - issueBody += ` ${URL}\r\n`; - }); - issueBody += '\r\n\r\n'; - } - - // Internal QA PR list - if (!_.isEmpty(internalQAPRMap)) { - console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); - issueBody += '**Internal QA:**\r\n'; - _.each(internalQAPRMap, (assignees, URL) => { - const assigneeMentions = _.reduce(assignees, (memo, assignee) => `${memo} @${assignee}`, ''); - issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; - issueBody += `${URL}`; - issueBody += ` -${assigneeMentions}`; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } - - // Deploy blockers - if (!_.isEmpty(deployBlockers)) { - issueBody += '**Deploy Blockers:**\r\n'; - _.each(sortedDeployBlockers, (URL) => { - issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; - issueBody += URL; - issueBody += '\r\n'; - }); - issueBody += '\r\n\r\n'; - } - - issueBody += '**Deployer verifications:**'; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isTimingDashboardChecked ? 'x' : ' ' - }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${ - isFirebaseChecked ? 'x' : ' ' - }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; - // eslint-disable-next-line max-len - issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; - - issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; - return issueBody; + const internalQAPRs = _.filter(data, (pr) => !_.isEmpty(_.findWhere(pr.labels, {name: CONST.LABELS.INTERNAL_QA}))); + return Promise.all(_.map(internalQAPRs, (pr) => this.getPullRequestMergerLogin(pr.number).then((mergerLogin) => ({url: pr.html_url, mergerLogin})))).then((results) => { + // The format of this map is following: + // { + // 'https://github.com/Expensify/App/pull/9641': 'PauloGasparSv', + // 'https://github.com/Expensify/App/pull/9642': 'mountiny' + // } + const internalQAPRMap = _.reduce( + results, + (acc, {url, mergerLogin}) => { + acc[url] = mergerLogin; + return acc; + }, + {}, + ); + console.log('Found the following Internal QA PRs:', internalQAPRMap); + + const noQAPRs = _.pluck( + _.filter(data, (PR) => /\[No\s?QA]/i.test(PR.title)), + 'html_url', + ); + console.log('Found the following NO QA PRs:', noQAPRs); + const verifiedOrNoQAPRs = _.union(verifiedPRList, noQAPRs); + + const sortedPRList = _.chain(PRList).difference(_.keys(internalQAPRMap)).unique().sortBy(GithubUtils.getPullRequestNumberFromURL).value(); + const sortedDeployBlockers = _.sortBy(_.unique(deployBlockers), GithubUtils.getIssueOrPullRequestNumberFromURL); + + // Tag version and comparison URL + // eslint-disable-next-line max-len + let issueBody = `**Release Version:** \`${tag}\`\r\n**Compare Changes:** https://github.com/Expensify/App/compare/production...staging\r\n`; + + // PR list + if (!_.isEmpty(sortedPRList)) { + issueBody += '\r\n**This release contains changes from the following pull requests:**\r\n'; + _.each(sortedPRList, (URL) => { + issueBody += _.contains(verifiedOrNoQAPRs, URL) ? '- [x]' : '- [ ]'; + issueBody += ` ${URL}\r\n`; + }); + issueBody += '\r\n\r\n'; + } + + // Internal QA PR list + if (!_.isEmpty(internalQAPRMap)) { + console.log('Found the following verified Internal QA PRs:', resolvedInternalQAPRs); + issueBody += '**Internal QA:**\r\n'; + _.each(internalQAPRMap, (merger, URL) => { + const mergerMention = `@${merger}`; + issueBody += `${_.contains(resolvedInternalQAPRs, URL) ? '- [x]' : '- [ ]'} `; + issueBody += `${URL}`; + issueBody += ` - ${mergerMention}`; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } + + // Deploy blockers + if (!_.isEmpty(deployBlockers)) { + issueBody += '**Deploy Blockers:**\r\n'; + _.each(sortedDeployBlockers, (URL) => { + issueBody += _.contains(resolvedDeployBlockers, URL) ? '- [x] ' : '- [ ] '; + issueBody += URL; + issueBody += '\r\n'; + }); + issueBody += '\r\n\r\n'; + } + + issueBody += '**Deployer verifications:**'; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isTimingDashboardChecked ? 'x' : ' ' + }] I checked the [App Timing Dashboard](https://graphs.expensify.com/grafana/d/yj2EobAGz/app-timing?orgId=1) and verified this release does not cause a noticeable performance regression.`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${ + isFirebaseChecked ? 'x' : ' ' + }] I checked [Firebase Crashlytics](https://console.firebase.google.com/u/0/project/expensify-chat/crashlytics/app/android:com.expensify.chat/issues?state=open&time=last-seven-days&tag=all) and verified that this release does not introduce any new crashes. More detailed instructions on this verification can be found [here](https://stackoverflowteams.com/c/expensify/questions/15095/15096).`; + // eslint-disable-next-line max-len + issueBody += `\r\n- [${isGHStatusChecked ? 'x' : ' '}] I checked [GitHub Status](https://www.githubstatus.com/) and verified there is no reported incident with Actions.`; + + issueBody += '\r\n\r\ncc @Expensify/applauseleads\r\n'; + const issueAssignees = _.uniq(_.values(internalQAPRMap)); + const issue = {issueBody, issueAssignees}; + return issue; + }); }) .catch((err) => console.warn('Error generating StagingDeployCash issue body! Continuing...', err)); } @@ -360,6 +364,20 @@ class GithubUtils { .catch((err) => console.error('Failed to get PR list', err)); } + /** + * @param {Number} pullRequestNumber + * @returns {Promise} + */ + static getPullRequestMergerLogin(pullRequestNumber) { + return this.octokit.pulls + .get({ + owner: CONST.GITHUB_OWNER, + repo: CONST.APP_REPO, + pull_number: pullRequestNumber, + }) + .then(({data: pullRequest}) => pullRequest.merged_by.login); + } + /** * @param {Number} pullRequestNumber * @returns {Promise} diff --git a/__mocks__/@react-native-clipboard/clipboard.js b/__mocks__/@react-native-clipboard/clipboard.js deleted file mode 100644 index e56e290c3cc9..000000000000 --- a/__mocks__/@react-native-clipboard/clipboard.js +++ /dev/null @@ -1,3 +0,0 @@ -import MockClipboard from '@react-native-clipboard/clipboard/jest/clipboard-mock'; - -export default MockClipboard; diff --git a/__mocks__/@react-native-clipboard/clipboard.ts b/__mocks__/@react-native-clipboard/clipboard.ts new file mode 100644 index 000000000000..75b6f09f5345 --- /dev/null +++ b/__mocks__/@react-native-clipboard/clipboard.ts @@ -0,0 +1,3 @@ +import clipboardMock from '@react-native-clipboard/clipboard/jest/clipboard-mock'; + +export default clipboardMock; diff --git a/__mocks__/react-native-document-picker.js b/__mocks__/react-native-document-picker.ts similarity index 64% rename from __mocks__/react-native-document-picker.js rename to __mocks__/react-native-document-picker.ts index 8cba2bc1eba4..6d26a0227fc3 100644 --- a/__mocks__/react-native-document-picker.js +++ b/__mocks__/react-native-document-picker.ts @@ -1,9 +1,16 @@ -export default { - getConstants: jest.fn(), +import type {pick, pickDirectory, releaseSecureAccess, types} from 'react-native-document-picker'; + +type ReactNativeDocumentPickerMock = { + pick: typeof pick; + releaseSecureAccess: typeof releaseSecureAccess; + pickDirectory: typeof pickDirectory; + types: typeof types; +}; + +const reactNativeDocumentPickerMock: ReactNativeDocumentPickerMock = { pick: jest.fn(), releaseSecureAccess: jest.fn(), pickDirectory: jest.fn(), - types: Object.freeze({ allFiles: 'public.item', audio: 'public.audio', @@ -21,3 +28,5 @@ export default { zip: 'public.zip-archive', }), }; + +export default reactNativeDocumentPickerMock; diff --git a/android/app/build.gradle b/android/app/build.gradle index 9c5db608a846..62e30858e73c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001045503 - versionName "1.4.55-3" + versionCode 1001045602 + versionName "1.4.56-2" } flavorDimensions "default" diff --git a/desktop/package-lock.json b/desktop/package-lock.json index b8ae9d0a2be5..efa8a25a0614 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -10,7 +10,7 @@ "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", "electron-serve": "^1.3.0", - "electron-updater": "^6.1.9", + "electron-updater": "^6.2.1", "node-machine-id": "^1.1.12" } }, @@ -156,9 +156,9 @@ } }, "node_modules/electron-updater": { - "version": "6.1.9", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.1.9.tgz", - "integrity": "sha512-omoTwGSG+/H8G62cEZS/dc5Lmc4HohAd4198AP+JNv8H7bfxXUCKekaR6WpsN1n2DiWzvcqOusfGSogZv/uj9w==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.2.1.tgz", + "integrity": "sha512-83eKIPW14qwZqUUM6wdsIRwVKZyjmHxQ4/8G+1C6iS5PdDt7b1umYQyj1/qPpH510GmHEQe4q0kCPe3qmb3a0Q==", "dependencies": { "builder-util-runtime": "9.2.4", "fs-extra": "^10.1.0", @@ -541,9 +541,9 @@ "integrity": "sha512-OEC/48ZBJxR6XNSZtCl4cKPyQ1lvsu8yp8GdCplMWwGS1eEyMcEmzML5BRs/io/RLDnpgyf+7rSL+X6ICifRIg==" }, "electron-updater": { - "version": "6.1.9", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.1.9.tgz", - "integrity": "sha512-omoTwGSG+/H8G62cEZS/dc5Lmc4HohAd4198AP+JNv8H7bfxXUCKekaR6WpsN1n2DiWzvcqOusfGSogZv/uj9w==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.2.1.tgz", + "integrity": "sha512-83eKIPW14qwZqUUM6wdsIRwVKZyjmHxQ4/8G+1C6iS5PdDt7b1umYQyj1/qPpH510GmHEQe4q0kCPe3qmb3a0Q==", "requires": { "builder-util-runtime": "9.2.4", "fs-extra": "^10.1.0", diff --git a/desktop/package.json b/desktop/package.json index 606fcac92500..4249f3fcfba9 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -7,7 +7,7 @@ "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", "electron-serve": "^1.3.0", - "electron-updater": "^6.1.9", + "electron-updater": "^6.2.1", "node-machine-id": "^1.1.12" }, "author": "Expensify, Inc.", diff --git a/docs/articles/expensify-classic/expenses/Add-expenses-to-a-report.md b/docs/articles/expensify-classic/expenses/Add-expenses-to-a-report.md new file mode 100644 index 000000000000..96427a60d87f --- /dev/null +++ b/docs/articles/expensify-classic/expenses/Add-expenses-to-a-report.md @@ -0,0 +1,18 @@ +--- +title: Add expenses to a report +description: Add expenses to a report to submit them for approval +--- +
+ +To submit expenses for approval, they must be added to a report. + +1. Click the **Expenses** tab. +2. Find the expenses you want to add to the report by searching through the table of expenses and/or using the sort filters. +3. Select the expenses by checking the box to the left of each expense or selecting them all. +4. Click **Add to Report** in the right corner and select either: + - **Auto-Report**: Automatically adds the expenses to an open report, or creates a new report if there are no open reports + - **New Report**: Creates a new report for the expenses + - **None**: Ensures none of the selected expenses are attached to a report (as long as the report has not already been submitted) + - **Existing Report**: Adds the expenses to the selected report + +
diff --git a/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md index ecdea4699ee0..47e7a5b5382a 100644 --- a/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md +++ b/docs/articles/expensify-classic/integrations/HR-integrations/Workday.md @@ -34,7 +34,7 @@ In order to complete the steps below, you'll need a Workday System Administrator 4. Search and select "security group membership and access". 5. Search for the security group you just created. 6. Click the ellipsis, then **Security Group > Maintain Domain Permissions for Security Group**. -7. Under **Integration Permissions**, add "External Account Provisioning" to **Domain Security Workspaces permitting Put access** and "Worker Data: Workers" to **Domain Security Workspaces permitting Get access**. +7. Head to Integration Permissions and add **Get access** for “External Account Provisioning” and “Worker Data: Workers” under Domain Security Workspaces. 8. Click **OK** and **Done**. 9. Search **Activate Pending Security Workspace Changes** and complete the task for activating the security workspace change, adding a comment if required and checking the **Confirmed** check-box. diff --git a/docs/articles/expensify-classic/workspaces/Set-time-and-distance-rates.md b/docs/articles/expensify-classic/workspaces/Set-time-and-distance-rates.md new file mode 100644 index 000000000000..e81e05446379 --- /dev/null +++ b/docs/articles/expensify-classic/workspaces/Set-time-and-distance-rates.md @@ -0,0 +1,24 @@ +--- +title: Set time and distance rates +description: Set rates for hourly and mileage expenses +--- +
+ +You can set rates for your workspace’s hourly billable and mileage expenses. + +1. Hover over Settings, then click **Workspaces**. +2. Click the **Group** tab on the left. +3. Click the desired workspace name. +4. Click the **Expenses** tab on the left. +5. Scroll down to the time or distance section and set your rates. + - For distance: + - If desired, adjust your unit (miles or kilometers) and your default category. These options will apply to all of your distance rates. + - To add a new rate, click **Add a Mileage Rate**. + - To edit an existing rate, + - Click the toggle to enable or disable the rate. + - Click the name or rate field to edit them. + - For time, + - Click the Enable toggle to enable hourly rate expenses. + - Enter the default hourly rate. + +
diff --git a/docs/redirects.csv b/docs/redirects.csv index df4e2a45dce3..7539a2777d92 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -66,3 +66,4 @@ https://help.expensify.com/articles/expensify-classic/expensify-billing/Individu https://help.expensify.com/articles/expensify-classic/settings/Merge-Accounts,https://help.expensify.com/articles/expensify-classic/settings/account-settings/Merge-accounts https://help.expensify.com/articles/expensify-classic/settings/Preferences,https://help.expensify.com/expensify-classic/hubs/settings/account-settings https://help.expensify.com/articles/expensify-classic/getting-started/support/Your-Expensify-Account-Manager,https://use.expensify.com/support +https://help.expensify.com/articles/expensify-classic/settings/Copilot,https://help.expensify.com/expensify-classic/hubs/copilots-and-delegates/ diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 5e2ba1fcd614..a962c69f0bc6 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.55 + 1.4.56 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.55.3 + 1.4.56.2 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 69472200e46d..9f20eb574abc 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.55 + 1.4.56 CFBundleSignature ???? CFBundleVersion - 1.4.55.3 + 1.4.56.2 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 008ca16909b0..2319ff879a03 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.55 + 1.4.56 CFBundleVersion - 1.4.55.3 + 1.4.56.2 NSExtension NSExtensionPointIdentifier diff --git a/package-lock.json b/package-lock.json index 4bff5eaf6eb8..fcebc3cd46dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.55-3", + "version": "1.4.56-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.55-3", + "version": "1.4.56-2", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 53eb229d7b85..c41afac9d570 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.55-3", + "version": "1.4.56-2", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -50,8 +50,8 @@ "analyze-packages": "ANALYZE_BUNDLE=true webpack --config config/webpack/webpack.common.js --env envFile=.env.production", "symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map", "symbolicate:ios": "npx metro-symbolicate main.jsbundle.map", - "symbolicate-release:ios": "scripts/release-profile.js --platform=ios", - "symbolicate-release:android": "scripts/release-profile.js --platform=android", + "symbolicate-release:ios": "scripts/release-profile.ts --platform=ios", + "symbolicate-release:android": "scripts/release-profile.ts --platform=android", "test:e2e": "ts-node tests/e2e/testRunner.ts --config ./config.local.ts", "test:e2e:dev": "ts-node tests/e2e/testRunner.ts --config ./config.dev.ts", "gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh", diff --git a/scripts/release-profile.js b/scripts/release-profile.ts similarity index 83% rename from scripts/release-profile.js rename to scripts/release-profile.ts index 0f96232bcdca..8ec0979f9f9e 100755 --- a/scripts/release-profile.js +++ b/scripts/release-profile.ts @@ -1,13 +1,15 @@ -#!/usr/bin/env node +#!/usr/bin/env ts-node + /* eslint-disable no-console */ +import {execSync} from 'child_process'; +import fs from 'fs'; -const fs = require('fs'); -const {execSync} = require('child_process'); +type ArgsMap = Record; // Function to parse command-line arguments into a key-value object -function parseCommandLineArguments() { +function parseCommandLineArguments(): ArgsMap { const args = process.argv.slice(2); // Skip node and script paths - const argsMap = {}; + const argsMap: ArgsMap = {}; args.forEach((arg) => { const [key, value] = arg.split('='); if (key.startsWith('--')) { @@ -20,14 +22,13 @@ function parseCommandLineArguments() { // Function to find .cpuprofile files in the current directory function findCpuProfileFiles() { const files = fs.readdirSync(process.cwd()); - // eslint-disable-next-line rulesdir/prefer-underscore-method return files.filter((file) => file.endsWith('.cpuprofile')); } const argsMap = parseCommandLineArguments(); // Determine sourcemapPath based on the platform flag passed -let sourcemapPath; +let sourcemapPath: string | undefined; if (argsMap.platform === 'ios') { sourcemapPath = 'main.jsbundle.map'; } else if (argsMap.platform === 'android') { @@ -57,7 +58,10 @@ if (cpuProfiles.length === 0) { const output = execSync(command, {stdio: 'inherit'}); console.log(output.toString()); } catch (error) { - console.error(`Error executing command: ${error}`); + if (error instanceof Error) { + console.error(`Error executing command: ${error.toString()}`); + } + process.exit(1); } } diff --git a/src/CONST.ts b/src/CONST.ts index 18fa41e526d9..8ffaab2016e4 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1679,7 +1679,7 @@ const CONST = { POLICY_ID_FROM_PATH: /\/w\/([a-zA-Z0-9]+)(\/|$)/, - SHORT_MENTION: new RegExp(`@[\\w\\-\\+\\'#]+(?:\\.[\\w\\-\\'\\+]+)*`, 'gim'), + SHORT_MENTION: new RegExp(`@[\\w\\-\\+\\'#@]+(?:\\.[\\w\\-\\'\\+]+)*`, 'gim'), }, PRONOUNS: { @@ -4135,6 +4135,22 @@ const CONST = { SESSION_STORAGE_KEYS: { INITIAL_URL: 'INITIAL_URL', }, + DEFAULT_TAX: { + defaultExternalID: 'id_TAX_EXEMPT', + defaultValue: '0%', + foreignTaxDefault: 'id_TAX_EXEMPT', + name: 'Tax', + taxes: { + id_TAX_EXEMPT: { + name: 'Tax exempt', + value: '0%', + }, + id_TAX_RATE_1: { + name: 'Tax Rate 1', + value: '5%', + }, + }, + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/components/AddressSearch/index.tsx b/src/components/AddressSearch/index.tsx index 9901ff9243f9..d0aa2e206eb2 100644 --- a/src/components/AddressSearch/index.tsx +++ b/src/components/AddressSearch/index.tsx @@ -455,3 +455,5 @@ function AddressSearch( AddressSearch.displayName = 'AddressSearchWithRef'; export default forwardRef(AddressSearch); + +export type {AddressSearchProps}; diff --git a/src/components/AddressSearch/types.ts b/src/components/AddressSearch/types.ts index 27e068cd1777..efbcc6374341 100644 --- a/src/components/AddressSearch/types.ts +++ b/src/components/AddressSearch/types.ts @@ -96,4 +96,4 @@ type AddressSearchProps = { type IsCurrentTargetInsideContainerType = (event: FocusEvent | NativeSyntheticEvent, containerRef: RefObject) => boolean; -export type {CurrentLocationButtonProps, AddressSearchProps, RenamedInputKeysProps, IsCurrentTargetInsideContainerType}; +export type {CurrentLocationButtonProps, AddressSearchProps, RenamedInputKeysProps, IsCurrentTargetInsideContainerType, StreetValue}; diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 0c047ce52dc8..442b3cd864d6 100755 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -549,7 +549,7 @@ function AttachmentModal({ // @ts-expect-error TODO: Remove this once Attachments (https://github.com/Expensify/App/issues/24969) is migrated to TypeScript. containerStyles={[styles.mh5]} source={sourceForAttachmentView} - isAuthTokenRequired={isAuthTokenRequired} + isAuthTokenRequired={isAuthTokenRequiredState} file={file} onToggleKeyboard={updateConfirmButtonVisibility} isWorkspaceAvatar={isWorkspaceAvatar} diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index 56fe7c4d0b42..a46b37c986ba 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -109,3 +109,5 @@ function Banner({text, onClose, onPress, containerStyles, textStyles, shouldRend Banner.displayName = 'Banner'; export default memo(Banner); + +export type {BannerProps}; diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 3abc5b17ae80..3b05d22fe6c4 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -352,3 +352,5 @@ function Button( Button.displayName = 'Button'; export default withNavigationFallback(React.forwardRef(Button)); + +export type {ButtonProps}; diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 21f3e9a3b605..f3293596aa46 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -15,7 +15,6 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useThrottledButtonState from '@hooks/useThrottledButtonState'; -import useWaitForNavigation from '@hooks/useWaitForNavigation'; import getButtonState from '@libs/getButtonState'; import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; @@ -58,7 +57,6 @@ function HeaderWithBackButton({ children = null, shouldOverlayDots = false, shouldOverlay = false, - singleExecution = (func) => func, shouldNavigateToTopMostReport = false, style, }: HeaderWithBackButtonProps) { @@ -68,7 +66,6 @@ function HeaderWithBackButton({ const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState(); const {translate} = useLocalize(); const {isKeyboardShown} = useKeyboardState(); - const waitForNavigate = useWaitForNavigation(); // If the icon is present, the header bar should be taller and use different font. const isCentralPaneSettings = !!icon; @@ -175,7 +172,7 @@ function HeaderWithBackButton({ Navigation.navigate(ROUTES.GET_ASSISTANCE.getRoute(guidesCallTaskID, Navigation.getActiveRoute()))))} + onPress={() => Navigation.navigate(ROUTES.GET_ASSISTANCE.getRoute(guidesCallTaskID, Navigation.getActiveRoute()))} style={[styles.touchableButtonImage]} role="button" accessibilityLabel={translate('getAssistancePage.questionMarkButtonTooltip')} diff --git a/src/components/Picker/BasePicker.tsx b/src/components/Picker/BasePicker.tsx index 03f1e0f496a8..1c337e024116 100644 --- a/src/components/Picker/BasePicker.tsx +++ b/src/components/Picker/BasePicker.tsx @@ -72,11 +72,11 @@ function BasePicker( */ const onValueChange = (inputValue: TPickerValue, index: number) => { if (inputID) { - onInputChange(inputValue); + onInputChange?.(inputValue); return; } - onInputChange(inputValue, index); + onInputChange?.(inputValue, index); }; const enableHighlight = () => { diff --git a/src/components/Picker/types.ts b/src/components/Picker/types.ts index 3f7c0282d35a..d935ebe8fdc5 100644 --- a/src/components/Picker/types.ts +++ b/src/components/Picker/types.ts @@ -80,7 +80,7 @@ type BasePickerProps = { shouldShowOnlyTextWhenDisabled?: boolean; /** A callback method that is called when the value changes and it receives the selected value as an argument */ - onInputChange: (value: TPickerValue, index?: number) => void; + onInputChange?: (value: TPickerValue, index?: number) => void; /** Size of a picker component */ size?: PickerSize; diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index 827eec8088a6..b78e274371ca 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -23,6 +23,7 @@ import KeyboardAvoidingView from './KeyboardAvoidingView'; import OfflineIndicator from './OfflineIndicator'; import SafeAreaConsumer from './SafeAreaConsumer'; import TestToolsModal from './TestToolsModal'; +import withNavigationFallback from './withNavigationFallback'; type ChildrenProps = { insets: EdgeInsets; @@ -279,4 +280,4 @@ function ScreenWrapper( ScreenWrapper.displayName = 'ScreenWrapper'; -export default forwardRef(ScreenWrapper); +export default withNavigationFallback(forwardRef(ScreenWrapper)); diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx index 0ea8ea308d6a..690a9485f099 100644 --- a/src/components/SettlementButton.tsx +++ b/src/components/SettlementButton.tsx @@ -143,9 +143,8 @@ function SettlementButton({ const session = useSession(); const chatReport = ReportUtils.getReport(chatReportID); const isPaidGroupPolicy = ReportUtils.isPaidGroupPolicyExpenseChat(chatReport as OnyxEntry); - const shouldShowPaywithExpensifyOption = - !isPaidGroupPolicy || - (!shouldHidePaymentOptions && policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES && policy?.reimburserEmail === session?.email); + const shouldShowPaywithExpensifyOption = !isPaidGroupPolicy || (!shouldHidePaymentOptions && ReportUtils.isPayer(session, iouReport as OnyxEntry)); + const shouldShowPayElsewhereOption = !isPaidGroupPolicy || policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; const paymentButtonOptions = useMemo(() => { const buttonOptions = []; const isExpenseReport = ReportUtils.isExpenseReport(iouReport); @@ -189,7 +188,9 @@ function SettlementButton({ if (isExpenseReport && shouldShowPaywithExpensifyOption) { buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.VBBA]); } - buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.ELSEWHERE]); + if (shouldShowPayElsewhereOption) { + buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.ELSEWHERE]); + } if (shouldShowApproveButton) { buttonOptions.push(approveButtonOption); diff --git a/src/components/VideoPlayer/IconButton.js b/src/components/VideoPlayer/IconButton.js index 71c1a2150692..5af9bc87e66f 100644 --- a/src/components/VideoPlayer/IconButton.js +++ b/src/components/VideoPlayer/IconButton.js @@ -1,8 +1,7 @@ import PropTypes from 'prop-types'; import React from 'react'; -import Hoverable from '@components/Hoverable'; import Icon from '@components/Icon'; -import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import Tooltip from '@components/Tooltip'; import useThemeStyles from '@hooks/useThemeStyles'; import stylePropTypes from '@styles/stylePropTypes'; @@ -44,21 +43,18 @@ function IconButton({src, fill, onPress, style, hoverStyle, tooltipText, small, text={tooltipText} shouldForceRenderingBelow={shouldForceRenderingTooltipBelow} > - - {(isHovered) => ( - - - - )} - + + + ); } diff --git a/src/components/VideoPlayerPreview/index.tsx b/src/components/VideoPlayerPreview/index.tsx index a1e9568cc2ad..28415fa52837 100644 --- a/src/components/VideoPlayerPreview/index.tsx +++ b/src/components/VideoPlayerPreview/index.tsx @@ -7,6 +7,8 @@ import VideoPlayer from '@components/VideoPlayer'; import IconButton from '@components/VideoPlayer/IconButton'; import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext'; import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useThumbnailDimensions from '@hooks/useThumbnailDimensions'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -39,6 +41,8 @@ type VideoPlayerPreviewProps = { function VideoPlayerPreview({videoUrl, thumbnailUrl, fileName, videoDimensions, videoDuration, onShowModalPress}: VideoPlayerPreviewProps) { const styles = useThemeStyles(); + const theme = useTheme(); + const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const {currentlyPlayingURL, updateCurrentlyPlayingURL} = usePlaybackContext(); const {isSmallScreenWidth} = useWindowDimensions(); @@ -84,14 +88,16 @@ function VideoPlayerPreview({videoUrl, thumbnailUrl, fileName, videoDimensions, shouldUseSmallVideoControls style={[styles.w100, styles.h100]} /> - - + + + )} diff --git a/src/libs/API/parameters/EnablePolicyTaxesParams.ts b/src/libs/API/parameters/EnablePolicyTaxesParams.ts index 4a235d5d6a1f..5517bd442231 100644 --- a/src/libs/API/parameters/EnablePolicyTaxesParams.ts +++ b/src/libs/API/parameters/EnablePolicyTaxesParams.ts @@ -1,6 +1,7 @@ type EnablePolicyTaxesParams = { policyID: string; enabled: boolean; + taxFields?: string; }; export default EnablePolicyTaxesParams; diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index 985499078c9a..9a7e0a568627 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -124,7 +124,8 @@ function getDistanceMerchant( const formattedDistance = getDistanceForDisplay(hasRoute, distanceInMeters, unit, rate, translate); const singularDistanceUnit = unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.mile') : translate('common.kilometer'); - const ratePerUnit = PolicyUtils.getUnitRateValue({rate}, toLocaleDigit); + const ratePerUnit = PolicyUtils.getUnitRateValue(toLocaleDigit, {rate}); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const currencySymbol = CurrencyUtils.getCurrencySymbol(currency) || `${currency} `; diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index fad823a8fded..6c8070566d59 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -5,9 +5,9 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {PolicyTagList, Report, ReportAction} from '@src/types/onyx'; import * as CurrencyUtils from './CurrencyUtils'; import DateUtils from './DateUtils'; +import getReportPolicyID from './getReportPolicyID'; import * as Localize from './Localize'; import * as PolicyUtils from './PolicyUtils'; -import * as ReportUtils from './ReportUtils'; import type {ExpenseOriginalMessage} from './ReportUtils'; import * as TransactionUtils from './TransactionUtils'; @@ -112,12 +112,12 @@ function getForDistanceRequest(newDistance: string, oldDistance: string, newAmou * ModifiedExpense::getNewDotComment in Web-Expensify should match this. * If we change this function be sure to update the backend as well. */ -function getForReportAction(reportID: string | undefined, reportAction: OnyxEntry): string { +function getForReportAction(reportID: string | undefined, reportAction: OnyxEntry | ReportAction | Record): string { if (reportAction?.actionName !== CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE) { return ''; } const reportActionOriginalMessage = reportAction?.originalMessage as ExpenseOriginalMessage | undefined; - const policyID = ReportUtils.getReportPolicyID(reportID) ?? ''; + const policyID = getReportPolicyID(reportID) ?? ''; const removalFragments: string[] = []; const setFragments: string[] = []; diff --git a/src/libs/PolicyDistanceRatesUtils.ts b/src/libs/PolicyDistanceRatesUtils.ts index cdcfb13eeb72..edc5e51ceeb9 100644 --- a/src/libs/PolicyDistanceRatesUtils.ts +++ b/src/libs/PolicyDistanceRatesUtils.ts @@ -1,5 +1,7 @@ import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import CONST from '@src/CONST'; import type ONYXKEYS from '@src/ONYXKEYS'; +import type {Rate} from '@src/types/onyx/Policy'; import * as CurrencyUtils from './CurrencyUtils'; import getPermittedDecimalSeparator from './getPermittedDecimalSeparator'; import * as MoneyRequestUtils from './MoneyRequestUtils'; @@ -22,4 +24,13 @@ function validateRateValue(values: FormOnyxValues, currency: stri return errors; } -export default validateRateValue; +/** + * Get the optimistic rate name in a way that matches BE logic + * @param rates + */ +function getOptimisticRateName(rates: Record): string { + const existingRatesWithSameName = Object.values(rates ?? {}).filter((rate) => (rate.name ?? '').startsWith(CONST.CUSTOM_UNITS.DEFAULT_RATE)); + return existingRatesWithSameName.length ? `${CONST.CUSTOM_UNITS.DEFAULT_RATE} ${existingRatesWithSameName.length}` : CONST.CUSTOM_UNITS.DEFAULT_RATE; +} + +export {validateRateValue, getOptimisticRateName}; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 30c9ed935f7b..37439de5a6c3 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -5,13 +5,12 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {PersonalDetailsList, Policy, PolicyCategories, PolicyMembers, PolicyTagList, PolicyTags, TaxRate} from '@src/types/onyx'; -import type {PolicyFeatureName} from '@src/types/onyx/Policy'; +import type {PolicyFeatureName, Rate} from '@src/types/onyx/Policy'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import Navigation from './Navigation/Navigation'; type MemberEmailsToAccountIDs = Record; -type UnitRate = {rate: number}; /** * Filter out the active policies, which will exclude policies with pending deletion @@ -66,7 +65,7 @@ function hasCustomUnitsError(policy: OnyxEntry): boolean { return Object.keys(policy?.customUnits?.errors ?? {}).length > 0; } -function getNumericValue(value: number, toLocaleDigit: (arg: string) => string): number | string { +function getNumericValue(value: number | string, toLocaleDigit: (arg: string) => string): number | string { const numValue = parseFloat(value.toString().replace(toLocaleDigit('.'), '.')); if (Number.isNaN(numValue)) { return NaN; @@ -82,7 +81,7 @@ function getRateDisplayValue(value: number, toLocaleDigit: (arg: string) => stri return numValue.toString().replace('.', toLocaleDigit('.')).substring(0, value.toString().length); } -function getUnitRateValue(customUnitRate: UnitRate, toLocaleDigit: (arg: string) => string) { +function getUnitRateValue(toLocaleDigit: (arg: string) => string, customUnitRate?: Rate) { return getRateDisplayValue((customUnitRate?.rate ?? 0) / CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET, toLocaleDigit); } @@ -244,7 +243,7 @@ function isTaxPolicyEnabled(isPolicyExpenseChat: boolean, policy: OnyxEntry | EmptyObject): boolean { - return policy?.autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT || policy?.type === CONST.POLICY.TYPE.FREE; + return policy?.type === CONST.POLICY.TYPE.FREE || (policy?.autoReporting === true && policy?.autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT); } /** diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 05701c3e321f..64bac4f46b5a 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -135,7 +135,7 @@ function isReportPreviewAction(reportAction: OnyxEntry): boolean { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW; } -function isModifiedExpenseAction(reportAction: OnyxEntry): boolean { +function isModifiedExpenseAction(reportAction: OnyxEntry | ReportAction | Record): boolean { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.MODIFIEDEXPENSE; } @@ -269,7 +269,9 @@ function getContinuousReportActionChain(sortedReportActions: ReportAction[], id? } if (index === -1) { - return []; + // if no non-pending action is found, that means all actions on the report are optimistic + // in this case, we'll assume the whole chain of reportActions is continuous and return it in its entirety + return id ? [] : sortedReportActions; } let startIndex = index; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 6bbc2d2fdb0b..b655d751ddf7 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -55,11 +55,13 @@ import * as store from './actions/ReimbursementAccount/store'; import * as CollectionUtils from './CollectionUtils'; import * as CurrencyUtils from './CurrencyUtils'; import DateUtils from './DateUtils'; +import originalGetReportPolicyID from './getReportPolicyID'; import isReportMessageAttachment from './isReportMessageAttachment'; import localeCompare from './LocaleCompare'; import * as LocalePhoneNumber from './LocalePhoneNumber'; import * as Localize from './Localize'; import {isEmailPublicDomain} from './LoginUtils'; +import ModifiedExpenseMessage from './ModifiedExpenseMessage'; import linkingConfig from './Navigation/linkingConfig'; import Navigation from './Navigation/Navigation'; import * as NumberUtils from './NumberUtils'; @@ -1292,7 +1294,7 @@ function isPayer(session: OnyxEntry, iouReport: OnyxEntry) { if (isPaidGroupPolicy(iouReport)) { if (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES) { const isReimburser = session?.email === policy?.reimburserEmail; - return isReimburser && (isApproved || isManager); + return (!policy?.reimburserEmail || isReimburser) && (isApproved || isManager); } if (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL) { return isAdmin && (isApproved || isManager); @@ -2795,6 +2797,9 @@ function getReportName(report: OnyxEntry, policy: OnyxEntry = nu if (parentReportActionMessage && isArchivedRoom(report)) { return `${parentReportActionMessage} (${Localize.translateLocal('common.archived')})`; } + if (ReportActionsUtils.isModifiedExpenseAction(parentReportAction)) { + return ModifiedExpenseMessage.getForReportAction(report?.reportID, parentReportAction); + } return parentReportActionMessage; } @@ -4121,13 +4126,13 @@ function buildOptimisticTaskReport( * @param reportAction - the parent IOU report action from which to create the thread * @param moneyRequestReport - the report which the report action belongs to */ -function buildTransactionThread(reportAction: OnyxEntry, moneyRequestReport: Report): OptimisticChatReport { +function buildTransactionThread(reportAction: OnyxEntry, moneyRequestReport: OnyxEntry): OptimisticChatReport { const participantAccountIDs = [...new Set([currentUserAccountID, Number(reportAction?.actorAccountID)])].filter(Boolean) as number[]; return buildOptimisticChatReport( participantAccountIDs, getTransactionReportName(reportAction), undefined, - moneyRequestReport.policyID, + moneyRequestReport?.policyID, CONST.POLICY.OWNER_ACCOUNT_ID_FAKE, false, '', @@ -4135,7 +4140,7 @@ function buildTransactionThread(reportAction: OnyxEntry): boolean { * - The action is listed in the thread-disabled list * - The action is a split bill action * - The action is deleted and is not threaded + * - The report is archived and the action is not threaded * - The action is a whisper action and it's neither a report preview nor IOU action * - The action is the thread's first chat */ @@ -5374,11 +5380,13 @@ function shouldDisableThread(reportAction: OnyxEntry, reportID: st const isReportPreviewAction = ReportActionsUtils.isReportPreviewAction(reportAction); const isIOUAction = ReportActionsUtils.isMoneyRequestAction(reportAction); const isWhisperAction = ReportActionsUtils.isWhisperAction(reportAction); + const isArchivedReport = isArchivedRoom(getReport(reportID)); return ( CONST.REPORT.ACTIONS.THREAD_DISABLED.some((action: string) => action === reportAction?.actionName) || isSplitBillAction || (isDeletedAction && !reportAction?.childVisibleActionCount) || + (isArchivedReport && !reportAction?.childVisibleActionCount) || (isWhisperAction && !isReportPreviewAction && !isIOUAction) || isThreadFirstChat(reportAction, reportID) ); diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index a789b3332c4c..17d9a20e4bb1 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -4517,30 +4517,6 @@ function sendMoneyWithWallet(report: OnyxTypes.Report, amount: number, currency: Report.notifyNewAction(params.chatReportID, managerID); } -function canIOUBePaid(iouReport: OnyxEntry | EmptyObject, chatReport: OnyxEntry | EmptyObject, policy: OnyxEntry | EmptyObject) { - const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport); - const iouCanceled = ReportUtils.isArchivedRoom(chatReport); - - if (isEmptyObject(iouReport)) { - return false; - } - - const isPayer = ReportUtils.isPayer( - { - email: currentUserEmail, - accountID: userAccountID, - }, - iouReport, - ); - - const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport); - const iouSettled = ReportUtils.isSettled(iouReport?.reportID); - - const {reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(iouReport); - const isAutoReimbursable = ReportUtils.canBeAutoReimbursed(iouReport, policy); - return isPayer && !isOpenExpenseReport && !iouSettled && !iouReport?.isWaitingOnBankAccount && reimbursableSpend !== 0 && !iouCanceled && !isAutoReimbursable; -} - function canApproveIOU(iouReport: OnyxEntry | EmptyObject, chatReport: OnyxEntry | EmptyObject, policy: OnyxEntry | EmptyObject) { if (isEmptyObject(chatReport)) { return false; @@ -4567,6 +4543,36 @@ function canApproveIOU(iouReport: OnyxEntry | EmptyObject, cha return isCurrentUserManager && !isOpenExpenseReport && !isApproved && !iouSettled; } +function canIOUBePaid(iouReport: OnyxEntry | EmptyObject, chatReport: OnyxEntry | EmptyObject, policy: OnyxEntry | EmptyObject) { + const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(chatReport); + const iouCanceled = ReportUtils.isArchivedRoom(chatReport); + + if (isEmptyObject(iouReport)) { + return false; + } + + if (policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO) { + return false; + } + + const isPayer = ReportUtils.isPayer( + { + email: currentUserEmail, + accountID: userAccountID, + }, + iouReport, + ); + + const isOpenExpenseReport = isPolicyExpenseChat && ReportUtils.isOpenExpenseReport(iouReport); + const iouSettled = ReportUtils.isSettled(iouReport?.reportID); + + const {reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(iouReport); + const isAutoReimbursable = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES ? false : ReportUtils.canBeAutoReimbursed(iouReport, policy); + const shouldBeApproved = canApproveIOU(iouReport, chatReport, policy); + + return isPayer && !isOpenExpenseReport && !iouSettled && !iouReport?.isWaitingOnBankAccount && reimbursableSpend !== 0 && !iouCanceled && !isAutoReimbursable && !shouldBeApproved; +} + function hasIOUToApproveOrPay(chatReport: OnyxEntry | EmptyObject, excludedIOUReportID: string): boolean { const chatReportActions = ReportActionsUtils.getAllReportActions(chatReport?.reportID ?? ''); diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index ae95424f5776..c83c946aed46 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -10,6 +10,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; +import * as Session from './Session'; let isNetworkOffline = false; Onyx.connect({ @@ -102,6 +103,10 @@ function openLink(href: string, environmentURL: string, isAttachment = false) { // If we are handling a New Expensify link then we will assume this should be opened by the app internally. This ensures that the links are opened internally via react-navigation // instead of in a new tab or with a page refresh (which is the default behavior of an anchor tag) if (internalNewExpensifyPath && hasSameOrigin) { + if (Session.isAnonymousUser() && !Session.canAnonymousUserAccessRoute(internalNewExpensifyPath)) { + Session.signOutAndRedirectToSignIn(); + return; + } Navigation.navigate(internalNewExpensifyPath as Route); return; } diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 580898a2f869..ef5a09483c67 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -83,6 +83,7 @@ import type { ReimbursementAccount, Report, ReportAction, + TaxRatesWithDefault, Transaction, } from '@src/types/onyx'; import type {ErrorFields, Errors, OnyxValueWithOfflineFeedback, PendingAction} from '@src/types/onyx/OnyxCommon'; @@ -3669,6 +3670,62 @@ function enablePolicyTags(policyID: string, enabled: boolean) { } function enablePolicyTaxes(policyID: string, enabled: boolean) { + const defaultTaxRates: TaxRatesWithDefault = CONST.DEFAULT_TAX; + const taxRatesData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + ...defaultTaxRates, + taxes: { + ...Object.keys(defaultTaxRates.taxes).reduce( + (prevTaxesData, taxKey) => ({ + ...prevTaxesData, + [taxKey]: { + ...defaultTaxRates.taxes[taxKey], + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }), + {}, + ), + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + ...Object.keys(defaultTaxRates.taxes).reduce( + (prevTaxesData, taxKey) => ({ + ...prevTaxesData, + [taxKey]: {pendingAction: null}, + }), + {}, + ), + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: undefined, + }, + }, + ], + }; + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const shouldAddDefaultTaxRatesData = (!policy?.taxRates || isEmptyObject(policy.taxRates)) && enabled; const onyxData: OnyxData = { optimisticData: [ { @@ -3683,6 +3740,7 @@ function enablePolicyTaxes(policyID: string, enabled: boolean) { }, }, }, + ...(shouldAddDefaultTaxRatesData ? taxRatesData.optimisticData ?? [] : []), ], successData: [ { @@ -3694,6 +3752,7 @@ function enablePolicyTaxes(policyID: string, enabled: boolean) { }, }, }, + ...(shouldAddDefaultTaxRatesData ? taxRatesData.successData ?? [] : []), ], failureData: [ { @@ -3708,11 +3767,14 @@ function enablePolicyTaxes(policyID: string, enabled: boolean) { }, }, }, + ...(shouldAddDefaultTaxRatesData ? taxRatesData.failureData ?? [] : []), ], }; const parameters: EnablePolicyTaxesParams = {policyID, enabled}; - + if (shouldAddDefaultTaxRatesData) { + parameters.taxFields = JSON.stringify(defaultTaxRates); + } API.write(WRITE_COMMANDS.ENABLE_POLICY_TAXES, parameters, onyxData); if (enabled) { @@ -4623,3 +4685,5 @@ export { setPolicyDistanceRatesEnabled, deletePolicyDistanceRates, }; + +export type {NewCustomUnit}; diff --git a/src/libs/actions/ReportActions.ts b/src/libs/actions/ReportActions.ts index aad6ae39810a..72d1fe9fe63d 100644 --- a/src/libs/actions/ReportActions.ts +++ b/src/libs/actions/ReportActions.ts @@ -1,4 +1,5 @@ import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import * as ReportActionUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -6,10 +7,10 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type ReportAction from '@src/types/onyx/ReportAction'; import * as Report from './Report'; -function clearReportActionErrors(reportID: string, reportAction: ReportAction) { +function clearReportActionErrors(reportID: string, reportAction: OnyxEntry) { const originalReportID = ReportUtils.getOriginalReportID(reportID, reportAction); - if (!reportAction.reportActionID) { + if (!reportAction?.reportActionID) { return; } diff --git a/src/libs/getReportPolicyID.ts b/src/libs/getReportPolicyID.ts new file mode 100644 index 000000000000..12124f24fbe7 --- /dev/null +++ b/src/libs/getReportPolicyID.ts @@ -0,0 +1,33 @@ +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; +import type {EmptyObject} from '@src/types/utils/EmptyObject'; + +let allReports: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (value) => (allReports = value), +}); + +/** + * Get the report given a reportID + */ +function getReport(reportID: string | undefined): OnyxEntry | EmptyObject { + if (!allReports) { + return {}; + } + + return allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? {}; +} + +/** + * Get the report policyID given a reportID. + * We need to define this method in a separate file to avoid cyclic dependency. + */ +function getReportPolicyID(reportID?: string): string | undefined { + return getReport(reportID)?.policyID; +} + +export default getReportPolicyID; diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 242602b0654c..9bc0c4c3a4a8 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -234,7 +234,7 @@ function ReportScreen({ const prevReport = usePrevious(report); const prevUserLeavingStatus = usePrevious(userLeavingStatus); - const [isLinkingToMessage, setLinkingToMessage] = useState(!!reportActionIDFromRoute); + const [isLinkingToMessage, setIsLinkingToMessage] = useState(!!reportActionIDFromRoute); const reportActions = useMemo(() => { if (!sortedAllReportActions.length) { return []; @@ -243,9 +243,11 @@ function ReportScreen({ return currentRangeOfReportActions; }, [reportActionIDFromRoute, sortedAllReportActions]); - // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. If we have cached reportActions, they will be shown immediately. We aim to display a loader first, then fetch relevant reportActions, and finally show them. + // Define here because reportActions are recalculated before mount, allowing data to display faster than useEffect can trigger. + // If we have cached reportActions, they will be shown immediately. + // We aim to display a loader first, then fetch relevant reportActions, and finally show them. useLayoutEffect(() => { - setLinkingToMessage(!!reportActionIDFromRoute); + setIsLinkingToMessage(!!reportActionIDFromRoute); }, [route, reportActionIDFromRoute]); const [isBannerVisible, setIsBannerVisible] = useState(true); @@ -261,15 +263,12 @@ function ReportScreen({ const {reportPendingAction, reportErrors} = ReportUtils.getReportOfflinePendingActionAndErrors(report); const screenWrapperStyle: ViewStyle[] = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}]; const isEmptyChat = useMemo((): boolean => reportActions.length === 0, [reportActions]); - // There are no reportActions at all to display and we are still in the process of loading the next set of actions. - const isLoadingInitialReportActions = reportActions.length === 0 && !!reportMetadata?.isLoadingInitialReportActions; const isOptimisticDelete = report.statusNum === CONST.REPORT.STATUS_NUM.CLOSED; // If there's a non-404 error for the report we should show it instead of blocking the screen const hasHelpfulErrors = Object.keys(report?.errorFields ?? {}).some((key) => key !== 'notFound'); const shouldHideReport = !hasHelpfulErrors && !ReportUtils.canAccessReport(report, policies, betas); - const isLoading = !reportIDFromRoute || !isSidebarLoaded || PersonalDetailsUtils.isPersonalDetailsEmpty(); const lastReportAction: OnyxEntry = useMemo( () => reportActions.length @@ -325,16 +324,34 @@ function ReportScreen({ /** * When false the ReportActionsView will completely unmount and we will show a loader until it returns true. */ - const isReportReadyForDisplay = useMemo((): boolean => { + const isCurrentReportLoadedFromOnyx = useMemo((): boolean => { // This is necessary so that when we are retrieving the next report data from Onyx the ReportActionsView will remount completely const isTransitioning = report && report.reportID !== reportIDFromRoute; return reportIDFromRoute !== '' && !!report.reportID && !isTransitioning; }, [report, reportIDFromRoute]); + const isLoading = !ReportUtils.isValidReportIDFromPath(reportIDFromRoute) || !isSidebarLoaded || PersonalDetailsUtils.isPersonalDetailsEmpty(); const shouldShowSkeleton = - isLinkingToMessage || !isReportReadyForDisplay || isLoadingInitialReportActions || isLoading || (!!reportActionIDFromRoute && reportMetadata?.isLoadingInitialReportActions); - - const shouldShowReportActionList = isReportReadyForDisplay && !isLoading; + isLinkingToMessage || + !isCurrentReportLoadedFromOnyx || + (reportActions.length === 0 && !!reportMetadata?.isLoadingInitialReportActions) || + isLoading || + (!!reportActionIDFromRoute && reportMetadata?.isLoadingInitialReportActions); + const shouldShowReportActionList = isCurrentReportLoadedFromOnyx && !isLoading; + // eslint-disable-next-line rulesdir/no-negated-variables + const shouldShowNotFoundPage = useMemo( + (): boolean => + !shouldShowSkeleton && + ((!wasReportAccessibleRef.current && + !firstRenderRef.current && + !report.reportID && + !isOptimisticDelete && + !reportMetadata?.isLoadingInitialReportActions && + !userLeavingStatus) || + shouldHideReport || + (!!reportIDFromRoute && !ReportUtils.isValidReportIDFromPath(reportIDFromRoute))), + [shouldShowSkeleton, report.reportID, isOptimisticDelete, reportMetadata?.isLoadingInitialReportActions, userLeavingStatus, shouldHideReport, reportIDFromRoute], + ); const fetchReport = useCallback(() => { Report.openReport(reportIDFromRoute, reportActionIDFromRoute); @@ -399,7 +416,7 @@ function ReportScreen({ }); return () => { interactionTask.cancel(); - if (!didSubscribeToReportLeavingEvents) { + if (!didSubscribeToReportLeavingEvents.current) { return; } @@ -490,6 +507,10 @@ function ReportScreen({ if (!ReportUtils.isValidReportIDFromPath(reportIDFromRoute)) { return; } + // Ensures the optimistic report is created successfully + if (reportIDFromRoute !== report.reportID) { + return; + } // Ensures subscription event succeeds when the report/workspace room is created optimistically. // Check if the optimistic `OpenReport` or `AddWorkspaceRoom` has succeeded by confirming // any `pendingFields.createChat` or `pendingFields.addWorkspaceRoom` fields are set to null. @@ -520,34 +541,19 @@ function ReportScreen({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // eslint-disable-next-line rulesdir/no-negated-variables - const shouldShowNotFoundPage = useMemo( - (): boolean => - (!wasReportAccessibleRef.current && - !firstRenderRef.current && - !report.reportID && - !isOptimisticDelete && - !reportMetadata?.isLoadingInitialReportActions && - !isLoading && - !userLeavingStatus) || - shouldHideReport || - (!!reportIDFromRoute && !ReportUtils.isValidReportIDFromPath(reportIDFromRoute)), - [report, reportMetadata, isLoading, shouldHideReport, isOptimisticDelete, userLeavingStatus, reportIDFromRoute], - ); - const actionListValue = useMemo((): ActionListContextType => ({flatListRef, scrollPosition, setScrollPosition}), [flatListRef, scrollPosition, setScrollPosition]); // This helps in tracking from the moment 'route' triggers useMemo until isLoadingInitialReportActions becomes true. It prevents blinking when loading reportActions from cache. useEffect(() => { InteractionManager.runAfterInteractions(() => { - setLinkingToMessage(false); + setIsLinkingToMessage(false); }); }, [reportMetadata?.isLoadingInitialReportActions]); - const onLinkPress = () => { + const navigateToEndOfReport = useCallback(() => { Navigation.setParams({reportActionID: ''}); fetchReport(); - }; + }, [fetchReport]); const isLinkedReportActionDeleted = useMemo(() => { if (!reportActionIDFromRoute || !sortedAllReportActions) { @@ -566,7 +572,7 @@ function ReportScreen({ title={translate('notFound.notHere')} shouldShowLink linkKey="notFound.noAccess" - onLinkPress={onLinkPress} + onLinkPress={navigateToEndOfReport} /> ); } @@ -614,7 +620,7 @@ function ReportScreen({ shouldShowCloseButton /> )} - + } - {isReportReadyForDisplay ? ( + {isCurrentReportLoadedFromOnyx ? ( + @@ -707,7 +707,7 @@ function ReportActionItem({ if (ReportUtils.isTaskReport(report)) { if (ReportUtils.isCanceledTaskReport(report, parentReportAction)) { return ( - + @@ -725,7 +725,7 @@ function ReportActionItem({ ); } return ( - + { if (draftMessage === action?.message?.[0].html) { @@ -124,11 +126,11 @@ function ReportActionItemMessageEdit( const draftRef = useRef(draft); useEffect(() => { - if (ReportActionsUtils.isDeletedAction(action) || (action.message && draftMessage === action.message[0].html)) { + if (ReportActionsUtils.isDeletedAction(action) || Boolean(action.message && draftMessage === action.message[0].html) || Boolean(prevDraftMessage === draftMessage)) { return; } setDraft(Str.htmlDecode(draftMessage)); - }, [draftMessage, action]); + }, [draftMessage, action, prevDraftMessage]); useEffect(() => { // required for keeping last state of isFocused variable diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index c74bb40a18b6..68cd069608da 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -264,6 +264,10 @@ function ReportActionsView({ }, [isSmallScreenWidth, reportActions, isReportFullyVisible]); useEffect(() => { + // Ensures the optimistic report is created successfully + if (route?.params?.reportID !== reportID) { + return; + } // Ensures subscription event succeeds when the report/workspace room is created optimistically. // Check if the optimistic `OpenReport` or `AddWorkspaceRoom` has succeeded by confirming // any `pendingFields.createChat` or `pendingFields.addWorkspaceRoom` fields are set to null. @@ -278,7 +282,7 @@ function ReportActionsView({ interactionTask.cancel(); }; } - }, [report.pendingFields, didSubscribeToReportTypingEvents, reportID]); + }, [report.pendingFields, didSubscribeToReportTypingEvents, route, reportID]); const onContentSizeChange = useCallback((w: number, h: number) => { contentListHeight.current = h; diff --git a/src/pages/workspace/WorkspaceProfilePage.tsx b/src/pages/workspace/WorkspaceProfilePage.tsx index d8b407d5cee9..662335d0b358 100644 --- a/src/pages/workspace/WorkspaceProfilePage.tsx +++ b/src/pages/workspace/WorkspaceProfilePage.tsx @@ -12,7 +12,6 @@ import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; import Text from '@components/Text'; import useActiveWorkspace from '@hooks/useActiveWorkspace'; @@ -119,133 +118,131 @@ function WorkspaceProfilePage({policy, currencyList = {}, route}: WorkSpaceProfi icon={Illustrations.House} > {(hasVBA?: boolean) => ( - - -
- - Navigation.navigate(ROUTES.WORKSPACE_AVATAR.getRoute(policy?.id ?? ''))} - source={policy?.avatar ?? ''} - size={CONST.AVATAR_SIZE.XLARGE} - avatarStyle={styles.avatarXLarge} - enablePreview - DefaultAvatar={DefaultAvatar} - type={CONST.ICON_TYPE_WORKSPACE} - fallbackIcon={Expensicons.FallbackWorkspaceAvatar} - style={[ - policy?.errorFields?.avatar ?? isSmallScreenWidth ? styles.mb1 : styles.mb3, - isSmallScreenWidth ? styles.mtn17 : styles.mtn20, - styles.alignItemsStart, - styles.sectionMenuItemTopDescription, - ]} - editIconStyle={styles.smallEditIconWorkspace} - isUsingDefaultAvatar={!policy?.avatar ?? null} - onImageSelected={(file) => Policy.updateWorkspaceAvatar(policy?.id ?? '', file as File)} - onImageRemoved={() => Policy.deleteWorkspaceAvatar(policy?.id ?? '')} - editorMaskImage={Expensicons.ImageCropSquareMask} - pendingAction={policy?.pendingFields?.avatar} - errors={policy?.errorFields?.avatar} - onErrorClose={() => Policy.clearAvatarErrors(policy?.id ?? '')} - previewSource={UserUtils.getFullSizeAvatar(policy?.avatar ?? '')} - headerTitle={translate('workspace.common.workspaceAvatar')} - originalFileName={policy?.originalFileName} + +
+ + Navigation.navigate(ROUTES.WORKSPACE_AVATAR.getRoute(policy?.id ?? ''))} + source={policy?.avatar ?? ''} + size={CONST.AVATAR_SIZE.XLARGE} + avatarStyle={styles.avatarXLarge} + enablePreview + DefaultAvatar={DefaultAvatar} + type={CONST.ICON_TYPE_WORKSPACE} + fallbackIcon={Expensicons.FallbackWorkspaceAvatar} + style={[ + policy?.errorFields?.avatar ?? isSmallScreenWidth ? styles.mb1 : styles.mb3, + isSmallScreenWidth ? styles.mtn17 : styles.mtn20, + styles.alignItemsStart, + styles.sectionMenuItemTopDescription, + ]} + editIconStyle={styles.smallEditIconWorkspace} + isUsingDefaultAvatar={!policy?.avatar ?? null} + onImageSelected={(file) => Policy.updateWorkspaceAvatar(policy?.id ?? '', file as File)} + onImageRemoved={() => Policy.deleteWorkspaceAvatar(policy?.id ?? '')} + editorMaskImage={Expensicons.ImageCropSquareMask} + pendingAction={policy?.pendingFields?.avatar} + errors={policy?.errorFields?.avatar} + onErrorClose={() => Policy.clearAvatarErrors(policy?.id ?? '')} + previewSource={UserUtils.getFullSizeAvatar(policy?.avatar ?? '')} + headerTitle={translate('workspace.common.workspaceAvatar')} + originalFileName={policy?.originalFileName} + disabled={readOnly} + disabledStyle={styles.cursorDefault} + errorRowStyles={styles.mt3} + /> + + - + + {(!StringUtils.isEmptyString(policy?.description ?? '') || !readOnly) && ( + Policy.clearPolicyErrorField(policy?.id ?? '', CONST.POLICY.COLLECTION_KEYS.DESCRIPTION)} + > - {(!StringUtils.isEmptyString(policy?.description ?? '') || !readOnly) && ( - Policy.clearPolicyErrorField(policy?.id ?? '', CONST.POLICY.COLLECTION_KEYS.DESCRIPTION)} - > - - - )} - Policy.clearPolicyErrorField(policy?.id ?? '', CONST.POLICY.COLLECTION_KEYS.GENERAL_SETTINGS)} - errorRowStyles={[styles.mt2]} - > - - - - {hasVBA ? translate('workspace.editor.currencyInputDisabledText') : translate('workspace.editor.currencyInputHelpText')} - - - - {!readOnly && ( - -
- setIsDeleteModalOpen(false)} - prompt={translate('workspace.common.deleteConfirmation')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - /> -
- + )} + Policy.clearPolicyErrorField(policy?.id ?? '', CONST.POLICY.COLLECTION_KEYS.GENERAL_SETTINGS)} + errorRowStyles={[styles.mt2]} + > + + + + {hasVBA ? translate('workspace.editor.currencyInputDisabledText') : translate('workspace.editor.currencyInputHelpText')} + + + + {!readOnly && ( + +
+ setIsDeleteModalOpen(false)} + prompt={translate('workspace.common.deleteConfirmation')} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger + /> +
)} ); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index f3456c3875f5..46afcd624350 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -273,6 +273,7 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat style={[styles.defaultModalContainer]} testID={WorkspaceCategoriesPage.displayName} shouldShowOfflineIndicatorInWideScreen + offlineIndicatorStyle={styles.mtAuto} > )} - {!shouldShowEmptyState && ( + {!shouldShowEmptyState && !isLoading && ( ) => { const newRate: Rate = { currency, - name: CONST.CUSTOM_UNITS.DEFAULT_RATE, + name: getOptimisticRateName(customUnits[customUnitID]?.rates), rate: parseFloat(values.rate) * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET, customUnitRateID, enabled: true, @@ -70,6 +70,7 @@ function CreateDistanceRatePage({policy, route}: CreateDistanceRatePageProps) { includeSafeAreaPaddingBottom={false} style={[styles.defaultModalContainer]} testID={CreateDistanceRatePage.displayName} + shouldEnableMaxHeight > { - if (selectedDistanceRates.length === Object.values(customUnitRates).length) { + const allSelectableRates = Object.values(customUnitRates).filter((rate) => rate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + + if (selectedDistanceRates.length === allSelectableRates.length) { setSelectedDistanceRates([]); } else { - setSelectedDistanceRates([...Object.values(customUnitRates).filter((rate) => rate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)]); + setSelectedDistanceRates([...allSelectableRates]); } }; diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.tsx new file mode 100644 index 000000000000..8fef5f4dc6f9 --- /dev/null +++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage.tsx @@ -0,0 +1,158 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useEffect} from 'react'; +import {Keyboard, View} from 'react-native'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormOnyxValues} from '@components/Form/types'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import Picker from '@components/Picker'; +import TextInput from '@components/TextInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import getPermittedDecimalSeparator from '@libs/getPermittedDecimalSeparator'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as NumberUtils from '@libs/NumberUtils'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import withPolicy from '@pages/workspace/withPolicy'; +import type {WithPolicyProps} from '@pages/workspace/withPolicy'; +import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; +import * as BankAccounts from '@userActions/BankAccounts'; +import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {Unit} from '@src/types/onyx/Policy'; + +type WorkspaceRateAndUnitPageProps = WithPolicyProps & StackScreenProps; + +type ValidationError = {rate?: TranslationPaths | undefined}; + +function WorkspaceRateAndUnitPage({policy, route}: WorkspaceRateAndUnitPageProps) { + const {translate, toLocaleDigit} = useLocalize(); + const styles = useThemeStyles(); + + useEffect(() => { + if ((policy?.customUnits ?? []).length !== 0) { + return; + } + + BankAccounts.setReimbursementAccountLoading(true); + Policy.openWorkspaceReimburseView(policy?.id ?? ''); + }, [policy?.customUnits, policy?.id]); + + const unitItems = [ + {label: translate('common.kilometers'), value: CONST.CUSTOM_UNITS.DISTANCE_UNIT_KILOMETERS}, + {label: translate('common.miles'), value: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + ]; + + const saveUnitAndRate = (unit: Unit, rate: string) => { + const distanceCustomUnit = Object.values(policy?.customUnits ?? {}).find((customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + if (!distanceCustomUnit) { + return; + } + const currentCustomUnitRate = Object.values(distanceCustomUnit?.rates ?? {}).find((r) => r.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); + const unitID = distanceCustomUnit.customUnitID ?? ''; + const unitName = distanceCustomUnit.name ?? ''; + const rateNumValue = PolicyUtils.getNumericValue(rate, toLocaleDigit); + + const newCustomUnit: Policy.NewCustomUnit = { + customUnitID: unitID, + name: unitName, + attributes: {unit}, + rates: { + ...currentCustomUnitRate, + rate: Number(rateNumValue) * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET, + }, + }; + + Policy.updateWorkspaceCustomUnitAndRate(policy?.id ?? '', distanceCustomUnit, newCustomUnit, policy?.lastModified); + }; + + const submit = (values: FormOnyxValues) => { + saveUnitAndRate(values.unit as Unit, values.rate); + Keyboard.dismiss(); + Navigation.goBack(ROUTES.WORKSPACE_REIMBURSE.getRoute(policy?.id ?? '')); + }; + + const validate = (values: FormOnyxValues): ValidationError => { + const errors: ValidationError = {}; + const decimalSeparator = toLocaleDigit('.'); + const outputCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD; + // Allow one more decimal place for accuracy + const rateValueRegex = RegExp(String.raw`^-?\d{0,8}([${getPermittedDecimalSeparator(decimalSeparator)}]\d{1,${CurrencyUtils.getCurrencyDecimals(outputCurrency) + 1}})?$`, 'i'); + if (!rateValueRegex.test(values.rate) || values.rate === '') { + errors.rate = 'workspace.reimburse.invalidRateError'; + } else if (NumberUtils.parseFloatAnyLocale(values.rate) <= 0) { + errors.rate = 'workspace.reimburse.lowRateError'; + } + return errors; + }; + + const distanceCustomUnit = Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + const distanceCustomRate = Object.values(distanceCustomUnit?.rates ?? {}).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); + + return ( + + {() => ( + + Policy.clearCustomUnitErrors(policy?.id ?? '', distanceCustomUnit?.customUnitID ?? '', distanceCustomRate?.customUnitRateID ?? '')} + > + + + + + + + + )} + + ); +} + +WorkspaceRateAndUnitPage.displayName = 'WorkspaceRateAndUnitPage'; + +export default withPolicy(WorkspaceRateAndUnitPage); diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx index 709e51cba383..8685cd3b1aee 100644 --- a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx +++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx @@ -9,7 +9,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; -import validateRateValue from '@libs/PolicyDistanceRatesUtils'; +import {validateRateValue} from '@libs/PolicyDistanceRatesUtils'; import withPolicy from '@pages/workspace/withPolicy'; import type {WithPolicyProps} from '@pages/workspace/withPolicy'; import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; diff --git a/src/pages/workspace/reimburse/WorkspaceReimbursePage.js b/src/pages/workspace/reimburse/WorkspaceReimbursePage.js deleted file mode 100644 index 94e72e30e4fb..000000000000 --- a/src/pages/workspace/reimburse/WorkspaceReimbursePage.js +++ /dev/null @@ -1,43 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import compose from '@libs/compose'; -import withPolicy, {policyPropTypes} from '@pages/workspace/withPolicy'; -import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; -import CONST from '@src/CONST'; -import WorkspaceReimburseView from './WorkspaceReimburseView'; - -const propTypes = { - /** The route object passed to this page from the navigator */ - route: PropTypes.shape({ - /** Each parameter passed via the URL */ - params: PropTypes.shape({ - /** The policyID that is being configured */ - policyID: PropTypes.string.isRequired, - }).isRequired, - }).isRequired, - - ...policyPropTypes, - ...withLocalizePropTypes, -}; - -function WorkspaceReimbursePage(props) { - return ( - - {() => } - - ); -} - -WorkspaceReimbursePage.propTypes = propTypes; -WorkspaceReimbursePage.displayName = 'WorkspaceReimbursePage'; - -export default compose(withPolicy, withLocalize)(WorkspaceReimbursePage); diff --git a/src/pages/workspace/reimburse/WorkspaceReimbursePage.tsx b/src/pages/workspace/reimburse/WorkspaceReimbursePage.tsx new file mode 100644 index 000000000000..3ca99147a83b --- /dev/null +++ b/src/pages/workspace/reimburse/WorkspaceReimbursePage.tsx @@ -0,0 +1,33 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import useLocalize from '@hooks/useLocalize'; +import type {WorkspacesCentralPaneNavigatorParamList} from '@libs/Navigation/types'; +import type {WithPolicyProps} from '@pages/workspace/withPolicy'; +import withPolicy from '@pages/workspace/withPolicy'; +import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; +import WorkspaceReimburseView from './WorkspaceReimburseView'; + +type WorkspaceReimbursePageProps = WithPolicyProps & StackScreenProps; + +function WorkspaceReimbursePage({route, policy}: WorkspaceReimbursePageProps) { + const {translate} = useLocalize(); + + return ( + + {() => } + + ); +} + +WorkspaceReimbursePage.displayName = 'WorkspaceReimbursePage'; + +export default withPolicy(WorkspaceReimbursePage); diff --git a/src/pages/workspace/reimburse/WorkspaceReimburseSection.js b/src/pages/workspace/reimburse/WorkspaceReimburseSection.tsx similarity index 60% rename from src/pages/workspace/reimburse/WorkspaceReimburseSection.js rename to src/pages/workspace/reimburse/WorkspaceReimburseSection.tsx index e8ae0e0958b4..18bd121b3fcb 100644 --- a/src/pages/workspace/reimburse/WorkspaceReimburseSection.js +++ b/src/pages/workspace/reimburse/WorkspaceReimburseSection.tsx @@ -1,45 +1,40 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React, {useEffect, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import ConnectBankAccountButton from '@components/ConnectBankAccountButton'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; -import networkPropTypes from '@components/networkPropTypes'; import Section from '@components/Section'; import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import usePrevious from '@hooks/usePrevious'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import BankAccount from '@libs/models/BankAccount'; -import * as ReimbursementAccountProps from '@pages/ReimbursementAccount/reimbursementAccountPropTypes'; import * as Link from '@userActions/Link'; +import type * as OnyxTypes from '@src/types/onyx'; -const propTypes = { +type WorkspaceReimburseSectionProps = { /** Policy values needed in the component */ - policy: PropTypes.shape({ - id: PropTypes.string, - }).isRequired, + policy: OnyxEntry; /** Bank account attached to free plan */ - reimbursementAccount: ReimbursementAccountProps.reimbursementAccountPropTypes.isRequired, - - /** Information about the network */ - network: networkPropTypes.isRequired, - - /** Returns translated string for given locale and phrase */ - translate: PropTypes.func.isRequired, + reimbursementAccount: OnyxEntry; }; -function WorkspaceReimburseSection(props) { +function WorkspaceReimburseSection({policy, reimbursementAccount}: WorkspaceReimburseSectionProps) { const theme = useTheme(); const styles = useThemeStyles(); + const {translate} = useLocalize(); const [shouldShowLoadingSpinner, setShouldShowLoadingSpinner] = useState(true); - const achState = lodashGet(props.reimbursementAccount, 'achData.state', ''); + const achState = reimbursementAccount?.achData?.state ?? ''; const hasVBA = achState === BankAccount.STATE.OPEN; - const reimburseReceiptsUrl = `reports?policyID=${props.policy.id}&from=all&type=expense&showStates=Archived&isAdvancedFilterMode=true`; - const isLoading = lodashGet(props.reimbursementAccount, 'isLoading', false); + const policyId = policy?.id ?? ''; + const reimburseReceiptsUrl = `reports?policyID=${policyId}&from=all&type=expense&showStates=Archived&isAdvancedFilterMode=true`; + const isLoading = reimbursementAccount?.isLoading ?? false; const prevIsLoading = usePrevious(isLoading); + const {isOffline} = useNetwork(); useEffect(() => { if (prevIsLoading === isLoading) { @@ -48,15 +43,15 @@ function WorkspaceReimburseSection(props) { setShouldShowLoadingSpinner(isLoading); }, [prevIsLoading, isLoading]); - if (props.network.isOffline) { + if (isOffline) { return (
- {`${props.translate('common.youAppearToBeOffline')} ${props.translate('common.thisFeatureRequiresInternet')}`} + {`${translate('common.youAppearToBeOffline')} ${translate('common.thisFeatureRequiresInternet')}`}
); @@ -75,12 +70,12 @@ function WorkspaceReimburseSection(props) { return hasVBA ? (
Link.openOldDotLink(reimburseReceiptsUrl), icon: Expensicons.Bank, shouldShowRightIcon: true, @@ -91,27 +86,26 @@ function WorkspaceReimburseSection(props) { ]} > - {props.translate('workspace.reimburse.fastReimbursementsVBACopy')} + {translate('workspace.reimburse.fastReimbursementsVBACopy')}
) : (
- {props.translate('workspace.reimburse.unlockNoVBACopy')} + {translate('workspace.reimburse.unlockNoVBACopy')}
); } -WorkspaceReimburseSection.propTypes = propTypes; WorkspaceReimburseSection.displayName = 'WorkspaceReimburseSection'; export default WorkspaceReimburseSection; diff --git a/src/pages/workspace/reimburse/WorkspaceReimburseView.js b/src/pages/workspace/reimburse/WorkspaceReimburseView.tsx similarity index 56% rename from src/pages/workspace/reimburse/WorkspaceReimburseView.js rename to src/pages/workspace/reimburse/WorkspaceReimburseView.tsx index 636675098d23..9c7b1c493e8d 100644 --- a/src/pages/workspace/reimburse/WorkspaceReimburseView.js +++ b/src/pages/workspace/reimburse/WorkspaceReimburseView.tsx @@ -1,87 +1,58 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx'; import CopyTextToClipboard from '@components/CopyTextToClipboard'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import networkPropTypes from '@components/networkPropTypes'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {withNetwork} from '@components/OnyxProvider'; import Section from '@components/Section'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; -import * as ReimbursementAccountProps from '@pages/ReimbursementAccount/reimbursementAccountPropTypes'; import * as BankAccounts from '@userActions/BankAccounts'; import * as Link from '@userActions/Link'; import * as Policy from '@userActions/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {Unit} from '@src/types/onyx/Policy'; import WorkspaceReimburseSection from './WorkspaceReimburseSection'; -const propTypes = { - /** Policy values needed in the component */ - policy: PropTypes.shape({ - id: PropTypes.string, - customUnits: PropTypes.objectOf( - PropTypes.shape({ - customUnitID: PropTypes.string, - name: PropTypes.string, - attributes: PropTypes.shape({ - unit: PropTypes.string, - }), - rates: PropTypes.objectOf( - PropTypes.shape({ - customUnitRateID: PropTypes.string, - name: PropTypes.string, - rate: PropTypes.number, - }), - ), - }), - ), - outputCurrency: PropTypes.string, - lastModified: PropTypes.number, - }).isRequired, - - /** From Onyx */ +type WorkspaceReimburseViewOnyxProps = { /** Bank account attached to free plan */ - reimbursementAccount: ReimbursementAccountProps.reimbursementAccountPropTypes, - - /** Information about the network */ - network: networkPropTypes.isRequired, - - ...withLocalizePropTypes, + reimbursementAccount: OnyxEntry; }; -const defaultProps = { - reimbursementAccount: ReimbursementAccountProps.reimbursementAccountDefaultProps, +type WorkspaceReimburseViewProps = WorkspaceReimburseViewOnyxProps & { + /** Policy values needed in the component */ + policy: OnyxEntry; }; -function WorkspaceReimburseView(props) { +function WorkspaceReimburseView({policy, reimbursementAccount}: WorkspaceReimburseViewProps) { const styles = useThemeStyles(); - const [currentRatePerUnit, setCurrentRatePerUnit] = useState(''); + const [currentRatePerUnit, setCurrentRatePerUnit] = useState(''); const {isSmallScreenWidth} = useWindowDimensions(); - const viewAllReceiptsUrl = `expenses?policyIDList=${props.policy.id}&billableReimbursable=reimbursable&submitterEmail=%2B%2B`; - const distanceCustomUnit = _.find(lodashGet(props.policy, 'customUnits', {}), (unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); - const distanceCustomRate = _.find(lodashGet(distanceCustomUnit, 'rates', {}), (rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); - const {translate, toLocaleDigit} = props; + const viewAllReceiptsUrl = `expenses?policyIDList=${policy?.id ?? ''}&billableReimbursable=reimbursable&submitterEmail=%2B%2B`; + const distanceCustomUnit = Object.values(policy?.customUnits ?? {}).find((unit) => unit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + const distanceCustomRate = Object.values(distanceCustomUnit?.rates ?? {}).find((rate) => rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); + const {translate, toLocaleDigit} = useLocalize(); + const {isOffline} = useNetwork(); - const getUnitLabel = useCallback((value) => translate(`common.${value}`), [translate]); + const getUnitLabel = useCallback((value: Unit): string => translate(`common.${value}`), [translate]); const getCurrentRatePerUnitLabel = useCallback(() => { - const customUnitRate = _.find(lodashGet(distanceCustomUnit, 'rates', '{}'), (rate) => rate && rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); - const currentUnit = getUnitLabel(lodashGet(distanceCustomUnit, 'attributes.unit', CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES)); - const currentRate = PolicyUtils.getUnitRateValue(customUnitRate, toLocaleDigit); + const customUnitRate = Object.values(distanceCustomUnit?.rates ?? {}).find((rate) => rate && rate.name === CONST.CUSTOM_UNITS.DEFAULT_RATE); + const currentUnit = getUnitLabel(distanceCustomUnit?.attributes.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES); + const currentRate = PolicyUtils.getUnitRateValue(toLocaleDigit, customUnitRate); const perWord = translate('common.per'); + return `${currentRate} ${perWord} ${currentUnit}`; }, [translate, distanceCustomUnit, toLocaleDigit, getUnitLabel]); @@ -90,19 +61,19 @@ function WorkspaceReimburseView(props) { // openWorkspaceReimburseView uses API.read which will not make the request until all WRITE requests in the sequential queue have finished responding, so there would be a delay in // updating Onyx with the optimistic data. BankAccounts.setReimbursementAccountLoading(true); - Policy.openWorkspaceReimburseView(props.policy.id); - }, [props.policy.id]); + Policy.openWorkspaceReimburseView(policy?.id ?? ''); + }, [policy?.id]); useEffect(() => { - if (props.network.isOffline) { + if (isOffline) { return; } fetchData(); - }, [props.network.isOffline, fetchData]); + }, [isOffline, fetchData]); useEffect(() => { setCurrentRatePerUnit(getCurrentRatePerUnitLabel()); - }, [props.policy.customUnits, getCurrentRatePerUnitLabel]); + }, [policy?.customUnits, getCurrentRatePerUnitLabel]); return ( @@ -117,7 +88,7 @@ function WorkspaceReimburseView(props) { icon: Expensicons.Receipt, shouldShowRightIcon: true, iconRight: Expensicons.NewWindow, - wrapperStyle: [styles.cardMenuItem], + wrapperStyle: styles.cardMenuItem, link: () => Link.buildOldDotURL(viewAllReceiptsUrl), }, ]} @@ -127,7 +98,7 @@ function WorkspaceReimburseView(props) { {translate('workspace.reimburse.captureNoVBACopyBeforeEmail')} {translate('workspace.reimburse.captureNoVBACopyAfterEmail')} @@ -142,7 +113,7 @@ function WorkspaceReimburseView(props) { {translate('workspace.reimburse.trackDistanceCopy')} @@ -151,36 +122,29 @@ function WorkspaceReimburseView(props) { description={translate('workspace.reimburse.trackDistanceRate')} shouldShowRightIcon onPress={() => { - Policy.setPolicyIDForReimburseView(props.policy.id); - Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy.id)); + Policy.setPolicyIDForReimburseView(policy?.id ?? ''); + Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(policy?.id ?? '')); }} wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt3]} - brickRoadIndicator={(lodashGet(distanceCustomUnit, 'errors') || lodashGet(distanceCustomRate, 'errors')) && CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR} + brickRoadIndicator={(distanceCustomUnit?.errors ?? distanceCustomRate?.errors) && CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR} /> ); } -WorkspaceReimburseView.defaultProps = defaultProps; -WorkspaceReimburseView.propTypes = propTypes; WorkspaceReimburseView.displayName = 'WorkspaceReimburseView'; -export default compose( - withLocalize, - withNetwork(), - withOnyx({ - reimbursementAccount: { - key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, - }, - }), -)(WorkspaceReimburseView); +export default withOnyx({ + // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM + reimbursementAccount: { + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + }, +})(WorkspaceReimburseView); diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index a355cc062f3d..f764f8d8e987 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -264,6 +264,7 @@ function WorkspaceTagsPage({policyTags, route}: WorkspaceTagsPageProps) { style={[styles.defaultModalContainer]} testID={WorkspaceTagsPage.displayName} shouldShowOfflineIndicatorInWideScreen + offlineIndicatorStyle={styles.mtAuto} > )} - {tagList.length > 0 && ( + {tagList.length > 0 && !isLoading && ( %} + ref={inputCallbackRef} />
diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 5bcb631c21b0..db59980a2cf4 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -1,6 +1,6 @@ import type {StackScreenProps} from '@react-navigation/stack'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {FlatList, View} from 'react-native'; +import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import ConfirmModal from '@components/ConfirmModal'; @@ -243,8 +243,11 @@ function WorkspaceWorkflowsPage({policy, betas, route, reimbursementAccount, ses session?.accountID, ]); - const renderOptionItem = ({item}: {item: ToggleSettingOptionRowProps}) => ( - + const renderOptionItem = (item: ToggleSettingOptionRowProps, index: number) => ( +
{translate('workflowsPage.workflowDescription')} - item.title} - /> + {optionItems.map(renderOptionItem)} ; /** * We use the Component Story Format for writing stories. Follow the docs here: * * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format */ -export default { +const story: ComponentMeta = { title: 'Components/AddressSearch', component: AddressSearch, args: { @@ -15,25 +20,26 @@ export default { }, }; -function Template(args) { - const [value, setValue] = useState(''); +function Template(props: AddressSearchProps) { + const [value, setValue] = useState(''); return ( setValue(street)} + value={value as string} + onInputChange={(inputValue) => setValue(inputValue)} // eslint-disable-next-line react/jsx-props-no-spreading - {...args} + {...props} /> ); } // Arguments can be passed to the component by binding // See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args -const Default = Template.bind({}); +const Default: AddressSearchStory = Template.bind({}); -const ErrorStory = Template.bind({}); +const ErrorStory: AddressSearchStory = Template.bind({}); ErrorStory.args = { errorText: 'The street you are looking for does not exist', }; +export default story; export {Default, ErrorStory}; diff --git a/src/stories/Banner.stories.js b/src/stories/Banner.stories.tsx similarity index 72% rename from src/stories/Banner.stories.js rename to src/stories/Banner.stories.tsx index 3a6f454843d1..9328e3d513ab 100644 --- a/src/stories/Banner.stories.js +++ b/src/stories/Banner.stories.tsx @@ -1,6 +1,10 @@ +import type {ComponentStory} from '@storybook/react'; import React from 'react'; +import type {BannerProps} from '@components/Banner'; import Banner from '@components/Banner'; +type BannerStory = ComponentStory; + /** * We use the Component Story Format for writing stories. Follow the docs here: * @@ -11,25 +15,25 @@ const story = { component: Banner, }; -function Template(args) { +function Template(props: BannerProps) { // eslint-disable-next-line react/jsx-props-no-spreading - return ; + return ; } // Arguments can be passed to the component by binding // See: https://storybook.js.org/docs/react/writing-stories/introduction#using-args -const InfoBanner = Template.bind({}); +const InfoBanner: BannerStory = Template.bind({}); InfoBanner.args = { text: 'This is an informational banner', }; -const HTMLBanner = Template.bind({}); +const HTMLBanner: BannerStory = Template.bind({}); HTMLBanner.args = { text: 'This is a informational banner containing HTML', shouldRenderHTML: true, }; -const BannerWithLink = Template.bind({}); +const BannerWithLink: BannerStory = Template.bind({}); BannerWithLink.args = { text: 'This is a informational banner containing internal Link and public link', shouldRenderHTML: true, diff --git a/src/stories/Button.stories.js b/src/stories/Button.stories.tsx similarity index 79% rename from src/stories/Button.stories.js rename to src/stories/Button.stories.tsx index 2bf254b9f382..3e094b0c65bf 100644 --- a/src/stories/Button.stories.js +++ b/src/stories/Button.stories.tsx @@ -1,29 +1,33 @@ /* eslint-disable react/jsx-props-no-spreading */ +import type {ComponentMeta, ComponentStory} from '@storybook/react'; import React, {useCallback, useState} from 'react'; import {View} from 'react-native'; +import type {ButtonProps} from '@components/Button'; import Button from '@components/Button'; import Text from '@components/Text'; +type ButtonStory = ComponentStory; + /** * We use the Component Story Format for writing stories. Follow the docs here: * * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format */ -const story = { +const story: ComponentMeta = { title: 'Components/Button', component: Button, }; -function Template(args) { +function Template(props: ButtonProps) { // eslint-disable-next-line react/jsx-props-no-spreading - return