├── .github ├── FUNDING.yml └── workflows │ ├── linter.yml │ └── tests.yml ├── .gitignore ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── action.yml ├── assets ├── historical.png ├── pull-request.png ├── signup-btn.png ├── slack-logo.jpg ├── slack.png ├── teams-logo.jpg ├── teams.png ├── web-banner.png ├── web-preview.png └── webhook-logo.jpg ├── dist └── index.js ├── docs ├── examples │ ├── teams copy.json │ └── teams.json ├── slack.md ├── teams.md └── webhook.md ├── eslint.config.mjs ├── package.json ├── src ├── __tests__ │ └── execute.test.js ├── config │ ├── index.js │ └── stats.js ├── constants.js ├── execute.js ├── fetchers │ ├── __tests__ │ │ ├── commentOnPullRequest.test.js │ │ └── fetchSponsorships.test.js │ ├── commentOnPullRequest.js │ ├── fetchPullRequestById │ │ ├── __tests__ │ │ │ ├── index.test.js │ │ │ └── parser.test.js │ │ ├── index.js │ │ └── parser.js │ ├── fetchPullRequests.js │ ├── fetchSponsorships.js │ ├── index.js │ ├── postToSlack.js │ ├── postToWebhook.js │ └── updatePullRequest.js ├── i18n │ ├── index.js │ └── locales │ │ ├── en-US │ │ ├── execution.json │ │ ├── index.js │ │ ├── integrations.json │ │ └── table.json │ │ └── index.js ├── index.js ├── interactors │ ├── __tests__ │ │ ├── alreadyPublished.test.js │ │ ├── buildComment.test.js │ │ ├── buildJsonOutput.test.js │ │ ├── getEntries.test.js │ │ ├── getPulls.test.js │ │ ├── mergeStats.test.js │ │ ├── mocks │ │ │ ├── populatedReviewers.json │ │ │ ├── reviewers.json │ │ │ ├── stats.json │ │ │ └── statsSum.json │ │ ├── postComment.test.js │ │ ├── postSummary.test.js │ │ ├── postWebhook.test.js │ │ └── publish.test.js │ ├── alreadyPublished.js │ ├── buildComment.js │ ├── buildJsonOutput.js │ ├── buildMarkdown │ │ ├── __tests__ │ │ │ ├── getMarkdownContent.test.js │ │ │ └── index.test.js │ │ ├── getMarkdownContent.js │ │ └── index.js │ ├── buildTable │ │ ├── __tests__ │ │ │ ├── calculateBests.test.js │ │ │ ├── getTableData.test.js │ │ │ ├── index.test.js │ │ │ ├── removeEmpty.test.js │ │ │ └── sortByStats.test.js │ │ ├── calculateBests.js │ │ ├── getTableData.js │ │ ├── index.js │ │ ├── removeEmpty.js │ │ └── sortByStats.js │ ├── checkSponsorship │ │ ├── __tests__ │ │ │ ├── getLogins.test.js │ │ │ ├── isExternalSponsor.test.js │ │ │ └── isSponsoring.test.js │ │ ├── getLogins.js │ │ ├── index.js │ │ ├── isExternalSponsor.js │ │ └── isSponsoring.js │ ├── fulfillEntries │ │ ├── __tests__ │ │ │ ├── buildReviewTimeLink.test.js │ │ │ ├── calculateTotals.test.js │ │ │ ├── getContributions.test.js │ │ │ └── index.test.js │ │ ├── buildReviewTimeLink.js │ │ ├── calculateTotals.js │ │ ├── getContributions.js │ │ └── index.js │ ├── getEntries.js │ ├── getPullRequestStats │ │ ├── __tests__ │ │ │ ├── calculatePullRequestStats.test.js │ │ │ ├── groupPullRequests.test.js │ │ │ └── index.test.js │ │ ├── calculatePullRequestStats.js │ │ ├── groupPullRequests.js │ │ └── index.js │ ├── getPulls.js │ ├── getReviewStats │ │ ├── __tests__ │ │ │ ├── calculateReviewsStats.test.js │ │ │ ├── groupReviews.test.js │ │ │ └── index.test.js │ │ ├── calculateReviewsStats.js │ │ ├── groupReviews.js │ │ └── index.js │ ├── getUsers │ │ ├── __tests__ │ │ │ ├── findUsers.test.js │ │ │ ├── index.test.js │ │ │ ├── parseFilter.test.js │ │ │ └── testFilter.test.js │ │ ├── findUsers.js │ │ ├── index.js │ │ ├── parseFilter.js │ │ └── testFilter.js │ ├── index.js │ ├── mergeStats.js │ ├── postComment.js │ ├── postSlackMessage │ │ ├── __tests__ │ │ │ └── index.test.js │ │ ├── buildMessage │ │ │ ├── __tests__ │ │ │ │ ├── buildRow.test.js │ │ │ │ ├── buildSubtitle.test.js │ │ │ │ └── index.test.js │ │ │ ├── buildRow.js │ │ │ ├── buildSubtitle.js │ │ │ └── index.js │ │ └── index.js │ ├── postSummary.js │ ├── postTeamsMessage │ │ ├── __tests__ │ │ │ ├── buildPayload.test.js │ │ │ └── index.test.js │ │ ├── buildMessage │ │ │ ├── __tests__ │ │ │ │ ├── buildHeaders.test.js │ │ │ │ ├── buildRow.test.js │ │ │ │ ├── buildSubtitle.test.js │ │ │ │ └── index.test.js │ │ │ ├── buildHeaders.js │ │ │ ├── buildRow.js │ │ │ ├── buildSubtitle.js │ │ │ └── index.js │ │ ├── buildPayload.js │ │ └── index.js │ ├── postWebhook.js │ └── publish.js ├── parsers │ ├── __tests__ │ │ ├── mocks │ │ │ ├── pullRequest.json │ │ │ ├── review.json │ │ │ └── user.json │ │ ├── parseInputs.test.js │ │ ├── parsePullRequest.test.js │ │ ├── parseReview.test.js │ │ └── parseUser.test.js │ ├── index.js │ ├── parseInputs.js │ ├── parsePullRequest.js │ ├── parseReview.js │ └── parseUser.js ├── services │ ├── index.js │ ├── splitter │ │ ├── __tests__ │ │ │ ├── base.test.js │ │ │ ├── slack.test.js │ │ │ └── teams.test.js │ │ ├── base.js │ │ ├── index.js │ │ ├── slack.js │ │ └── teams.js │ └── telemetry │ │ ├── __tests__ │ │ ├── index.test.js │ │ └── sendSuccess.test.js │ │ ├── buildTracker.js │ │ ├── index.js │ │ ├── sendError.js │ │ ├── sendStart.js │ │ └── sendSuccess.js └── utils │ ├── __test__ │ ├── average.test.js │ ├── buildSources.test.js │ ├── divide.test.js │ ├── median.test.js │ ├── repos.test.js │ └── sum.test.js │ ├── average.js │ ├── buildSources.js │ ├── divide.js │ ├── durationToString.js │ ├── index.js │ ├── isNil.js │ ├── median.js │ ├── repos.js │ ├── subtractDaysToDate.js │ └── sum.js ├── tests └── mocks │ ├── entries.js │ ├── index.js │ ├── pullRequestStats.js │ ├── pullRequests.js │ ├── reviewStats.js │ ├── reviews.js │ ├── table.js │ └── users.js └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: manuelmhtr 2 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: Linter 2 | 3 | on: push 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: Upgrade Yarn 13 | run: | 14 | corepack enable 15 | yarn set version stable 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 22 20 | cache: yarn 21 | 22 | - name: Install dependencies 23 | run: yarn install 24 | 25 | - run: yarn lint 26 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: push 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v4 11 | 12 | - name: Upgrade Yarn 13 | run: | 14 | corepack enable 15 | yarn set version stable 16 | 17 | - name: Use Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 22 21 | cache: yarn 22 | 23 | - run: yarn install 24 | 25 | - run: yarn test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and not Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | 107 | # Stores VSCode versions used for testing VSCode extensions 108 | .vscode-test 109 | 110 | # yarn v2 111 | .yarn/cache 112 | .yarn/unplugged 113 | .yarn/build-state.yml 114 | .yarn/install-state.gz 115 | .pnp.* 116 | 117 | # Local test file 118 | .run.js 119 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022-present Manuel Honorio de la Torre Ramírez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Pull Request Stats' 2 | author: 'Manuel de la Torre' 3 | description: 'Github action to print relevant stats about Pull Request reviewers' 4 | inputs: 5 | token: 6 | description: 'An optional Personal Access Token with enough permissions to fetch all required pull requests to calculate the stats.' 7 | required: false 8 | githubToken: 9 | description: 'The default GitHub Token (secrets.GITHUB_TOKEN), used to publish comments as a bot. No need to assign a value for this input.' 10 | required: true 11 | default: ${{ github.token }} 12 | repositories: 13 | description: 'A comma separated list of github repositories to calculate the stats.' 14 | required: false 15 | organization: 16 | description: 'An organization name to use all of its repositories to calculate the stats.' 17 | required: false 18 | period: 19 | description: 'The length of the period used to calculate the stats, expressed in days' 20 | required: false 21 | default: 30 22 | limit: 23 | description: 'The maximum number of rows to display in the table. A value of `0` means unlimited.' 24 | required: false 25 | default: 0 26 | stats: 27 | description: 'A comma separated list of the stats to be calculated and displayed.' 28 | required: false 29 | charts: 30 | description: 'Whether to add charts to the stats or not. Possible values: "true" or "false"' 31 | required: false 32 | default: 'false' 33 | sortBy: 34 | description: 'The column used to sort the data.' 35 | required: false 36 | publishAs: 37 | description: 'Where to publish the results. Possible values: "COMMENT", "DESCRIPTION" or "NONE"' 38 | required: false 39 | default: 'COMMENT' 40 | exclude: 41 | description: 'A list or regular expression to exclude users from the stats' 42 | required: false 43 | include: 44 | description: 'A list or regular expression to specify the users that will be included in the stats' 45 | required: false 46 | disableLinks: 47 | description: 'Prevents from adding any external links in the stats' 48 | required: false 49 | default: false 50 | telemetry: 51 | description: 'Indicates if the action is allowed to send monitoring data to the developer.' 52 | required: false 53 | default: true 54 | slackWebhook: 55 | description: 'A Slack webhook URL to post resulting stats.' 56 | required: false 57 | slackChannel: 58 | description: 'The Slack channel where stats will be posted. Required when a Slack webhook is configured.' 59 | required: false 60 | teamsWebhook: 61 | description: 'A Microsoft Teams webhook URL to post resulting stats.' 62 | required: false 63 | webhook: 64 | description: 'A webhook URL to post resulting stats.' 65 | required: false 66 | sort-by: 67 | description: 'Used for retro compatibility. Use "sortBy" input instead.' 68 | required: false 69 | publish-as: 70 | description: 'Used for retro compatibility. Use "publishAs" input instead.' 71 | required: false 72 | disable-links: 73 | description: 'Used for retro compatibility. Use "disableLinks" input instead.' 74 | required: false 75 | default: false 76 | slack-webhook: 77 | description: 'Used for retro compatibility. Use "slackWebhook" input instead.' 78 | required: false 79 | slack-channel: 80 | description: 'Used for retro compatibility. Use "slackChannel" input instead.' 81 | required: false 82 | teams-webhook: 83 | description: 'Used for retro compatibility. Use "teamsWebhook" input instead.' 84 | required: false 85 | outputs: 86 | resultsMd: 87 | description: 'The resulting stats stored as a step output variable in Markdown format.' 88 | resultsJson: 89 | description: 'The resulting stats stored as a step output variable in JSON format.' 90 | runs: 91 | using: node20 92 | main: 'dist/index.js' 93 | branding: 94 | icon: 'award' 95 | color: 'yellow' 96 | -------------------------------------------------------------------------------- /assets/historical.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowwer-dev/pull-request-stats/b1825f03f7f6887d96f428a4eb6039674d463ba4/assets/historical.png -------------------------------------------------------------------------------- /assets/pull-request.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowwer-dev/pull-request-stats/b1825f03f7f6887d96f428a4eb6039674d463ba4/assets/pull-request.png -------------------------------------------------------------------------------- /assets/signup-btn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowwer-dev/pull-request-stats/b1825f03f7f6887d96f428a4eb6039674d463ba4/assets/signup-btn.png -------------------------------------------------------------------------------- /assets/slack-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowwer-dev/pull-request-stats/b1825f03f7f6887d96f428a4eb6039674d463ba4/assets/slack-logo.jpg -------------------------------------------------------------------------------- /assets/slack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowwer-dev/pull-request-stats/b1825f03f7f6887d96f428a4eb6039674d463ba4/assets/slack.png -------------------------------------------------------------------------------- /assets/teams-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowwer-dev/pull-request-stats/b1825f03f7f6887d96f428a4eb6039674d463ba4/assets/teams-logo.jpg -------------------------------------------------------------------------------- /assets/teams.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowwer-dev/pull-request-stats/b1825f03f7f6887d96f428a4eb6039674d463ba4/assets/teams.png -------------------------------------------------------------------------------- /assets/web-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowwer-dev/pull-request-stats/b1825f03f7f6887d96f428a4eb6039674d463ba4/assets/web-banner.png -------------------------------------------------------------------------------- /assets/web-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowwer-dev/pull-request-stats/b1825f03f7f6887d96f428a4eb6039674d463ba4/assets/web-preview.png -------------------------------------------------------------------------------- /assets/webhook-logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/flowwer-dev/pull-request-stats/b1825f03f7f6887d96f428a4eb6039674d463ba4/assets/webhook-logo.jpg -------------------------------------------------------------------------------- /docs/examples/teams copy.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "message", 3 | "attachments": [ 4 | { 5 | "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", 6 | "type": "AdaptiveCard", 7 | "version": "1.0", 8 | "msteams": { 9 | "width": "Full" 10 | }, 11 | "body": [ 12 | { 13 | "type": "Container", 14 | "id": "4a4631f4-8373-6aa4-9c42-0eb9be92bea4", 15 | "padding": "Medium", 16 | "items": [ 17 | { 18 | "type": "TextBlock", 19 | "id": "f41ff117-10ce-4f16-372e-fb2948c18d4f", 20 | "text": "Stats of the last 30 days for [myorganization](yotepresto.com)", 21 | "wrap": true, 22 | "weight": "Lighter" 23 | } 24 | ] 25 | } 26 | ] 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /docs/slack.md: -------------------------------------------------------------------------------- 1 | # Posting to Slack 2 | 3 | > 💙 This integration is available to sponsors. 4 | 5 | This action can post the results to a channel in Slack. For example: 6 | 7 | ![](/assets/slack.png) 8 | 9 | To configure the Slack integration: 10 | 11 | 1. [Create a webhook](https://slack.com/help/articles/115005265063-Incoming-webhooks-for-Slack) in your workspace (you must be a Slack admin). It should look like this: `https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX`. Check out [this tutorial](https://www.youtube.com/watch?v=6NJuntZSJVA) if you have questions about getting the webhook URL. 12 | 2. Set the `slackWebhook` (from the previous step) and `slackChannel` (don't forget to include the `#` character) parameters in this action. 13 | 3. Ready to go! 14 | 15 | Since it may be pretty annoying to receive a Slack notification every time someone creates a pull request, it is recommended to configure this action to be executed every while using the `schedule` trigger. For example, every Monday at 9am UTC: 16 | 17 | ```yml 18 | name: Pull Request Stats 19 | 20 | on: 21 | schedule: 22 | - cron: '0 9 * * 1' 23 | 24 | jobs: 25 | stats: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Run pull request stats 29 | uses: flowwer-dev/pull-request-stats@main 30 | with: 31 | slackChannel: '#mystatschannel' 32 | slackWebhook: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX' 33 | # slackWebhook: ${{ secrets.SLACK_WEBHOOK }} You may want to store this value as a secret. 34 | ``` 35 | -------------------------------------------------------------------------------- /docs/teams.md: -------------------------------------------------------------------------------- 1 | # Posting to Microsoft Teams 2 | 3 | > 💙 This integration is available to sponsors. 4 | 5 | This action can post the results to a channel in Teams. For example: 6 | 7 | ![](/assets/teams.png) 8 | 9 | To configure the Teams integration: 10 | 11 | 1. [Create a webhook](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook) in the Channel you want the stats to be published (you must be an admin). You can set `Pull Request Stats` as the **name** and you may download [this file](https://s3.amazonaws.com/manuelmhtr.assets/flowwer/logo/logo-1024px.png) as the **image**. For It should look like this: `https://abcXXX.webhook.office.com/webhookb2/AAAAAA@BBBBBBBB/IncomingWebhook/XXXXXXXXXX/YYYYYY`. Check out [this tutorial](https://www.youtube.com/watch?v=amvh4rzTCS0) if you have questions about getting the webhook URL. 12 | 2. Set the `teamsWebhook` (from the previous step) parameter in this action. 13 | 3. Ready to go! 14 | 15 | Since it may be pretty annoying to receive a Teams notification every time someone creates a pull request, it is recommended to configure this action to be executed every while using the `schedule` trigger. For example, every Monday at 9am UTC: 16 | 17 | ```yml 18 | name: Pull Request Stats 19 | 20 | on: 21 | schedule: 22 | - cron: '0 9 * * 1' 23 | 24 | jobs: 25 | stats: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Run pull request stats 29 | uses: flowwer-dev/pull-request-stats@main 30 | with: 31 | teamsWebhook: 'https://abcXXX.webhook.office.com/webhookb2/...' 32 | # teamsWebhook: ${{ secrets.TEAMS_WEBHOOK }} You may want to store this value as a secret. 33 | ``` 34 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import jest from 'eslint-plugin-jest'; 3 | import globals from 'globals'; 4 | import path from 'node:path'; 5 | import js from '@eslint/js'; 6 | import { fileURLToPath } from 'node:url'; 7 | import { FlatCompat } from '@eslint/eslintrc'; 8 | /* eslint-enable import/no-extraneous-dependencies */ 9 | 10 | /* eslint-disable no-underscore-dangle */ 11 | const __filename = fileURLToPath(import.meta.url); 12 | const __dirname = path.dirname(__filename); 13 | /* eslint-enable no-underscore-dangle */ 14 | const compat = new FlatCompat({ 15 | baseDirectory: __dirname, 16 | recommendedConfig: js.configs.recommended, 17 | allConfig: js.configs.all, 18 | }); 19 | 20 | export default [{ 21 | ignores: ['dist/**/*'], 22 | }, ...compat.extends('airbnb-base'), { 23 | plugins: { 24 | jest, 25 | }, 26 | 27 | languageOptions: { 28 | globals: { 29 | ...globals.node, 30 | ...jest.environments.globals.globals, 31 | Promise: 'readonly', 32 | }, 33 | 34 | ecmaVersion: 'latest', 35 | sourceType: 'module', 36 | }, 37 | }]; 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pull-request-stats", 3 | "version": "3.2.2", 4 | "description": "Github action to print relevant stats about Pull Request reviewers", 5 | "main": "dist/index.js", 6 | "type": "commonjs", 7 | "scripts": { 8 | "build": "eslint src && ncc build src/index.js -o dist -a", 9 | "test": "jest", 10 | "lint": "eslint ./" 11 | }, 12 | "keywords": [], 13 | "author": "Manuel de la Torre", 14 | "license": "MIT", 15 | "jest": { 16 | "testEnvironment": "node", 17 | "testMatch": [ 18 | "**/?(*.)+(spec|test).[jt]s?(x)" 19 | ] 20 | }, 21 | "dependencies": { 22 | "@actions/core": "^1.11.1", 23 | "@actions/github": "^6.0.0", 24 | "axios": "^1.7.9", 25 | "humanize-duration": "^3.32.1", 26 | "i18n-js": "^3.9.2", 27 | "jsurl": "^0.1.5", 28 | "lodash.get": "^4.4.2", 29 | "markdown-table": "^2.0.0", 30 | "mixpanel": "^0.18.0" 31 | }, 32 | "devDependencies": { 33 | "@eslint/eslintrc": "^3.2.0", 34 | "@eslint/js": "^9.16.0", 35 | "@vercel/ncc": "^0.38.3", 36 | "eslint": "^9.16.0", 37 | "eslint-config-airbnb-base": "^15.0.0", 38 | "eslint-plugin-import": "^2.31.0", 39 | "eslint-plugin-jest": "^28.9.0", 40 | "globals": "^15.13.0", 41 | "jest": "^29.7.0" 42 | }, 43 | "funding": "https://github.com/sponsors/manuelmhtr", 44 | "packageManager": "yarn@4.1.0" 45 | } 46 | -------------------------------------------------------------------------------- /src/config/index.js: -------------------------------------------------------------------------------- 1 | const getSlackLimits = () => ({ 2 | chars: 30_000, 3 | blocks: 50, 4 | }); 5 | const getTeamsBytesLimit = () => 27_000; 6 | const getGithubApiUrl = () => process.env.GITHUB_API_URL || 'https://api.github.com'; 7 | const getGithubServerUrl = () => process.env.GITHUB_SERVER_URL || 'https://github.com'; 8 | 9 | module.exports = { 10 | getSlackLimits, 11 | getTeamsBytesLimit, 12 | getGithubApiUrl, 13 | getGithubServerUrl, 14 | }; 15 | -------------------------------------------------------------------------------- /src/config/stats.js: -------------------------------------------------------------------------------- 1 | const { durationToString, isNil } = require('../utils'); 2 | 3 | const noParse = (value) => String(value ?? '-'); 4 | 5 | const toFixed = (decimals = 0) => (value) => { 6 | if (isNil(value)) return '-'; 7 | return new Intl.NumberFormat('en-US', { 8 | maximumFractionDigits: decimals, 9 | minimumFractionDigits: decimals, 10 | }).format(value); 11 | }; 12 | 13 | const STATS = { 14 | totalReviews: { 15 | id: 'totalReviews', 16 | sortOrder: 'DESC', 17 | parser: toFixed(0), 18 | }, 19 | totalComments: { 20 | id: 'totalComments', 21 | sortOrder: 'DESC', 22 | parser: toFixed(0), 23 | }, 24 | timeToReview: { 25 | id: 'timeToReview', 26 | sortOrder: 'ASC', 27 | parser: durationToString, 28 | }, 29 | commentsPerReview: { 30 | id: 'commentsPerReview', 31 | sortOrder: 'DESC', 32 | parser: toFixed(2), 33 | }, 34 | reviewedAdditions: { 35 | id: 'reviewedAdditions', 36 | sortOrder: 'DESC', 37 | parser: toFixed(0), 38 | }, 39 | reviewedDeletions: { 40 | id: 'reviewedDeletions', 41 | sortOrder: 'DESC', 42 | parser: toFixed(0), 43 | }, 44 | reviewedLines: { 45 | id: 'reviewedLines', 46 | sortOrder: 'DESC', 47 | parser: toFixed(0), 48 | }, 49 | openedPullRequests: { 50 | id: 'openedPullRequests', 51 | sortOrder: 'DESC', 52 | parser: noParse, 53 | }, 54 | totalObservations: { 55 | id: 'totalObservations', 56 | sortOrder: 'DESC', 57 | parser: toFixed(0), 58 | }, 59 | medianObservations: { 60 | id: 'medianObservations', 61 | sortOrder: 'DESC', 62 | parser: toFixed(2), 63 | }, 64 | revisionSuccessRate: { 65 | id: 'revisionSuccessRate', 66 | sortOrder: 'DESC', 67 | parser: toFixed(2), 68 | }, 69 | additions: { 70 | id: 'additions', 71 | sortOrder: 'DESC', 72 | parser: toFixed(0), 73 | }, 74 | deletions: { 75 | id: 'deletions', 76 | sortOrder: 'DESC', 77 | parser: toFixed(0), 78 | }, 79 | lines: { 80 | id: 'lines', 81 | sortOrder: 'DESC', 82 | parser: toFixed(0), 83 | }, 84 | }; 85 | 86 | const VALID_STATS = Object.keys(STATS); 87 | 88 | const DEFAULT_STATS = ['totalReviews', 'timeToReview', 'totalComments']; 89 | 90 | module.exports = { 91 | STATS, 92 | VALID_STATS, 93 | DEFAULT_STATS, 94 | }; 95 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | const SORT_KEY = { 2 | TIME: 'timeToReview', 3 | REVIEWS: 'totalReviews', 4 | COMMENTS: 'totalComments', 5 | }; 6 | 7 | const COLUMNS_ORDER = ['totalReviews', 'timeToReview', 'totalComments']; 8 | 9 | const STATS_OPTIMIZATION = { 10 | totalReviews: 'MAX', 11 | totalComments: 'MAX', 12 | commentsPerReview: 'MAX', 13 | timeToReview: 'MIN', 14 | }; 15 | 16 | const STATS = Object.keys(STATS_OPTIMIZATION); 17 | 18 | module.exports = { 19 | SORT_KEY, 20 | COLUMNS_ORDER, 21 | STATS, 22 | STATS_OPTIMIZATION, 23 | }; 24 | -------------------------------------------------------------------------------- /src/execute.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const github = require('@actions/github'); 3 | const { t } = require('./i18n'); 4 | const { subtractDaysToDate } = require('./utils'); 5 | const { Telemetry } = require('./services'); 6 | const { fetchPullRequestById } = require('./fetchers'); 7 | const { getGithubApiUrl } = require('./config'); 8 | const { 9 | alreadyPublished, 10 | checkSponsorship, 11 | getPulls, 12 | getEntries, 13 | publish, 14 | } = require('./interactors'); 15 | 16 | const run = async ({ inputs, octokit }) => { 17 | const pullRequest = inputs.pullRequestId 18 | ? await fetchPullRequestById(octokit, inputs.pullRequestId) 19 | : null; 20 | 21 | if (alreadyPublished(pullRequest)) { 22 | core.info('Skipping execution because stats are published already'); 23 | return null; 24 | } 25 | 26 | const pulls = await getPulls({ 27 | org: inputs.org, 28 | repos: inputs.repos, 29 | octokit: github.getOctokit(inputs.personalToken, { baseUrl: getGithubApiUrl() }), 30 | startDate: subtractDaysToDate(new Date(), inputs.periodLength), 31 | }); 32 | core.info(`Found ${pulls.length} pull requests to analyze`); 33 | 34 | const entries = await getEntries({ 35 | core, 36 | pulls, 37 | excludeStr: inputs.excludeStr, 38 | includeStr: inputs.includeStr, 39 | periodLength: inputs.periodLength, 40 | }); 41 | core.debug(`Analyzed entries: ${entries.length}`); 42 | 43 | await publish({ 44 | core, 45 | octokit, 46 | entries, 47 | pullRequest, 48 | inputs, 49 | }); 50 | 51 | return { 52 | entries, 53 | pullRequest, 54 | }; 55 | }; 56 | 57 | module.exports = async (inputs) => { 58 | core.debug(`Inputs: ${JSON.stringify(inputs, null, 2)}`); 59 | 60 | const { githubToken, org, repos } = inputs; 61 | const octokit = github.getOctokit(githubToken, { baseUrl: getGithubApiUrl() }); 62 | const isSponsor = await checkSponsorship({ octokit, org, repos }); 63 | const telemetry = new Telemetry({ core, isSponsor, telemetry: inputs.telemetry }); 64 | if (isSponsor) core.info(t('execution.logs.sponsors')); 65 | 66 | try { 67 | telemetry.start(inputs); 68 | const results = await run({ 69 | octokit, 70 | inputs: { ...inputs, isSponsor }, 71 | }); 72 | telemetry.success(results); 73 | return results; 74 | } catch (error) { 75 | telemetry.error(error); 76 | throw error; 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /src/fetchers/__tests__/commentOnPullRequest.test.js: -------------------------------------------------------------------------------- 1 | const commentOnPullRequest = require('../commentOnPullRequest'); 2 | 3 | describe('Fetchers | .commentOnPullRequest', () => { 4 | const graphql = jest.fn(() => Promise.resolve()); 5 | const octokit = { graphql }; 6 | 7 | beforeEach(() => { 8 | graphql.mockClear(); 9 | }); 10 | 11 | it('builds the query and fetches data from Github API', async () => { 12 | const body = 'Test comment'; 13 | const pullRequestId = '123'; 14 | await commentOnPullRequest({ octokit, body, pullRequestId }); 15 | expect(graphql).toHaveBeenCalledTimes(1); 16 | expect(graphql).toHaveBeenCalledWith( 17 | expect.stringContaining('addComment(input: $input)'), 18 | { 19 | input: { 20 | body, 21 | subjectId: pullRequestId, 22 | }, 23 | }, 24 | ); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/fetchers/__tests__/fetchSponsorships.test.js: -------------------------------------------------------------------------------- 1 | const fetchSponsorships = require('../fetchSponsorships'); 2 | 3 | const logins = [ 4 | 'login1', 5 | 'login2', 6 | ]; 7 | 8 | describe('Fetchers | .fetchSponsorships', () => { 9 | const data = { user: { login1: true, login2: false } }; 10 | const graphql = jest.fn(() => Promise.resolve(data)); 11 | const octokit = { graphql }; 12 | 13 | beforeEach(() => { 14 | graphql.mockClear(); 15 | }); 16 | 17 | it('builds the query and fetches data from Github API', async () => { 18 | const response = await fetchSponsorships({ octokit, logins }); 19 | expect(response).toEqual(data); 20 | expect(graphql).toHaveBeenCalledTimes(1); 21 | expect(graphql).toHaveBeenCalledWith( 22 | expect.stringContaining(`isSponsoredBy(accountLogin: "${logins[0]}")`), 23 | ); 24 | }); 25 | 26 | it('returns an empty response when request fails', async () => { 27 | const error = new Error("Field 'isSponsoredBy' doesn't exist on type 'User'"); 28 | graphql.mockImplementation(() => Promise.reject(error)); 29 | const response = await fetchSponsorships({ octokit, logins }); 30 | expect(graphql).toHaveBeenCalledTimes(1); 31 | expect(response).toEqual({ user: {} }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/fetchers/commentOnPullRequest.js: -------------------------------------------------------------------------------- 1 | const COMMENT_MUTATION = ` 2 | mutation($input: AddCommentInput!) { 3 | addComment(input: $input) { 4 | clientMutationId 5 | } 6 | } 7 | `; 8 | 9 | module.exports = ({ 10 | octokit, 11 | body, 12 | pullRequestId: subjectId, 13 | }) => { 14 | const variables = { input: { body, subjectId } }; 15 | return octokit 16 | .graphql(COMMENT_MUTATION, variables) 17 | .catch((error) => { 18 | const msg = `Error commenting on the pull request, with variables "${JSON.stringify(variables)}"`; 19 | throw new Error(`${msg}. Error: ${error}`); 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /src/fetchers/fetchPullRequestById/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | const parser = require('../parser'); 2 | const fetchPullRequestById = require('../index'); 3 | 4 | jest.mock('../parser', () => jest.fn()); 5 | 6 | describe('Fetchers | .fetchPullRequestById', () => { 7 | const graphql = jest.fn(() => Promise.resolve()); 8 | const octokit = { graphql }; 9 | 10 | beforeEach(() => { 11 | graphql.mockClear(); 12 | parser.mockClear(); 13 | }); 14 | 15 | it('builds the query and fetches data from Github API', async () => { 16 | const id = '123'; 17 | await fetchPullRequestById(octokit, id); 18 | expect(graphql).toBeCalledTimes(1); 19 | expect(graphql).toBeCalledWith( 20 | expect.stringContaining('node(id: $id)'), 21 | { 22 | id, 23 | }, 24 | ); 25 | }); 26 | 27 | it('parses the input', async () => { 28 | const id = '123'; 29 | await fetchPullRequestById(octokit, id); 30 | expect(parser).toBeCalledTimes(1); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/fetchers/fetchPullRequestById/__tests__/parser.test.js: -------------------------------------------------------------------------------- 1 | const parser = require('../parser'); 2 | 3 | const input = { 4 | node: { 5 | id: 'PR_kwDODiVEWs455SUz', 6 | url: 'https://github.com/zenfi/api/pull/493', 7 | number: 493, 8 | author: { 9 | login: 'author1', 10 | }, 11 | comments: { 12 | nodes: [ 13 | { 14 | author: { 15 | login: 'user1', 16 | }, 17 | body: 'body1', 18 | }, 19 | { 20 | author: { 21 | login: 'user2', 22 | }, 23 | body: 'body2', 24 | }, 25 | ], 26 | }, 27 | }, 28 | }; 29 | 30 | const expectedOutput = { 31 | id: 'PR_kwDODiVEWs455SUz', 32 | url: 'https://github.com/zenfi/api/pull/493', 33 | number: 493, 34 | author: { 35 | login: 'author1', 36 | }, 37 | comments: [ 38 | { 39 | author: { 40 | login: 'user1', 41 | }, 42 | body: 'body1', 43 | }, 44 | { 45 | author: { 46 | login: 'user2', 47 | }, 48 | body: 'body2', 49 | }, 50 | ], 51 | }; 52 | 53 | describe('Fetchers | .fetchPullRequestById | .parser', () => { 54 | it('parses the pull request data', () => { 55 | const result = parser(input); 56 | expect(result).toEqual(expectedOutput); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/fetchers/fetchPullRequestById/index.js: -------------------------------------------------------------------------------- 1 | const parsePullRequest = require('./parser'); 2 | 3 | const PR_BY_ID_QUERY = ` 4 | query($id: ID!) { 5 | node(id: $id) { 6 | ... on PullRequest { 7 | id 8 | url 9 | body 10 | number 11 | author { 12 | login 13 | } 14 | comments(last: 100) { 15 | nodes { 16 | author { 17 | login 18 | } 19 | body 20 | } 21 | } 22 | } 23 | } 24 | } 25 | `; 26 | 27 | module.exports = (octokit, id) => { 28 | const variables = { id }; 29 | return octokit 30 | .graphql(PR_BY_ID_QUERY, variables) 31 | .then(parsePullRequest) 32 | .catch((error) => { 33 | const msg = `Error fetching pull requests with id "${id}"`; 34 | throw new Error(`${msg}. Error: ${error}`); 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /src/fetchers/fetchPullRequestById/parser.js: -------------------------------------------------------------------------------- 1 | const get = require('lodash.get'); 2 | 3 | const parseComments = (node) => ({ 4 | ...node, 5 | }); 6 | 7 | module.exports = ({ node: data }) => ({ 8 | ...data, 9 | comments: (get(data, 'comments.nodes') || []).map(parseComments), 10 | }); 11 | -------------------------------------------------------------------------------- /src/fetchers/fetchPullRequests.js: -------------------------------------------------------------------------------- 1 | const PRS_QUERY = ` 2 | query($search: String!, $limit: Int!, $after: String) { 3 | search(query: $search, first: $limit, after: $after, type: ISSUE) { 4 | edges { 5 | cursor 6 | node { 7 | ... on PullRequest { 8 | id 9 | additions 10 | deletions 11 | publishedAt 12 | author { ...ActorFragment } 13 | reviews(first: 100) { 14 | nodes { 15 | id 16 | body 17 | state 18 | submittedAt 19 | commit { pushedDate } 20 | comments { totalCount } 21 | author { ...ActorFragment } 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | } 29 | 30 | fragment ActorFragment on User { 31 | url 32 | login 33 | avatarUrl 34 | databaseId 35 | } 36 | `; 37 | 38 | module.exports = ({ 39 | octokit, 40 | search, 41 | after, 42 | limit = null, 43 | }) => { 44 | const variables = { search, after, limit }; 45 | return octokit 46 | .graphql(PRS_QUERY, variables) 47 | .catch((error) => { 48 | const msg = `Error fetching pull requests with variables "${JSON.stringify(variables)}"`; 49 | throw new Error(`${msg}. Error: ${error}`); 50 | }); 51 | }; 52 | -------------------------------------------------------------------------------- /src/fetchers/fetchSponsorships.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | 3 | const SPONSORED_ACCOUNT = 'manuelmhtr'; 4 | const DEFAULT_RESPONSE = { user: {} }; 5 | 6 | const buildQuery = (logins) => { 7 | const fields = logins.map( 8 | (login, index) => `sponsor${index + 1}: isSponsoredBy(accountLogin: "${login}")`, 9 | ).join('\n'); 10 | 11 | return `{ 12 | user( 13 | login: "${SPONSORED_ACCOUNT}" 14 | ) { 15 | ${fields} 16 | } 17 | }`; 18 | }; 19 | 20 | module.exports = ({ 21 | octokit, 22 | logins, 23 | }) => octokit 24 | .graphql(buildQuery(logins)) 25 | .catch((error) => { 26 | const msg = `Error fetching sponsorships with logins: "${JSON.stringify(logins)}"`; 27 | core.debug(new Error(`${msg}. Error: ${error}`)); 28 | return DEFAULT_RESPONSE; 29 | }); 30 | -------------------------------------------------------------------------------- /src/fetchers/index.js: -------------------------------------------------------------------------------- 1 | const commentOnPullRequest = require('./commentOnPullRequest'); 2 | const fetchPullRequestById = require('./fetchPullRequestById'); 3 | const fetchPullRequests = require('./fetchPullRequests'); 4 | const fetchSponsorships = require('./fetchSponsorships'); 5 | const postToSlack = require('./postToSlack'); 6 | const postToWebhook = require('./postToWebhook'); 7 | const updatePullRequest = require('./updatePullRequest'); 8 | 9 | module.exports = { 10 | commentOnPullRequest, 11 | fetchPullRequestById, 12 | fetchPullRequests, 13 | fetchSponsorships, 14 | postToSlack, 15 | postToWebhook, 16 | updatePullRequest, 17 | }; 18 | -------------------------------------------------------------------------------- /src/fetchers/postToSlack.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios').default; 2 | 3 | module.exports = ({ 4 | webhook, 5 | message, 6 | channel, 7 | iconUrl, 8 | username, 9 | }) => axios({ 10 | method: 'post', 11 | url: webhook, 12 | data: { 13 | channel, 14 | username, 15 | blocks: message.blocks, 16 | icon_url: iconUrl, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /src/fetchers/postToWebhook.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios').default; 2 | 3 | module.exports = ({ 4 | payload, 5 | webhook, 6 | }) => axios({ 7 | method: 'post', 8 | url: webhook, 9 | data: payload, 10 | }); 11 | -------------------------------------------------------------------------------- /src/fetchers/updatePullRequest.js: -------------------------------------------------------------------------------- 1 | const UPDATE_PR_MUTATION = ` 2 | mutation($id: ID!, $body: String!) { 3 | updatePullRequest(input: { 4 | body: $body, 5 | pullRequestId: $id 6 | }) { 7 | pullRequest { 8 | id 9 | } 10 | } 11 | } 12 | `; 13 | 14 | module.exports = ({ 15 | octokit, 16 | id, 17 | body, 18 | event, 19 | }) => { 20 | const variables = { id, body, event }; 21 | return octokit 22 | .graphql(UPDATE_PR_MUTATION, variables) 23 | .catch((error) => { 24 | const msg = `Error updating pull request with id "${id}"`; 25 | throw new Error(`${msg}. Error: ${error}`); 26 | }); 27 | }; 28 | -------------------------------------------------------------------------------- /src/i18n/index.js: -------------------------------------------------------------------------------- 1 | const i18n = require('i18n-js'); 2 | const locales = require('./locales'); 3 | 4 | const LOCALE = 'en-US'; 5 | 6 | i18n.translations = locales; 7 | i18n.locale = LOCALE; 8 | i18n.defaultLocale = LOCALE; 9 | i18n.fallbacks = true; 10 | 11 | module.exports = i18n; 12 | -------------------------------------------------------------------------------- /src/i18n/locales/en-US/execution.json: -------------------------------------------------------------------------------- 1 | { 2 | "logs": { 3 | "success": "Action successfully executed", 4 | "news": "\n✨ New web version released! https://app.flowwer.dev", 5 | "sponsors": "Thanks for sponsoring this project! 💙" 6 | }, 7 | "sponsors": { 8 | "external": { 9 | "fetch": { 10 | "success": "External sponsors fetched successfully. {{data}}", 11 | "error": "Failed to fetch external sponsors. {{error}}" 12 | } 13 | } 14 | }, 15 | "errors": { 16 | "main": "Execution failed with error: {{message}}" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/i18n/locales/en-US/index.js: -------------------------------------------------------------------------------- 1 | const execution = require('./execution.json'); 2 | const integrations = require('./integrations.json'); 3 | const table = require('./table.json'); 4 | 5 | module.exports = { 6 | execution, 7 | integrations, 8 | table, 9 | }; 10 | -------------------------------------------------------------------------------- /src/i18n/locales/en-US/integrations.json: -------------------------------------------------------------------------------- 1 | { 2 | "slack": { 3 | "logs": { 4 | "notConfigured": "Slack integration is disabled. No webhook or channel configured.", 5 | "posting": "Post a Slack message with params: {{params}}", 6 | "success": "Successfully posted to slack" 7 | }, 8 | "errors": { 9 | "notSponsor": "Slack integration is a premium feature, available to sponsors.\n(If you are already an sponsor, please make sure it is configured as public).", 10 | "requestFailed": "Error posting Slack message: {{error}}" 11 | } 12 | }, 13 | "teams": { 14 | "logs": { 15 | "notConfigured": "Microsoft Teams integration is disabled. No webhook configured.", 16 | "posting": "Post a MS Teams message with params: {{params}}", 17 | "success": "Successfully posted to MS Teams" 18 | }, 19 | "errors": { 20 | "notSponsor": "Microsoft Teams integration is a premium feature, available to sponsors.\n(If you are already an sponsor, please make sure it is configured as public).", 21 | "requestFailed": "Error posting MS Teams message: {{error}}" 22 | } 23 | }, 24 | "webhook": { 25 | "logs": { 26 | "notConfigured": "Webhook integration is disabled.", 27 | "posting": "Post a Slack message with params: {{params}}", 28 | "success": "Successfully posted to slack" 29 | }, 30 | "errors": { 31 | "requestFailed": "Error posting Webhook: {{error}}", 32 | "statsLimitExceeded": "Slack integration cannot post more than {{statsLimit}} stats due to API limits. Reduce the number of stats to post in the 'stats' parameter to avoid this error." 33 | } 34 | }, 35 | "summary": { 36 | "logs": { 37 | "posting": "Post action summary: {{content}}", 38 | "success": "Successfully posted to action summary" 39 | }, 40 | "errors": { 41 | "writeFailed": "Error posting action summary: {{error}}" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/i18n/locales/en-US/table.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Pull reviewers stats", 3 | "icon": "https://s3.amazonaws.com/manuelmhtr.assets/flowwer/logo/logo-1024px.png", 4 | "subtitle": { 5 | "one": "Stats of the last day for {{sources}}", 6 | "other": "Stats of the last {{count}} days for {{sources}}" 7 | }, 8 | "sources": { 9 | "separator": ", ", 10 | "fullList": "{{firsts}} and {{last}}", 11 | "andOthers": "{{firsts}} and {{count}} others" 12 | }, 13 | "columns": { 14 | "avatar": "", 15 | "username": "User", 16 | "totalReviews": "Total reviews", 17 | "totalComments": "Total comments", 18 | "timeToReview": "Time to review", 19 | "commentsPerReview": "Comments per review", 20 | "reviewedAdditions": "Reviewed additions", 21 | "reviewedDeletions": "Reviewed deletions", 22 | "reviewedLines": "Reviewed lines", 23 | "openedPullRequests": "Opened PRs", 24 | "totalObservations": "Total observations", 25 | "medianObservations": "Observations per PR", 26 | "revisionSuccessRate": "Revision success rate", 27 | "additions": "Additions", 28 | "deletions": "Deletions", 29 | "lines": "Lines of code" 30 | }, 31 | "footer": "⚡️ [Pull request stats](https://bit.ly/pull-request-stats)" 32 | } 33 | -------------------------------------------------------------------------------- /src/i18n/locales/index.js: -------------------------------------------------------------------------------- 1 | const enUS = require('./en-US'); 2 | 3 | module.exports = { 4 | 'en-US': enUS, 5 | }; 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const github = require('@actions/github'); 3 | const execute = require('./execute'); 4 | const { parseInputs } = require('./parsers'); 5 | const { t } = require('./i18n'); 6 | 7 | const getParams = () => { 8 | const currentRepo = process.env.GITHUB_REPOSITORY; 9 | 10 | return parseInputs({ 11 | core, 12 | github, 13 | currentRepo, 14 | }); 15 | }; 16 | 17 | const run = async () => { 18 | try { 19 | await execute(getParams()); 20 | core.info(t('execution.logs.success')); 21 | core.info(t('execution.logs.news')); 22 | } catch (error) { 23 | core.debug(t('execution.errors.main', error)); 24 | core.debug(error.stack); 25 | core.setFailed(error.message); 26 | } 27 | }; 28 | 29 | run(); 30 | -------------------------------------------------------------------------------- /src/interactors/__tests__/alreadyPublished.test.js: -------------------------------------------------------------------------------- 1 | const alreadyPublished = require('../alreadyPublished'); 2 | 3 | const STATS = '## Pull reviewers stats\n|stats|table|'; 4 | const OTHER_CONTENT = '## Other pull request content'; 5 | 6 | describe('Interactors | .alreadyPublished', () => { 7 | it('returns false when input is falsy', () => { 8 | expect(alreadyPublished(null)).toBe(false); 9 | }); 10 | 11 | it('returns false when body is empty', () => { 12 | const body = ''; 13 | const pullRequest = { body }; 14 | expect(alreadyPublished(pullRequest)).toBe(false); 15 | }); 16 | 17 | it('returns false when body contains other stuff', () => { 18 | const body = OTHER_CONTENT; 19 | const pullRequest = { body }; 20 | expect(alreadyPublished(pullRequest)).toBe(false); 21 | }); 22 | 23 | it('returns true when body contains stats only', () => { 24 | const body = STATS; 25 | const pullRequest = { body }; 26 | expect(alreadyPublished(pullRequest)).toBe(true); 27 | }); 28 | 29 | it('returns true when body contains other stuff and stats', () => { 30 | const body = `${OTHER_CONTENT}\n${STATS}`; 31 | const pullRequest = { body }; 32 | expect(alreadyPublished(pullRequest)).toBe(true); 33 | }); 34 | 35 | it('returns true when a comment contains stats', () => { 36 | const comments = [ 37 | { 38 | body: STATS, 39 | }, 40 | ]; 41 | const pullRequest = { body: '', comments }; 42 | expect(alreadyPublished(pullRequest)).toBe(true); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/interactors/__tests__/buildComment.test.js: -------------------------------------------------------------------------------- 1 | const { t } = require('../../i18n'); 2 | const buildComment = require('../buildComment'); 3 | const { getRepoName } = require('../../utils'); 4 | 5 | const TABLE_MOCK = 'TABLE'; 6 | const ORG = 'org'; 7 | const REPO1 = 'org/repo1'; 8 | const REPO2 = 'org/repo2'; 9 | const FOOTER = t('table.footer'); 10 | 11 | const linkOrg = (org) => `[${org}](https://github.com/${org})`; 12 | 13 | const linkRepo = (repo) => `[${getRepoName(repo)}](https://github.com/${repo})`; 14 | 15 | describe('Interactors | .buildComment', () => { 16 | const title = '## Pull reviewers stats'; 17 | 18 | describe('when GITHUB_SERVER_URL is present', () => { 19 | const periodLength = 1; 20 | const message = `Stats of the last day for [${ORG}](https://github.example.io/${ORG}):`; 21 | 22 | it('builds an environment-specific comment using this URL', () => { 23 | process.env.GITHUB_SERVER_URL = 'https://github.example.io'; 24 | const expected = `${title}\n${message}\n${TABLE_MOCK}\n\n${FOOTER}`; 25 | const response = buildComment({ periodLength, markdownTable: TABLE_MOCK, org: ORG }); 26 | delete process.env.GITHUB_SERVER_URL; 27 | expect(response).toEqual(expected); 28 | }); 29 | }); 30 | 31 | describe('when period length is 1', () => { 32 | const periodLength = 1; 33 | const message = `Stats of the last day for ${linkOrg(ORG)}:`; 34 | 35 | it('builds the message in singular', () => { 36 | const expected = `${title}\n${message}\n${TABLE_MOCK}\n\n${FOOTER}`; 37 | const response = buildComment({ periodLength, markdownTable: TABLE_MOCK, org: ORG }); 38 | expect(response).toEqual(expected); 39 | }); 40 | }); 41 | 42 | describe('when period length is more than 1', () => { 43 | const periodLength = 365; 44 | const message = `Stats of the last 365 days for ${linkOrg(ORG)}:`; 45 | 46 | it('builds the message in singular', () => { 47 | const expected = `${title}\n${message}\n${TABLE_MOCK}\n\n${FOOTER}`; 48 | const response = buildComment({ periodLength, markdownTable: TABLE_MOCK, org: ORG }); 49 | expect(response).toEqual(expected); 50 | }); 51 | }); 52 | 53 | describe('when sending repos', () => { 54 | const repos = [REPO1, REPO2]; 55 | const periodLength = 1; 56 | const message = `Stats of the last day for ${linkRepo(REPO1)} and ${linkRepo(REPO2)}:`; 57 | 58 | it('builds the message in singular', () => { 59 | const expected = `${title}\n${message}\n${TABLE_MOCK}\n\n${FOOTER}`; 60 | const response = buildComment({ periodLength, markdownTable: TABLE_MOCK, repos }); 61 | expect(response).toEqual(expected); 62 | }); 63 | }); 64 | 65 | describe('when is a sponsor', () => { 66 | const isSponsor = true; 67 | const periodLength = 1; 68 | const message = `Stats of the last day for ${linkOrg(ORG)}:`; 69 | 70 | it('removes the footer', () => { 71 | const expected = `${title}\n${message}\n${TABLE_MOCK}`; 72 | const response = buildComment({ 73 | isSponsor, 74 | periodLength, 75 | org: ORG, 76 | markdownTable: TABLE_MOCK, 77 | }); 78 | expect(response).toEqual(expected); 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/interactors/__tests__/buildJsonOutput.test.js: -------------------------------------------------------------------------------- 1 | const buildJsonOutput = require('../buildJsonOutput'); 2 | 3 | describe('Interactors | .alreadyPublished', () => { 4 | const inputs = { 5 | githubToken: 'GITHUB_TOKEN', 6 | personalToken: 'PERSONAL_TOKEN', 7 | org: 'ORGANIZATION', 8 | repos: ['REPO1', 'REPO2'], 9 | periodLength: 'PERIOD_LENGTH', 10 | foo: 'BAR', 11 | pullRequestId: 'PULL_REQUEST_ID', 12 | }; 13 | const entries = 'ENTRIES'; 14 | const input = { inputs, entries }; 15 | 16 | it('removes tokens and unknown inputs', () => { 17 | const results = buildJsonOutput(input); 18 | expect(results).not.toHaveProperty('githubToken'); 19 | expect(results).not.toHaveProperty('personalToken'); 20 | expect(results).not.toHaveProperty('foo'); 21 | }); 22 | 23 | it('keeps entries, pull request and important inputs', () => { 24 | const results = buildJsonOutput(input); 25 | expect(results).toEqual(expect.objectContaining({ 26 | entries, 27 | options: expect.objectContaining({ 28 | organization: inputs.org, 29 | repositories: null, 30 | periodLength: inputs.periodLength, 31 | pullRequestId: inputs.pullRequestId, 32 | }), 33 | })); 34 | }); 35 | 36 | it('uses repos when org is not sent', () => { 37 | const results = buildJsonOutput({ 38 | ...input, 39 | inputs: { ...inputs, org: '' }, 40 | }); 41 | expect(results).toEqual(expect.objectContaining({ 42 | options: expect.objectContaining({ 43 | organization: null, 44 | repositories: inputs.repos, 45 | }), 46 | })); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/interactors/__tests__/getEntries.test.js: -------------------------------------------------------------------------------- 1 | const { users, reviewStats, pullRequestStats } = require('../../../tests/mocks'); 2 | const fulfillEntries = require('../fulfillEntries'); 3 | const getPullRequestStats = require('../getPullRequestStats'); 4 | const getReviewStats = require('../getReviewStats'); 5 | const getUsers = require('../getUsers'); 6 | const mergeStats = require('../mergeStats'); 7 | const getEntries = require('../getEntries'); 8 | 9 | jest.mock('../fulfillEntries', () => jest.fn()); 10 | jest.mock('../getPullRequestStats', () => jest.fn()); 11 | jest.mock('../getReviewStats', () => jest.fn()); 12 | jest.mock('../getUsers', () => jest.fn()); 13 | jest.mock('../mergeStats', () => jest.fn()); 14 | 15 | describe('Interactors | .getEntries', () => { 16 | const core = { info: jest.fn(), debug: jest.fn() }; 17 | const pulls = ['PULL1', 'PULL2', 'PULL3']; 18 | const entries = ['ENTRY1', 'ENTRY2', 'ENTRY3']; 19 | const merged = 'MERGED'; 20 | const params = { 21 | core, 22 | pulls, 23 | excludeStr: 'EXCLUDE', 24 | includeStr: 'INCLUDE', 25 | periodLength: 'PERIOD_LENGTH', 26 | }; 27 | 28 | getUsers.mockReturnValue(users); 29 | getPullRequestStats.mockReturnValue(pullRequestStats); 30 | getReviewStats.mockReturnValue(reviewStats); 31 | mergeStats.mockReturnValue(merged); 32 | fulfillEntries.mockReturnValue(entries); 33 | 34 | beforeEach(jest.clearAllMocks); 35 | 36 | it('calls the correct interactors with the expected params', async () => { 37 | const results = await getEntries(params); 38 | expect(results).toBe(entries); 39 | expect(getUsers).toBeCalledWith(pulls, { 40 | excludeStr: params.excludeStr, 41 | includeStr: params.includeStr, 42 | }); 43 | expect(getPullRequestStats).toBeCalledWith(pulls); 44 | expect(getReviewStats).toBeCalledWith(pulls); 45 | expect(mergeStats).toBeCalledWith({ users, pullRequestStats, reviewStats }); 46 | expect(fulfillEntries).toBeCalledWith(merged, { periodLength: params.periodLength }); 47 | expect(core.info).toHaveBeenCalledTimes(3); 48 | expect(core.debug).toHaveBeenCalledTimes(3); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/interactors/__tests__/mergeStats.test.js: -------------------------------------------------------------------------------- 1 | const mergeStats = require('../mergeStats'); 2 | const { VALID_STATS } = require('../../config/stats'); 3 | const { users, reviewStats, pullRequestStats } = require('../../../tests/mocks'); 4 | 5 | describe('Interactors | .mergeStats', () => { 6 | const baseParams = { 7 | users, 8 | reviewStats, 9 | pullRequestStats, 10 | }; 11 | 12 | it('returns an array with all the stats for each user', async () => { 13 | const results = mergeStats(baseParams); 14 | expect(results.length).toEqual(users.length); 15 | 16 | results.forEach((result) => { 17 | expect(result).toHaveProperty('user'); 18 | expect(result.user).toHaveProperty('login'); 19 | expect(result).toHaveProperty('reviews'); 20 | expect(result).toHaveProperty('stats'); 21 | VALID_STATS.forEach((stat) => { 22 | expect(result.stats).toHaveProperty(stat); 23 | }); 24 | }); 25 | }); 26 | 27 | it('returns all the stats for users with data', async () => { 28 | const results = mergeStats(baseParams) 29 | .find(({ user }) => user.login === 'user1'); 30 | 31 | expect(results.stats).toEqual({ 32 | totalReviews: 4, 33 | totalComments: 1, 34 | timeToReview: 2052500, 35 | commentsPerReview: 0.25, 36 | reviewedAdditions: 1_000, 37 | reviewedDeletions: 500, 38 | reviewedLines: 1_500, 39 | openedPullRequests: 17, 40 | totalObservations: 68, 41 | medianObservations: 4, 42 | revisionSuccessRate: 0.35, 43 | additions: 100, 44 | deletions: 50, 45 | lines: 150, 46 | }); 47 | }); 48 | 49 | it('returns empty stats for users with no data', async () => { 50 | const results = mergeStats(baseParams) 51 | .find(({ user }) => user.login === 'user4'); 52 | 53 | expect(results.stats).toEqual({ 54 | totalReviews: null, 55 | totalComments: null, 56 | timeToReview: null, 57 | commentsPerReview: null, 58 | reviewedAdditions: null, 59 | reviewedDeletions: null, 60 | reviewedLines: null, 61 | openedPullRequests: null, 62 | totalObservations: null, 63 | medianObservations: null, 64 | revisionSuccessRate: null, 65 | additions: null, 66 | deletions: null, 67 | lines: null, 68 | }); 69 | }); 70 | 71 | it('returns empty array when no users passed', async () => { 72 | const results = mergeStats({ ...baseParams, users: [] }); 73 | expect(results).toEqual([]); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/interactors/__tests__/mocks/populatedReviewers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "author": { 4 | "id": "1234", 5 | "url": "https://github.com/user1", 6 | "login": "user1", 7 | "avatarUrl": "https://avatars.githubusercontent.com/u/1234" 8 | }, 9 | "reviews": [ 10 | { 11 | "submittedAt": "2021-03-12T17:41:18.000Z", 12 | "id": 611016770, 13 | "commentsCount": 0, 14 | "timeToReview": 363000, 15 | "pullRequestId": 591820759 16 | }, 17 | { 18 | "submittedAt": "2021-03-12T22:13:17.000Z", 19 | "id": 611189051, 20 | "commentsCount": 0, 21 | "timeToReview": 4674000, 22 | "pullRequestId": 591932158 23 | }, 24 | { 25 | "submittedAt": "2021-03-10T22:54:45.000Z", 26 | "id": 609241758, 27 | "commentsCount": 0, 28 | "timeToReview": 292000, 29 | "pullRequestId": 590242592 30 | }, 31 | { 32 | "submittedAt": "2021-03-29T18:25:17.000Z", 33 | "id": 623480404, 34 | "commentsCount": 1, 35 | "timeToReview": 3742000, 36 | "pullRequestId": 602948274 37 | } 38 | ], 39 | "stats": { 40 | "totalReviews": 4, 41 | "totalComments": 1, 42 | "commentsPerReview": 0.25, 43 | "timeToReview": 2052500 44 | }, 45 | "contributions": { 46 | "totalReviews": 0.8, 47 | "totalComments": 0.16666666666666666, 48 | "commentsPerReview": 0.047619047619047616, 49 | "timeToReview": 0.19515093891133825 50 | }, 51 | "urls": { 52 | "timeToReview": "https://app.flowwer.dev/charts/review-time/1" 53 | } 54 | }, 55 | { 56 | "author": { 57 | "id": "5678", 58 | "url": "https://github.com/user2", 59 | "login": "user2", 60 | "avatarUrl": "https://avatars.githubusercontent.com/u/5678" 61 | }, 62 | "reviews": [ 63 | { 64 | "submittedAt": "2021-03-15T12:14:00.000Z", 65 | "id": 611015896, 66 | "commentsCount": 5, 67 | "timeToReview": 8465000, 68 | "pullRequestId": 5918228571 69 | } 70 | ], 71 | "stats": { 72 | "totalReviews": 1, 73 | "totalComments": 5, 74 | "commentsPerReview": 5, 75 | "timeToReview": 8465000 76 | }, 77 | "contributions": { 78 | "totalReviews": 0.2, 79 | "totalComments": 0.8333333333333334, 80 | "commentsPerReview": 0.9523809523809523, 81 | "timeToReview": 0.8048490610886617 82 | }, 83 | "urls": { 84 | "timeToReview": "https://app.flowwer.dev/charts/review-time/2" 85 | } 86 | } 87 | ] 88 | -------------------------------------------------------------------------------- /src/interactors/__tests__/mocks/reviewers.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "author": "REVIEWER_1", 4 | "stats": { 5 | "totalReviews": 40, 6 | "totalComments": 1, 7 | "commentsPerReview": 0.025, 8 | "timeToReview": 2052500 9 | } 10 | }, 11 | { 12 | "author": "REVIEWER_2", 13 | "stats": { 14 | "totalReviews": 5, 15 | "totalComments": 15, 16 | "commentsPerReview": 3, 17 | "timeToReview": 25000 18 | } 19 | }, 20 | { 21 | "author": "REVIEWER_3", 22 | "stats": { 23 | "totalReviews": 37, 24 | "totalComments": 99, 25 | "commentsPerReview": 2.67, 26 | "timeToReview": 426000 27 | } 28 | } 29 | ] 30 | -------------------------------------------------------------------------------- /src/interactors/__tests__/mocks/stats.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "totalReviews": 4, 4 | "totalComments": 1, 5 | "commentsPerReview": 0.25, 6 | "timeToReview": 2052500 7 | }, 8 | { 9 | "totalReviews": 5, 10 | "totalComments": 15, 11 | "commentsPerReview": 3, 12 | "timeToReview": 25000 13 | }, 14 | { 15 | "totalReviews": 37, 16 | "totalComments": 99, 17 | "commentsPerReview": 2.67, 18 | "timeToReview": 426000 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /src/interactors/__tests__/mocks/statsSum.json: -------------------------------------------------------------------------------- 1 | { 2 | "totalReviews": 46, 3 | "totalComments": 115, 4 | "commentsPerReview": 5.92, 5 | "timeToReview": 2503500 6 | } 7 | -------------------------------------------------------------------------------- /src/interactors/__tests__/postComment.test.js: -------------------------------------------------------------------------------- 1 | const postComment = require('../postComment'); 2 | const { updatePullRequest, commentOnPullRequest } = require('../../fetchers'); 3 | 4 | jest.mock('../../fetchers', () => ({ 5 | updatePullRequest: jest.fn(), 6 | commentOnPullRequest: jest.fn(), 7 | })); 8 | 9 | describe('Interactors | .postComment', () => { 10 | beforeEach(jest.clearAllMocks); 11 | 12 | const octokit = 'OCTOKIT'; 13 | const pullRequestId = '1234'; 14 | const content = '# MARKDOWN BODY'; 15 | const baseParams = { 16 | octokit, 17 | pullRequestId, 18 | content, 19 | currentBody: null, 20 | publishAs: null, 21 | }; 22 | 23 | it('calls updatePullRequest when publishAs is "DESCRIPTION"', async () => { 24 | const publishAs = 'DESCRIPTION'; 25 | await postComment({ ...baseParams, publishAs }); 26 | expect(commentOnPullRequest).not.toBeCalled(); 27 | expect(updatePullRequest).toBeCalledTimes(1); 28 | expect(updatePullRequest).toBeCalledWith({ 29 | octokit, 30 | id: pullRequestId, 31 | body: content, 32 | }); 33 | }); 34 | 35 | it('calls commentOnPullRequest when publishAs is any other value', async () => { 36 | const publishAs = 'COMMENT'; 37 | await postComment({ ...baseParams, publishAs }); 38 | expect(updatePullRequest).not.toBeCalled(); 39 | expect(commentOnPullRequest).toBeCalledTimes(1); 40 | expect(commentOnPullRequest).toBeCalledWith({ 41 | octokit, 42 | pullRequestId, 43 | body: content, 44 | }); 45 | }); 46 | 47 | it('does nothing when publishAs is "NONE"', async () => { 48 | const publishAs = 'NONE'; 49 | await postComment({ ...baseParams, publishAs }); 50 | expect(commentOnPullRequest).not.toBeCalled(); 51 | expect(updatePullRequest).not.toBeCalled(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/interactors/__tests__/postSummary.test.js: -------------------------------------------------------------------------------- 1 | const { t } = require('../../i18n'); 2 | const postSummary = require('../postSummary'); 3 | 4 | const CONTENT = 'CONTENT'; 5 | 6 | describe('Interactors | .postSummary', () => { 7 | const debug = jest.fn(); 8 | const error = jest.fn(); 9 | const write = jest.fn(); 10 | const addRaw = jest.fn(); 11 | 12 | const core = { 13 | debug, 14 | error, 15 | summary: { 16 | addRaw, 17 | }, 18 | }; 19 | 20 | const defaultOptions = { 21 | core, 22 | content: CONTENT, 23 | }; 24 | 25 | beforeEach(() => { 26 | debug.mockClear(); 27 | error.mockClear(); 28 | write.mockClear().mockResolvedValue(true); 29 | addRaw.mockClear().mockReturnValue({ write }); 30 | }); 31 | 32 | it('writes summary to action', async () => { 33 | await postSummary(defaultOptions); 34 | 35 | expect(addRaw).toHaveBeenCalledTimes(1); 36 | expect(addRaw).toHaveBeenCalledWith(`\n${CONTENT}`, true); 37 | expect(write).toHaveBeenCalledTimes(1); 38 | expect(write).toHaveBeenCalledWith(); 39 | }); 40 | 41 | it('debugs starts and and', async () => { 42 | await postSummary(defaultOptions); 43 | 44 | expect(debug).toHaveBeenCalledTimes(2); 45 | expect(debug).toHaveBeenCalledWith( 46 | t('integrations.summary.logs.posting', { content: CONTENT }), 47 | ); 48 | expect(debug).toHaveBeenCalledWith( 49 | t('integrations.summary.logs.success'), 50 | ); 51 | }); 52 | 53 | it('logs error when write fails', async () => { 54 | const e = new Error('Oops!... I did it again'); 55 | write.mockRejectedValue(e); 56 | 57 | await postSummary(defaultOptions); 58 | 59 | expect(debug).toHaveBeenCalledTimes(1); 60 | expect(error).toHaveBeenCalledTimes(1); 61 | expect(error).toHaveBeenCalledWith( 62 | t('integrations.summary.errors.writeFailed', { error: e }), 63 | ); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/interactors/__tests__/postWebhook.test.js: -------------------------------------------------------------------------------- 1 | const Fetchers = require('../../fetchers'); 2 | const postWebhook = require('../postWebhook'); 3 | 4 | jest.mock('../../fetchers', () => ({ postToWebhook: jest.fn(() => Promise.resolve()) })); 5 | 6 | describe('Interactors | .postWebhook', () => { 7 | const debug = jest.fn(); 8 | const error = jest.fn(); 9 | const payload = 'PAYLOAD'; 10 | const webhook = 'https://somewebhook.com/'; 11 | const core = { 12 | debug, 13 | error, 14 | }; 15 | 16 | const defaultOptions = { 17 | core, 18 | payload, 19 | webhook, 20 | }; 21 | 22 | beforeEach(() => { 23 | debug.mockClear(); 24 | error.mockClear(); 25 | Fetchers.postToWebhook.mockClear(); 26 | }); 27 | 28 | describe('when integration is not configured', () => { 29 | const expectDisabledIntegration = () => { 30 | expect(debug).toHaveBeenCalled(); 31 | expect(Fetchers.postToWebhook).not.toHaveBeenCalled(); 32 | }; 33 | 34 | it('logs a message when webhook is not passed', async () => { 35 | await postWebhook({ ...defaultOptions, webhook: null }); 36 | expectDisabledIntegration(); 37 | }); 38 | }); 39 | 40 | describe('when integration is enabled', () => { 41 | it('posts successfully to webhook', async () => { 42 | await postWebhook({ ...defaultOptions }); 43 | expect(error).not.toHaveBeenCalled(); 44 | expect(Fetchers.postToWebhook).toBeCalledTimes(1); 45 | expect(Fetchers.postToWebhook).toBeCalledWith({ 46 | payload, 47 | webhook, 48 | }); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/interactors/alreadyPublished.js: -------------------------------------------------------------------------------- 1 | const { t } = require('../i18n'); 2 | 3 | const TITLE_REGEXP = new RegExp(`(^|\\n)(## ${t('table.title')})\\n`); 4 | 5 | const isActionComment = (body) => body && TITLE_REGEXP.test(body); 6 | 7 | module.exports = (pullRequest) => { 8 | if (!pullRequest) return false; 9 | const { body, comments } = pullRequest || {}; 10 | const bodies = [body, ...(comments || []).map((c) => c.body)]; 11 | return bodies.some(isActionComment); 12 | }; 13 | -------------------------------------------------------------------------------- /src/interactors/buildComment.js: -------------------------------------------------------------------------------- 1 | const { t } = require('../i18n'); 2 | const { buildSources } = require('../utils'); 3 | const { getGithubServerUrl } = require('../config'); 4 | 5 | const buildGithubLink = ({ description, path }) => `[${description}](${getGithubServerUrl()}/${path})`; 6 | 7 | module.exports = ({ 8 | markdownTable, 9 | org, 10 | repos, 11 | isSponsor, 12 | periodLength, 13 | }) => { 14 | const sources = buildSources({ buildGithubLink, org, repos }); 15 | const message = t('table.subtitle', { sources, count: periodLength }); 16 | const footer = isSponsor ? '' : `\n\n${t('table.footer')}`; 17 | return `## ${t('table.title')}\n${message}:\n${markdownTable}${footer}`; 18 | }; 19 | -------------------------------------------------------------------------------- /src/interactors/buildJsonOutput.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ 2 | inputs, 3 | entries, 4 | }) => ({ 5 | entries, 6 | options: { 7 | organization: inputs.org || null, 8 | repositories: inputs.org ? null : inputs.repos, 9 | periodLength: inputs.periodLength, 10 | pullRequestId: inputs.pullRequestId, 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /src/interactors/buildMarkdown/__tests__/getMarkdownContent.test.js: -------------------------------------------------------------------------------- 1 | const { t } = require('../../../i18n'); 2 | const { table } = require('../../../../tests/mocks'); 3 | const getMarkdownContent = require('../getMarkdownContent'); 4 | 5 | const HEADERS = [ 6 | '', 7 | t('table.columns.username'), 8 | t('table.columns.totalReviews'), 9 | t('table.columns.timeToReview'), 10 | t('table.columns.totalComments'), 11 | t('table.columns.commentsPerReview'), 12 | t('table.columns.openedPullRequests'), 13 | ]; 14 | 15 | const AVATAR1_SM = ''; 16 | const AVATAR1_LG = ''; 17 | 18 | const ROW1_SIMPLE = [ 19 | AVATAR1_LG, 20 | 'user1
🥇', 21 | '**4**
▀▀▀▀▀▀▀▀', 22 | '[34m](https://app.flowwer.dev/charts/review-time/1)
▀▀', 23 | '1
▀▀', 24 | '0.25
', 25 | '7
▀▀', 26 | ]; 27 | 28 | const ROW1_NO_CHARTS = [ 29 | AVATAR1_SM, 30 | 'user1', 31 | '**4**', 32 | '[34m](https://app.flowwer.dev/charts/review-time/1)', 33 | '1', 34 | '0.25', 35 | '7', 36 | ]; 37 | 38 | describe('Interactors | .buildMarkdown | .getMarkdownContent', () => { 39 | it('returns the default case data', () => { 40 | const response = getMarkdownContent({ table }); 41 | expect(response.length).toEqual(table.rows.length + 1); 42 | expect(response[0]).toEqual(HEADERS); 43 | expect(response[1]).toEqual(ROW1_SIMPLE); 44 | }); 45 | 46 | it('sets a small avatar size when there a no charts', () => { 47 | const tableCopy = { ...table }; 48 | tableCopy.rows[0].user.emoji = null; 49 | 50 | const response = getMarkdownContent({ table: tableCopy }); 51 | expect(response[1][0]).toEqual(AVATAR1_SM); 52 | expect(response[1][1]).toEqual('user1'); 53 | }); 54 | 55 | it('does not add a character chart when stats have no chart value', () => { 56 | const tableCopy = { ...table }; 57 | tableCopy.rows[0].user.emoji = null; 58 | tableCopy.rows[0].stats = tableCopy.rows[0].stats 59 | .map((stat) => ({ ...stat, chartValue: null })); 60 | 61 | const response = getMarkdownContent({ table: tableCopy }); 62 | expect(response[1]).toEqual(ROW1_NO_CHARTS); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/interactors/buildMarkdown/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | const { table } = require('../../../../tests/mocks'); 2 | const buildTable = require('../index'); 3 | 4 | const EXPECTED_RESPONSE = `| | User | Total reviews | Time to review | Total comments | Comments per review | Opened PRs | 5 | | ----------------------------------------------------------------------------------------------------------- | ------------ | ------------------ | ------------------------------------------------------------------ | ------------------ | ------------------- | ------------------- | 6 | | | user1
🥇 | **4**
▀▀▀▀▀▀▀▀ | [34m](https://app.flowwer.dev/charts/review-time/1)
▀▀ | 1
▀▀ | 0.25
| 7
▀▀ | 7 | | | user2
🥈 | 1
▀▀ | [2h 21m](https://app.flowwer.dev/charts/review-time/2)
▀▀▀▀▀▀▀ | **5**
▀▀▀▀▀▀▀▀ | **5**
▀▀▀▀▀▀▀▀ | 3
▀ | 8 | | | user3
🥉 | 0
| [**17m**](https://app.flowwer.dev/charts/review-time/3)
▀ | 0
| 1
▀▀ | **30**
▀▀▀▀▀▀▀▀ |`; 9 | 10 | describe('Interactors | .buildTable', () => { 11 | it('builds a formatted markdown table', () => { 12 | const response = buildTable({ table }); 13 | expect(response).toEqual(EXPECTED_RESPONSE); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/interactors/buildMarkdown/getMarkdownContent.js: -------------------------------------------------------------------------------- 1 | const { isNil } = require('../../utils'); 2 | 3 | const EMOJIS_MAP = { 4 | medal1: String.fromCodePoint(0x1F947), /* 🥇 */ 5 | medal2: String.fromCodePoint(0x1F948), /* 🥈 */ 6 | medal3: String.fromCodePoint(0x1F949), /* 🥉 */ 7 | }; 8 | 9 | const AVATAR_SIZE = { 10 | SMALL: 20, 11 | LARGE: 32, 12 | }; 13 | 14 | const NEW_LINE = '
'; 15 | 16 | const CHART_CHARACTER = '▀'; 17 | 18 | const CHART_MAX_LENGTH = 10; 19 | 20 | const generateChart = (percentage = 0) => { 21 | const length = Math.round(percentage * CHART_MAX_LENGTH); 22 | const chart = Array(length).fill(CHART_CHARACTER).join(''); 23 | return `${NEW_LINE}${chart}`; 24 | }; 25 | 26 | const buildLink = (href, content) => `${content}`; 27 | 28 | const buildImage = (src, width) => ``; 29 | 30 | const markdownBold = (value) => `**${value}**`; 31 | 32 | const markdownLink = (text, link) => `[${text}](${link})`; 33 | 34 | const buildHeader = ({ text }) => (text); 35 | 36 | const buildHeaders = ({ headers }) => [ 37 | '', // Empty header for the avatar 38 | ...headers.map(buildHeader), 39 | ]; 40 | 41 | const buildAvatar = ({ image, link, avatarSize }) => buildLink(link, buildImage(image, avatarSize)); 42 | 43 | const buildUsername = ({ text, emoji }) => (emoji ? `${text}${NEW_LINE}${EMOJIS_MAP[emoji]}` : text); 44 | 45 | const buildStat = ({ 46 | text, 47 | link, 48 | chartValue, 49 | bold, 50 | }) => { 51 | const bolded = bold ? markdownBold(text) : text; 52 | const linked = link ? markdownLink(bolded, link) : bolded; 53 | const chart = isNil(chartValue) ? '' : generateChart(chartValue); 54 | return `${linked}${chart}`; 55 | }; 56 | 57 | const buildRows = ({ table, avatarSize }) => table.rows.map((row) => [ 58 | buildAvatar({ ...row.user, avatarSize }), 59 | buildUsername(row.user), 60 | ...row.stats.map((stat) => buildStat(stat)), 61 | ]); 62 | 63 | module.exports = ({ 64 | table, 65 | }) => { 66 | const firstUser = table.rows[0].user; 67 | const avatarSize = firstUser.emoji ? AVATAR_SIZE.LARGE : AVATAR_SIZE.SMALL; 68 | 69 | const headers = buildHeaders(table); 70 | const rows = buildRows({ table, avatarSize }); 71 | 72 | return [ 73 | headers, 74 | ...rows, 75 | ]; 76 | }; 77 | -------------------------------------------------------------------------------- /src/interactors/buildMarkdown/index.js: -------------------------------------------------------------------------------- 1 | const toMarkdownTable = require('markdown-table'); 2 | const getMarkdownContent = require('./getMarkdownContent'); 3 | 4 | module.exports = ({ table }) => { 5 | const content = getMarkdownContent({ table }); 6 | return toMarkdownTable(content); 7 | }; 8 | -------------------------------------------------------------------------------- /src/interactors/buildTable/__tests__/calculateBests.test.js: -------------------------------------------------------------------------------- 1 | const { entries } = require('../../../../tests/mocks'); 2 | const calculateBests = require('../calculateBests'); 3 | 4 | describe('Interactors | .buildTable | .calculateBests', () => { 5 | it('returns the best stats for DESC sort order', () => { 6 | const response = calculateBests(entries); 7 | expect(response).toMatchObject({ 8 | totalReviews: 4, 9 | totalComments: 5, 10 | commentsPerReview: 5, 11 | openedPullRequests: 30, 12 | }); 13 | }); 14 | 15 | it('returns the best stats for ASC sort order', () => { 16 | const response = calculateBests(entries); 17 | expect(response).toMatchObject({ 18 | timeToReview: 1_000_000, 19 | }); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/interactors/buildTable/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | const { entries } = require('../../../../tests/mocks'); 2 | const { VALID_STATS } = require('../../../config/stats'); 3 | const getTableData = require('../getTableData'); 4 | const buildTable = require('../index'); 5 | 6 | jest.mock('../getTableData', () => jest.fn()); 7 | 8 | describe('Interactors | .buildTable', () => { 9 | const defaultParams = { 10 | entries, 11 | limit: null, 12 | sortBy: VALID_STATS[0], 13 | mainStats: VALID_STATS, 14 | disableLinks: true, 15 | displayCharts: false, 16 | }; 17 | 18 | getTableData.mockImplementation(jest.requireActual('../getTableData')); 19 | 20 | beforeEach(() => { 21 | getTableData.mockClear(); 22 | }); 23 | 24 | it('limits the results', () => { 25 | const response1 = buildTable(defaultParams); 26 | expect(response1.rows.length).toEqual(entries.length); 27 | 28 | const limit = 1; 29 | const response2 = buildTable({ ...defaultParams, limit }); 30 | expect(response2.rows.length).toEqual(limit); 31 | }); 32 | 33 | it('sorts data by the given key', () => { 34 | const response = buildTable(defaultParams); 35 | expect(response.rows[0].user.text).toEqual('user1'); 36 | expect(response.rows[1].user.text).toEqual('user2'); 37 | expect(response.rows[2].user.text).toEqual('user3'); 38 | }); 39 | 40 | it('calls build table data with the correct params', () => { 41 | buildTable(defaultParams); 42 | expect(getTableData).toHaveBeenCalledWith(expect.objectContaining({ 43 | entries, 44 | bests: expect.any(Object), 45 | mainStats: defaultParams.mainStats, 46 | disableLinks: defaultParams.disableLinks, 47 | displayCharts: defaultParams.displayCharts, 48 | })); 49 | }); 50 | 51 | it('removes the entries with empty stats', () => { 52 | const response = buildTable({ 53 | ...defaultParams, 54 | mainStats: [], 55 | }); 56 | expect(response.rows.length).toEqual(0); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/interactors/buildTable/__tests__/removeEmpty.test.js: -------------------------------------------------------------------------------- 1 | const { entries } = require('../../../../tests/mocks'); 2 | const removeEmpty = require('../removeEmpty'); 3 | 4 | describe('Interactors | .buildTable | .removeEmpty', () => { 5 | const mainStats = ['timeToReview', 'totalReviews', 'totalComments']; 6 | 7 | it('keeps all the entry when no stats are empty', () => { 8 | const response = removeEmpty(entries, mainStats); 9 | expect(response).toHaveLength(entries.length); 10 | expect(response).toMatchObject(entries); 11 | }); 12 | 13 | it('removes the entries if they have no requested stats', () => { 14 | const input = entries.map((entry) => ({ 15 | ...entry, 16 | stats: { 17 | ...entry.stats, 18 | timeToReview: null, 19 | totalReviews: null, 20 | totalComments: null, 21 | }, 22 | })); 23 | const response = removeEmpty(input, mainStats); 24 | expect(response).toEqual([]); 25 | }); 26 | 27 | it('keeps the entries if they have some requested stats', () => { 28 | const input = entries.map((entry) => ({ 29 | ...entry, 30 | stats: { 31 | ...entry.stats, 32 | totalComments: 0, 33 | }, 34 | })); 35 | const response = removeEmpty(input, mainStats); 36 | expect(response).toHaveLength(entries.length); 37 | expect(response).toMatchObject(input); 38 | }); 39 | 40 | it('removes all if no stats are requested', () => { 41 | const response = removeEmpty(entries, []); 42 | expect(response).toEqual([]); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/interactors/buildTable/__tests__/sortByStats.test.js: -------------------------------------------------------------------------------- 1 | const { entries } = require('../../../../tests/mocks'); 2 | const sortByStats = require('../sortByStats'); 3 | 4 | describe('Interactors | .buildTable | .sortByStats', () => { 5 | const expectToMatchOrder = (results, expectedOrder) => { 6 | const actualOrder = results.map((r) => r.user.id); 7 | expect(actualOrder).toEqual(expectedOrder); 8 | }; 9 | 10 | const getLastUserId = (results) => results[results.length - 1].user.id; 11 | 12 | it('sorts the reviewers by "totalReviews" when sortBy is not specified', () => { 13 | const sortBy = null; 14 | const response = sortByStats(entries, sortBy); 15 | expectToMatchOrder(response, [ 16 | 'user1', 17 | 'user2', 18 | 'user3', 19 | ]); 20 | }); 21 | 22 | it('sorts the entries by "timeToReview"', () => { 23 | const sortBy = 'timeToReview'; 24 | const response = sortByStats(entries, sortBy); 25 | expectToMatchOrder(response, [ 26 | 'user3', 27 | 'user1', 28 | 'user2', 29 | ]); 30 | }); 31 | 32 | it('sorts the entries by "totalReviews"', () => { 33 | const sortBy = 'totalReviews'; 34 | const response = sortByStats(entries, sortBy); 35 | expectToMatchOrder(response, [ 36 | 'user1', 37 | 'user2', 38 | 'user3', 39 | ]); 40 | }); 41 | 42 | it('sorts the entries by "totalComments"', () => { 43 | const sortBy = 'totalComments'; 44 | const response = sortByStats(entries, sortBy); 45 | expectToMatchOrder(response, [ 46 | 'user2', 47 | 'user1', 48 | 'user3', 49 | ]); 50 | }); 51 | 52 | it('sorts the entries by "openedPullRequests"', () => { 53 | const sortBy = 'openedPullRequests'; 54 | const response = sortByStats(entries, sortBy); 55 | expectToMatchOrder(response, [ 56 | 'user3', 57 | 'user1', 58 | 'user2', 59 | ]); 60 | }); 61 | 62 | it('nil values always go last', () => { 63 | const list = [...entries, { user: { id: 'user4' }, stats: {} }]; 64 | const response1 = sortByStats(list, 'timeToReview'); 65 | expect(getLastUserId(response1)).toEqual('user4'); 66 | 67 | const response2 = sortByStats(list, 'totalComments'); 68 | expect(getLastUserId(response2)).toEqual('user4'); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/interactors/buildTable/calculateBests.js: -------------------------------------------------------------------------------- 1 | const { STATS, VALID_STATS } = require('../../config/stats'); 2 | 3 | const getBest = (values, sortOrder) => (sortOrder === 'DESC' ? Math.max(...values) : Math.min(...values)); 4 | 5 | const calculateBests = (entries) => { 6 | const allStats = entries.map((r) => r.stats); 7 | 8 | return VALID_STATS.reduce((prev, statName) => { 9 | const values = allStats.map((v) => v[statName]); 10 | const statConfig = STATS[statName]; 11 | const best = getBest(values, statConfig.sortOrder); 12 | return { ...prev, [statName]: best }; 13 | }, {}); 14 | }; 15 | 16 | module.exports = calculateBests; 17 | -------------------------------------------------------------------------------- /src/interactors/buildTable/getTableData.js: -------------------------------------------------------------------------------- 1 | const { t } = require('../../i18n'); 2 | const { STATS } = require('../../config/stats'); 3 | const { isNil } = require('../../utils'); 4 | 5 | const NA = '-'; 6 | 7 | const EMOJIS = [ 8 | 'medal1', 9 | 'medal2', 10 | 'medal3', 11 | ]; 12 | 13 | const buildUser = ({ index, user, displayCharts }) => ({ 14 | link: user.url, 15 | image: user.avatarUrl, 16 | text: user.login, 17 | emoji: displayCharts ? (EMOJIS[index] || null) : null, 18 | }); 19 | 20 | const buildHeaders = ({ mainStats }) => { 21 | const usernameHeader = { text: t('table.columns.username') }; 22 | const statsHeaders = mainStats.map((statName) => ({ text: t(`table.columns.${statName}`) })); 23 | return [usernameHeader, ...statsHeaders]; 24 | }; 25 | 26 | module.exports = ({ 27 | mainStats, 28 | bests, 29 | entries, 30 | disableLinks = false, 31 | displayCharts = false, 32 | }) => { 33 | const buildStats = ({ entry }) => mainStats.map((key) => { 34 | const statConfig = STATS[key]; 35 | const value = entry.stats[key]; 36 | const link = disableLinks ? null : (entry.urls[key] || null); 37 | const text = isNil(value) ? NA : statConfig.parser(value); 38 | const chartValue = displayCharts ? entry.contributions[key] : null; 39 | 40 | return { 41 | text, 42 | link, 43 | chartValue, 44 | bold: bests[key] === value, 45 | }; 46 | }); 47 | 48 | const buildRow = ({ entry, index }) => { 49 | const user = buildUser({ displayCharts, index, user: entry.user }); 50 | const stats = buildStats({ entry }); 51 | 52 | return { 53 | user, 54 | stats, 55 | }; 56 | }; 57 | 58 | const execute = () => { 59 | const headers = buildHeaders({ mainStats }); 60 | const rows = entries.map((entry, index) => buildRow({ 61 | entry, 62 | index, 63 | })); 64 | 65 | return { 66 | headers, 67 | rows, 68 | }; 69 | }; 70 | 71 | return execute(); 72 | }; 73 | -------------------------------------------------------------------------------- /src/interactors/buildTable/index.js: -------------------------------------------------------------------------------- 1 | const calculateBests = require('./calculateBests'); 2 | const getTableData = require('./getTableData'); 3 | const removeEmpty = require('./removeEmpty'); 4 | const sortByStats = require('./sortByStats'); 5 | 6 | const applyLimit = (data, limit) => (limit > 0 ? data.slice(0, limit) : data); 7 | 8 | module.exports = ({ 9 | limit, 10 | entries, 11 | sortBy, 12 | mainStats, 13 | disableLinks, 14 | displayCharts, 15 | }) => { 16 | const execute = () => { 17 | const sortByStat = sortBy || mainStats[0]; 18 | const filtered = removeEmpty(entries, mainStats); 19 | const sorted = applyLimit(sortByStats(filtered, sortByStat), limit); 20 | const bests = calculateBests(sorted); 21 | 22 | return getTableData({ 23 | mainStats, 24 | bests, 25 | disableLinks, 26 | displayCharts, 27 | entries: sorted, 28 | }); 29 | }; 30 | 31 | return execute(); 32 | }; 33 | -------------------------------------------------------------------------------- /src/interactors/buildTable/removeEmpty.js: -------------------------------------------------------------------------------- 1 | const removeEmpty = (entries, mainStats) => entries 2 | .filter((entry) => mainStats.some((stat) => !!entry.stats[stat])); 3 | 4 | module.exports = removeEmpty; 5 | -------------------------------------------------------------------------------- /src/interactors/buildTable/sortByStats.js: -------------------------------------------------------------------------------- 1 | const { STATS } = require('../../config/stats'); 2 | const { isNil } = require('../../utils'); 3 | 4 | const buildSort = (statConfig) => (a, b) => { 5 | const { id, sortOrder } = statConfig; 6 | const { stats: statsA = {} } = a; 7 | const { stats: statsB = {} } = b; 8 | const multiplier = sortOrder === 'DESC' ? -1 : 1; 9 | if (isNil(statsA[id])) return 1; 10 | return multiplier * (statsA[id] - statsB[id]); 11 | }; 12 | 13 | const sortByStats = (reviewers, sortBy) => { 14 | const statConfig = STATS[sortBy] || STATS.totalReviews; 15 | const sortFn = buildSort(statConfig); 16 | return reviewers.sort(sortFn); 17 | }; 18 | 19 | module.exports = sortByStats; 20 | -------------------------------------------------------------------------------- /src/interactors/checkSponsorship/__tests__/getLogins.test.js: -------------------------------------------------------------------------------- 1 | const getLogins = require('../getLogins'); 2 | 3 | const org = 'organization'; 4 | const repos = ['org1/repo1', 'org1/repo2', 'org2/repo3']; 5 | const repoNames = ['org1', 'org2']; 6 | 7 | describe('Interactors | checkSponsorship | .getLogins', () => { 8 | it('returns empty array when nothing is sent', () => { 9 | const expected = []; 10 | const response = getLogins(); 11 | expect(response).toEqual(expected); 12 | }); 13 | 14 | it('returns array with organization name when is sent', () => { 15 | const expected = [org]; 16 | const response = getLogins({ org }); 17 | expect(response).toEqual(expected); 18 | }); 19 | 20 | it('returns the owners of the repos when there are multiples', () => { 21 | const expected = [...repoNames]; 22 | const response = getLogins({ repos }); 23 | expect(response).toEqual(expected); 24 | }); 25 | 26 | it('returns both the organization an repo owners', () => { 27 | const expected = [org, ...repoNames]; 28 | const response = getLogins({ org, repos }); 29 | expect(response).toEqual(expected); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/interactors/checkSponsorship/__tests__/isExternalSponsor.test.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios').default; 2 | const isExternalSponsor = require('../isExternalSponsor'); 3 | 4 | const sponsors = [ 5 | '4cf2d30b6327df1b462663c7611de22f', 6 | 'b9cf4cc40150a529e71058bd59f0ed0b', 7 | 'cf681c59a1d2b1817befafc0d9482ba1', 8 | ]; 9 | 10 | jest.mock('axios', () => ({ 11 | default: { 12 | get: jest.fn(), 13 | }, 14 | })); 15 | 16 | jest.mock('@actions/core', () => ({ 17 | debug: jest.fn(), 18 | error: jest.fn(), 19 | })); 20 | 21 | describe('Interactors | checkSponsorship | .isExternalSponsor', () => { 22 | beforeAll(() => { 23 | axios.get.mockImplementation(() => Promise.resolve({ 24 | data: sponsors, 25 | })); 26 | }); 27 | 28 | it('returns false when sending nothing', async () => { 29 | const expected = false; 30 | const response = await isExternalSponsor(); 31 | expect(response).toEqual(expected); 32 | }); 33 | 34 | it('returns false when no user is sponsor', async () => { 35 | const input = new Set(['noSponsor1', 'noSponsor2']); 36 | const expected = false; 37 | const response = await isExternalSponsor(input); 38 | expect(response).toEqual(expected); 39 | }); 40 | 41 | it('returns true when al least one user is sponsor', async () => { 42 | const input = new Set(['noSponsor1', 'sponsors']); 43 | const expected = true; 44 | const response = await isExternalSponsor(input); 45 | expect(response).toEqual(expected); 46 | }); 47 | 48 | it('returns true when including an offline sponsor', async () => { 49 | const input = new Set(['noSponsor1', 'offlineSponsor']); 50 | const expected = true; 51 | const response = await isExternalSponsor(input); 52 | expect(response).toEqual(expected); 53 | }); 54 | 55 | it('returns a response even when fetch fails', async () => { 56 | global.fetch = jest.fn().mockImplementation(() => Promise.reject(new Error('Fetch failed'))); 57 | const input = new Set(['noSponsor1', 'offlineSponsor']); 58 | const expected = true; 59 | const response = await isExternalSponsor(input); 60 | expect(response).toEqual(expected); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/interactors/checkSponsorship/__tests__/isSponsoring.test.js: -------------------------------------------------------------------------------- 1 | const isSponsoring = require('../isSponsoring'); 2 | 3 | describe('Interactors | checkSponsorship | .isSponsoring', () => { 4 | it('returns false when sending nothing', () => { 5 | const expected = false; 6 | const response = isSponsoring(); 7 | expect(response).toEqual(expected); 8 | }); 9 | 10 | it('returns false when no key is true', () => { 11 | const input = { 12 | sponsor1: false, 13 | sponsor2: false, 14 | }; 15 | const expected = false; 16 | const response = isSponsoring(input); 17 | expect(response).toEqual(expected); 18 | }); 19 | 20 | it('returns true when al least one key is true', () => { 21 | const input = { 22 | sponsor1: false, 23 | sponsor2: true, 24 | }; 25 | const expected = true; 26 | const response = isSponsoring(input); 27 | expect(response).toEqual(expected); 28 | }); 29 | 30 | it('returns false for truthy values', () => { 31 | const input = { 32 | sponsor1: false, 33 | sponsor2: 'truthy', 34 | }; 35 | const expected = false; 36 | const response = isSponsoring(input); 37 | expect(response).toEqual(expected); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/interactors/checkSponsorship/getLogins.js: -------------------------------------------------------------------------------- 1 | const { getRepoOwner } = require('../../utils/repos'); 2 | 3 | module.exports = ({ org, repos } = {}) => { 4 | const logins = new Set(); 5 | if (org) logins.add(org); 6 | (repos || []).forEach((repo) => logins.add(getRepoOwner(repo))); 7 | return [...logins]; 8 | }; 9 | -------------------------------------------------------------------------------- /src/interactors/checkSponsorship/index.js: -------------------------------------------------------------------------------- 1 | const { fetchSponsorships } = require('../../fetchers'); 2 | const getLogins = require('./getLogins'); 3 | const isSponsoring = require('./isSponsoring'); 4 | const isExternalSponsor = require('./isExternalSponsor'); 5 | 6 | module.exports = async ({ 7 | octokit, 8 | org, 9 | repos, 10 | }) => { 11 | const logins = getLogins({ org, repos }); 12 | const { user } = await fetchSponsorships({ octokit, logins }); 13 | return isSponsoring(user) || isExternalSponsor(logins); 14 | }; 15 | -------------------------------------------------------------------------------- /src/interactors/checkSponsorship/isExternalSponsor.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios').default; 2 | const crypto = require('crypto'); 3 | const core = require('@actions/core'); 4 | const { t } = require('../../i18n'); 5 | 6 | // A list of organizations which are sponsoring this project outside Github 💙 7 | // (hashed to keep them private) 8 | const FILE_URL = 'https://raw.githubusercontent.com/manuelmhtr/private-sponsors/main/list.json'; 9 | const offlineSponsors = new Set([ 10 | 'd6ffa1c8205ff50605752d1fff1fa180', 11 | ]); 12 | 13 | const getHash = (str) => crypto 14 | .createHash('md5') 15 | .update(str.toLowerCase()) 16 | .digest('hex'); 17 | 18 | // Get a json file from a url 19 | const getList = async (url) => { 20 | try { 21 | const response = await axios.get(url); 22 | const data = response.data || []; 23 | core.debug(t('execution.sponsors.external.fetch.success', { data })); 24 | return new Set([...data, ...offlineSponsors]); 25 | } catch (error) { 26 | core.error(t('execution.sponsors.external.fetch.error', { error })); 27 | return offlineSponsors; 28 | } 29 | }; 30 | 31 | module.exports = async (logins) => { 32 | const list = await getList(FILE_URL); 33 | return [...(logins || [])] 34 | .some((login) => list.has(getHash(login))); 35 | }; 36 | -------------------------------------------------------------------------------- /src/interactors/checkSponsorship/isSponsoring.js: -------------------------------------------------------------------------------- 1 | module.exports = (list = {}) => Object 2 | .values(list) 3 | .some((value) => value === true); 4 | -------------------------------------------------------------------------------- /src/interactors/fulfillEntries/__tests__/buildReviewTimeLink.test.js: -------------------------------------------------------------------------------- 1 | const { entries } = require('../../../../tests/mocks'); 2 | const buildReviewTimeLink = require('../buildReviewTimeLink'); 3 | 4 | const [entry] = entries; 5 | 6 | const SUCCESSFUL_LINK = "https://app.flowwer.dev/charts/review-time/~(u~(i~'user1~n~'user1)~p~30~r~(~(d~'qprzn9~t~'84)~(d~'qpvagu~t~'a3)~(d~'qpvn25~t~'3lu)~(d~'qqqtu5~t~'2vy)))"; 7 | 8 | const EMPTY_LINK = "https://app.flowwer.dev/charts/review-time/~(u~(i~'user1~n~'user1)~p~30~r~(~))"; 9 | 10 | const MAX_LENGTH = 1024; 11 | 12 | const buildReview = (submittedAt) => ({ 13 | timeToReview: 1000, 14 | submittedAt: new Date(submittedAt).toISOString(), 15 | }); 16 | 17 | describe('Interactors | .buildTable | .buildReviewTimeLink', () => { 18 | const period = 30; 19 | 20 | it('builds the link correctly', () => { 21 | const response = buildReviewTimeLink(entry, period); 22 | 23 | expect(response).toEqual(SUCCESSFUL_LINK); 24 | }); 25 | 26 | it('builds a link event with empty reviews', () => { 27 | const emptyReviewer = { ...entry, reviews: null }; 28 | const response = buildReviewTimeLink(emptyReviewer, period); 29 | 30 | expect(response).toEqual(EMPTY_LINK); 31 | }); 32 | 33 | it('limits the url to less than 1,024 characters', () => { 34 | const reviews = Array(100).fill().map((_e, index) => buildReview(index * 1000)); 35 | const response = buildReviewTimeLink({ ...entry, reviews }, period); 36 | expect(response.length <= MAX_LENGTH).toEqual(true); 37 | expect(MAX_LENGTH - response.length < 16).toEqual(true); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/interactors/fulfillEntries/__tests__/calculateTotals.test.js: -------------------------------------------------------------------------------- 1 | const stats = require('../../__tests__/mocks/stats.json'); 2 | const statsSum = require('../../__tests__/mocks/statsSum.json'); 3 | const calculateTotals = require('../calculateTotals'); 4 | 5 | describe('Interactors | .buildTable | .calculateTotals', () => { 6 | it('sums all the stats in a array', () => { 7 | const response = calculateTotals(stats); 8 | expect(response).toEqual(statsSum); 9 | }); 10 | 11 | it('returns the correct sum event when data contains nulls', () => { 12 | const withNulls = { 13 | totalReviews: undefined, 14 | totalComments: null, 15 | commentsPerReview: null, 16 | timeToReview: 0, 17 | }; 18 | const response = calculateTotals([...stats, withNulls]); 19 | expect(response).toEqual(statsSum); 20 | }); 21 | 22 | it('returns the correct sum event when data contains an empty object', () => { 23 | const empty = {}; 24 | const response = calculateTotals([...stats, empty]); 25 | expect(response).toEqual(statsSum); 26 | }); 27 | 28 | it('returns all stats in zeros when receiving an empty one', () => { 29 | const response = calculateTotals([]); 30 | expect(response).toEqual({ 31 | totalReviews: 0, 32 | totalComments: 0, 33 | commentsPerReview: 0, 34 | timeToReview: 0, 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/interactors/fulfillEntries/__tests__/getContributions.test.js: -------------------------------------------------------------------------------- 1 | const statsSum = require('../../__tests__/mocks/statsSum.json'); 2 | const reviewers = require('../../__tests__/mocks/reviewers.json'); 3 | const getContributions = require('../getContributions'); 4 | 5 | const [reviewer] = reviewers; 6 | 7 | describe('Interactors | .buildTable | .getContributions', () => { 8 | it('adds the percentage of each stat vs the total', () => { 9 | const response = getContributions(reviewer, statsSum); 10 | expect(response).toMatchObject({ 11 | commentsPerReview: 0.004222972972972973, 12 | totalComments: 0.008695652173913044, 13 | totalReviews: 0.8695652173913043, 14 | }); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/interactors/fulfillEntries/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | const { entries } = require('../../../../tests/mocks'); 2 | const fulfillEntries = require('../index'); 3 | 4 | describe('Interactors | .fulfillEntries', () => { 5 | const periodLength = 30; 6 | 7 | it('adds contributions to each reviewer', () => { 8 | const response = fulfillEntries(entries, { periodLength }); 9 | expect(response.length).toEqual(entries.length); 10 | 11 | response.forEach((reviewer) => { 12 | expect(reviewer).toHaveProperty('contributions'); 13 | }); 14 | }); 15 | 16 | it('adds urls to each reviewer', () => { 17 | const response = fulfillEntries(entries, { periodLength }); 18 | expect(response.length).toEqual(entries.length); 19 | 20 | response.forEach((reviewer) => { 21 | expect(reviewer).toHaveProperty('urls'); 22 | expect(reviewer.urls).toHaveProperty('timeToReview'); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/interactors/fulfillEntries/buildReviewTimeLink.js: -------------------------------------------------------------------------------- 1 | const JSURL = require('jsurl'); 2 | 3 | const URL = 'https://app.flowwer.dev/charts/review-time/'; 4 | const MAX_URI_LENGTH = 1024; 5 | const CHARS_PER_REVIEW = 16; 6 | 7 | const toSeconds = (ms) => Math.round(ms / 1000); 8 | 9 | const compressInt = (int) => int.toString(36); 10 | 11 | const compressDate = (date) => compressInt(Math.round(date.getTime() / 1000)); 12 | 13 | const parseReview = ({ submittedAt, timeToReview }) => ({ 14 | d: compressDate(submittedAt), 15 | t: compressInt(toSeconds(timeToReview)), 16 | }); 17 | 18 | const buildUri = ({ user, period, reviews }) => { 19 | const data = JSURL.stringify({ 20 | u: { 21 | i: `${user.id}`, 22 | n: user.login, 23 | }, 24 | p: period, 25 | r: reviews, 26 | }); 27 | 28 | const uri = `${URL}${data}`; 29 | const exceededLength = uri.length - MAX_URI_LENGTH; 30 | if (exceededLength <= 0) return uri; 31 | 32 | // Remove at least one, but trying to guess exactly how many to remove. 33 | const reviewsToRemove = Math.max(1, Math.ceil(exceededLength / CHARS_PER_REVIEW)); 34 | return buildUri({ user, period, reviews: reviews.slice(reviewsToRemove) }); 35 | }; 36 | 37 | module.exports = (entry, period) => { 38 | const { user, reviews } = entry || {}; 39 | const parsedReviews = (reviews || []) 40 | .map((r) => ({ ...r, submittedAt: new Date(r.submittedAt) })) 41 | .sort((a, b) => a.submittedAt - b.submittedAt) 42 | .map(parseReview); 43 | 44 | return buildUri({ 45 | user, 46 | period, 47 | reviews: parsedReviews, 48 | }); 49 | }; 50 | -------------------------------------------------------------------------------- /src/interactors/fulfillEntries/calculateTotals.js: -------------------------------------------------------------------------------- 1 | const { STATS } = require('../../constants'); 2 | 3 | const sumStat = (stats, statName) => stats.reduce((a, values) => a + (values[statName] || 0), 0); 4 | 5 | const calculateTotals = (allStats) => STATS.reduce((prev, statName) => ({ 6 | ...prev, 7 | [statName]: sumStat(allStats, statName), 8 | }), {}); 9 | 10 | module.exports = calculateTotals; 11 | -------------------------------------------------------------------------------- /src/interactors/fulfillEntries/getContributions.js: -------------------------------------------------------------------------------- 1 | const { STATS } = require('../../constants'); 2 | 3 | const calculatePercentage = (value, total) => { 4 | if (!total) return 0; 5 | return Math.min(1, Math.max(0, value / total)); 6 | }; 7 | 8 | const getContributions = (reviewer, totals) => STATS.reduce((prev, statsName) => { 9 | const percentage = calculatePercentage(reviewer.stats[statsName], totals[statsName]); 10 | return { ...prev, [statsName]: percentage }; 11 | }, {}); 12 | 13 | module.exports = getContributions; 14 | -------------------------------------------------------------------------------- /src/interactors/fulfillEntries/index.js: -------------------------------------------------------------------------------- 1 | const buildReviewTimeLink = require('./buildReviewTimeLink'); 2 | const getContributions = require('./getContributions'); 3 | const calculateTotals = require('./calculateTotals'); 4 | 5 | const getUrls = ({ entry, periodLength }) => ({ 6 | timeToReview: buildReviewTimeLink(entry, periodLength), 7 | }); 8 | 9 | module.exports = (entries, { periodLength }) => { 10 | const allStats = entries.map(({ stats }) => stats); 11 | const totals = calculateTotals(allStats); 12 | 13 | return entries.map((entry) => ({ 14 | ...entry, 15 | contributions: getContributions(entry, totals), 16 | urls: getUrls({ entry, periodLength }), 17 | })); 18 | }; 19 | -------------------------------------------------------------------------------- /src/interactors/getEntries.js: -------------------------------------------------------------------------------- 1 | const fulfillEntries = require('./fulfillEntries'); 2 | const getPullRequestStats = require('./getPullRequestStats'); 3 | const getReviewStats = require('./getReviewStats'); 4 | const getUsers = require('./getUsers'); 5 | const mergeStats = require('./mergeStats'); 6 | 7 | module.exports = async ({ 8 | core, 9 | pulls, 10 | excludeStr, 11 | includeStr, 12 | periodLength, 13 | }) => { 14 | const users = await getUsers(pulls, { excludeStr, includeStr }); 15 | core.info(`Found ${users.length} collaborators to analyze`); 16 | core.debug(JSON.stringify(users, null, 2)); 17 | 18 | const pullRequestStats = getPullRequestStats(pulls); 19 | core.info(`Analyzed stats for ${pullRequestStats.length} authors`); 20 | core.debug(JSON.stringify(pullRequestStats, null, 2)); 21 | 22 | const reviewStats = getReviewStats(pulls); 23 | core.info(`Analyzed stats for ${reviewStats.length} reviewers`); 24 | core.debug(JSON.stringify(reviewStats, null, 2)); 25 | 26 | return fulfillEntries( 27 | mergeStats({ users, pullRequestStats, reviewStats }), 28 | { periodLength }, 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /src/interactors/getPullRequestStats/__tests__/calculatePullRequestStats.test.js: -------------------------------------------------------------------------------- 1 | const { pullRequests: input } = require('../../../../tests/mocks'); 2 | const calculatePullRequestStats = require('../calculatePullRequestStats'); 3 | 4 | describe('Interactors | getPullRequestStats | .calculatePullRequestStats', () => { 5 | const result = calculatePullRequestStats(input); 6 | 7 | it('calculates the openedPullRequests', () => { 8 | expect(result.openedPullRequests).toBe(2); 9 | }); 10 | 11 | it('calculates the totalObservations', () => { 12 | expect(result.totalObservations).toBe(8); 13 | }); 14 | 15 | it('calculates the medianObservations', () => { 16 | expect(result.medianObservations).toBe(3); 17 | }); 18 | 19 | it('calculates the revisionSuccessRate', () => { 20 | expect(result.revisionSuccessRate).toBeCloseTo(0.33); 21 | }); 22 | 23 | it('calculates the additions', () => { 24 | expect(result.additions).toBe(173); 25 | }); 26 | 27 | it('calculates the deletions', () => { 28 | expect(result.deletions).toBe(87); 29 | }); 30 | 31 | it('calculates the lines', () => { 32 | expect(result.lines).toBe(260); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/interactors/getPullRequestStats/__tests__/groupPullRequests.test.js: -------------------------------------------------------------------------------- 1 | const { pullRequests: input } = require('../../../../tests/mocks'); 2 | const groupPullRequests = require('../groupPullRequests'); 3 | 4 | const getPRsByUserId = (data, userId) => { 5 | const { pullRequests } = data.find((review) => review.userId === userId); 6 | return pullRequests.map(({ id }) => id); 7 | }; 8 | 9 | describe('Interactors | getPullRequestStats | .groupPullRequests', () => { 10 | it('groups pull requests by author', () => { 11 | const result = groupPullRequests(input); 12 | expect(result.length).toEqual(2); 13 | const userIds = result.map((pr) => pr.userId); 14 | expect(userIds).toContain('1031639', '2009676'); 15 | expect(getPRsByUserId(result, '1031639')).toEqual([12345]); 16 | expect(getPRsByUserId(result, '2009676').sort()).toEqual([56789].sort()); 17 | }); 18 | 19 | it('keeps only the required properties', () => { 20 | const result = groupPullRequests(input); 21 | result.forEach(({ pullRequests }) => pullRequests.forEach((pullRequest) => { 22 | expect(pullRequest).toHaveProperty('id'); 23 | expect(pullRequest).toHaveProperty('reviews'); 24 | expect(pullRequest).toHaveProperty('publishedAt'); 25 | })); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/interactors/getPullRequestStats/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | const { pullRequests: input } = require('../../../../tests/mocks'); 2 | const getPullRequestStats = require('../index'); 3 | 4 | const getUserIds = (reviewers) => reviewers.map((r) => r.userId); 5 | 6 | describe('Interactors | getPullRequestStats', () => { 7 | it('groups pull requests by author and calculate its stats', () => { 8 | const result = getPullRequestStats(input); 9 | expect(result.length).toEqual(2); 10 | expect(getUserIds(result)).toContain('1031639', '8755542'); 11 | 12 | result.forEach((author) => { 13 | expect(author).toHaveProperty('userId'); 14 | 15 | expect(author).toHaveProperty('stats'); 16 | expect(author.stats).toHaveProperty('openedPullRequests'); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/interactors/getPullRequestStats/calculatePullRequestStats.js: -------------------------------------------------------------------------------- 1 | const { sum, median, divide } = require('../../utils'); 2 | 3 | const getProperty = (list, prop) => list.map((el) => el[prop]); 4 | 5 | const removeOwnPulls = ({ isOwnPull }) => !isOwnPull; 6 | 7 | const removeWithEmptyId = ({ id }) => !!id; 8 | 9 | module.exports = (pulls) => { 10 | const openedPullRequests = pulls.length; 11 | const reviews = pulls 12 | .reduce((acc, pull) => ([...acc, ...pull.reviews]), []) 13 | .filter(removeOwnPulls) 14 | .filter(removeWithEmptyId); 15 | 16 | const approvedReviews = reviews.filter(({ isApproved }) => isApproved); 17 | const observationsList = getProperty(reviews, 'commentsCount'); 18 | const totalObservations = sum(observationsList); 19 | const medianObservations = median(observationsList); 20 | const totalApprovedReviews = approvedReviews.length || 0; 21 | const additions = sum(getProperty(pulls, 'additions')); 22 | const deletions = sum(getProperty(pulls, 'deletions')); 23 | const lines = additions + deletions; 24 | 25 | return { 26 | openedPullRequests, 27 | totalObservations, 28 | medianObservations, 29 | revisionSuccessRate: divide(totalApprovedReviews, reviews.length), 30 | additions, 31 | deletions, 32 | lines, 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /src/interactors/getPullRequestStats/groupPullRequests.js: -------------------------------------------------------------------------------- 1 | module.exports = (pulls) => { 2 | const byAuthor = pulls.reduce((acc, pull) => { 3 | const userId = pull.author.id; 4 | 5 | if (!acc[userId]) acc[userId] = { userId, pullRequests: [] }; 6 | 7 | acc[userId].pullRequests.push(pull); 8 | return acc; 9 | }, {}); 10 | 11 | return Object.values(byAuthor); 12 | }; 13 | -------------------------------------------------------------------------------- /src/interactors/getPullRequestStats/index.js: -------------------------------------------------------------------------------- 1 | const calculatePullRequestStats = require('./calculatePullRequestStats'); 2 | const groupPullRequests = require('./groupPullRequests'); 3 | 4 | module.exports = (pulls) => groupPullRequests(pulls) 5 | .map(({ userId, pullRequests }) => { 6 | const stats = calculatePullRequestStats(pullRequests); 7 | return { userId, pullRequests, stats }; 8 | }); 9 | -------------------------------------------------------------------------------- /src/interactors/getPulls.js: -------------------------------------------------------------------------------- 1 | const { fetchPullRequests } = require('../fetchers'); 2 | const { parsePullRequest } = require('../parsers'); 3 | 4 | const filterNullAuthor = ({ node }) => !!node.author; 5 | 6 | const ownerFilter = ({ org, repos }) => { 7 | if (org) return `org:${org}`; 8 | return (repos || []).map((r) => `repo:${r}`).join(' '); 9 | }; 10 | 11 | const buildQuery = ({ org, repos, startDate }) => { 12 | const dateFilter = `created:>=${startDate.toISOString()}`; 13 | return `type:pr sort:author-date ${ownerFilter({ org, repos })} ${dateFilter}`; 14 | }; 15 | 16 | const getPullRequests = async (params) => { 17 | const { limit } = params; 18 | const data = await fetchPullRequests(params); 19 | const edges = data.search.edges || []; 20 | const results = edges 21 | .filter(filterNullAuthor) 22 | .map(parsePullRequest); 23 | 24 | if (edges.length < limit) return results; 25 | 26 | const last = results[results.length - 1].cursor; 27 | return results.concat(await getPullRequests({ ...params, after: last })); 28 | }; 29 | 30 | module.exports = ({ 31 | octokit, 32 | org, 33 | repos, 34 | startDate, 35 | itemsPerPage = 100, 36 | }) => { 37 | const search = buildQuery({ org, repos, startDate }); 38 | return getPullRequests({ octokit, search, limit: itemsPerPage }); 39 | }; 40 | -------------------------------------------------------------------------------- /src/interactors/getReviewStats/__tests__/calculateReviewsStats.test.js: -------------------------------------------------------------------------------- 1 | const { reviews: input, pullRequests } = require('../../../../tests/mocks'); 2 | const calculateReviewsStats = require('../calculateReviewsStats'); 3 | 4 | describe('Interactors | getReviewStats | .calculateReviewsStats', () => { 5 | const pullsById = pullRequests.reduce((acc, pr) => ({ ...acc, [pr.id]: pr }), {}); 6 | const result = calculateReviewsStats(input, pullsById); 7 | 8 | it('calculates the totalReviews', () => { 9 | expect(result.totalReviews).toBe(2); 10 | }); 11 | 12 | it('calculates the totalComments', () => { 13 | expect(result.totalComments).toBe(6); 14 | }); 15 | 16 | it('calculates the timeToReview', () => { 17 | expect(result.timeToReview).toBe(75000); 18 | }); 19 | 20 | it('calculates the commentsPerReview', () => { 21 | expect(result.commentsPerReview).toBe(2); 22 | }); 23 | 24 | it('calculates the reviewedAdditions', () => { 25 | expect(result.reviewedAdditions).toBe(173); 26 | }); 27 | 28 | it('calculates the reviewedDeletions', () => { 29 | expect(result.reviewedDeletions).toBe(87); 30 | }); 31 | 32 | it('calculates the reviewedLines', () => { 33 | expect(result.reviewedLines).toBe(260); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/interactors/getReviewStats/__tests__/groupReviews.test.js: -------------------------------------------------------------------------------- 1 | const { pullRequests: input } = require('../../../../tests/mocks'); 2 | const groupReviews = require('../groupReviews'); 3 | 4 | const getReviewsByUserId = (data, userId) => { 5 | const { reviews } = data.find((review) => review.userId === userId); 6 | return reviews.map(({ id }) => id); 7 | }; 8 | 9 | describe('Interactors | getReviewStats | .groupReviews', () => { 10 | it('groups reviews by author', () => { 11 | const result = groupReviews(input); 12 | expect(result.length).toEqual(2); 13 | const userIds = result.map((r) => r.userId); 14 | expect(userIds).toContain('1031639', '8755542'); 15 | expect(getReviewsByUserId(result, '1031639')).toEqual([9876]); 16 | expect(getReviewsByUserId(result, '8755542').sort()).toEqual([5679, 9877].sort()); 17 | }); 18 | 19 | it('removes reviews marked as own pull request', () => { 20 | const result = groupReviews(input); 21 | expect(getReviewsByUserId(result, '1031639')).not.toContain([5678]); 22 | }); 23 | 24 | it('keeps only the required properties', () => { 25 | const result = groupReviews(input); 26 | result.forEach(({ reviews }) => reviews.forEach((review) => { 27 | expect(review).toHaveProperty('id'); 28 | expect(review).toHaveProperty('submittedAt'); 29 | expect(review).toHaveProperty('commentsCount'); 30 | expect(review).toHaveProperty('timeToReview'); 31 | expect(review).toHaveProperty('pullRequestId'); 32 | expect(review).not.toHaveProperty('isOwnPull'); 33 | expect(review).not.toHaveProperty('author'); 34 | })); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/interactors/getReviewStats/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | const { pullRequests: input } = require('../../../../tests/mocks'); 2 | const getReviewers = require('../index'); 3 | 4 | const getUserIds = (reviewers) => reviewers.map((r) => r.userId); 5 | 6 | describe('Interactors | getReviewStats', () => { 7 | it('groups reviews by author and calculate its stats', () => { 8 | const result = getReviewers(input); 9 | expect(result.length).toEqual(2); 10 | expect(getUserIds(result)).toContain('1031639', '8755542'); 11 | 12 | result.forEach((reviewer) => { 13 | expect(reviewer).toHaveProperty('userId'); 14 | 15 | expect(reviewer).toHaveProperty('reviews'); 16 | expect(reviewer.reviews.length > 0).toBe(true); 17 | 18 | expect(reviewer).toHaveProperty('stats'); 19 | expect(reviewer.stats).toHaveProperty('timeToReview'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/interactors/getReviewStats/calculateReviewsStats.js: -------------------------------------------------------------------------------- 1 | const { sum, median } = require('../../utils'); 2 | 3 | const getProperty = (list, prop) => list.map((el) => el[prop]); 4 | 5 | module.exports = (reviews, pullsById) => { 6 | const pullRequestIds = new Set(getProperty(reviews, 'pullRequestId')); 7 | const totalReviews = pullRequestIds.size; 8 | const commentsCountList = getProperty(reviews, 'commentsCount'); 9 | const totalComments = sum(commentsCountList); 10 | const pullRequests = [...pullRequestIds].map((id) => pullsById[id]); 11 | const reviewedAdditions = sum(getProperty(pullRequests, 'additions')); 12 | const reviewedDeletions = sum(getProperty(pullRequests, 'deletions')); 13 | const reviewedLines = reviewedAdditions + reviewedDeletions; 14 | 15 | return { 16 | totalReviews, 17 | totalComments, 18 | timeToReview: median(getProperty(reviews, 'timeToReview')), 19 | commentsPerReview: median(commentsCountList), 20 | reviewedAdditions, 21 | reviewedDeletions, 22 | reviewedLines, 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/interactors/getReviewStats/groupReviews.js: -------------------------------------------------------------------------------- 1 | module.exports = (pulls) => { 2 | const removeOwnPulls = ({ isOwnPull }) => !isOwnPull; 3 | 4 | const removeWithEmptyId = ({ id }) => !!id; 5 | 6 | const all = Object.values(pulls).reduce((acc, pull) => { 7 | const reviews = pull.reviews 8 | .filter(removeOwnPulls) 9 | .filter(removeWithEmptyId) 10 | .map((r) => ({ ...r, pullRequestId: pull.id })); 11 | return acc.concat(reviews); 12 | }, []); 13 | 14 | const byUser = all.reduce((acc, review) => { 15 | const { author, isOwnPull, ...other } = review; 16 | const userId = author.id; 17 | 18 | if (!acc[userId]) acc[userId] = { userId, reviews: [] }; 19 | 20 | acc[userId].reviews.push(other); 21 | return acc; 22 | }, {}); 23 | 24 | return Object.values(byUser); 25 | }; 26 | -------------------------------------------------------------------------------- /src/interactors/getReviewStats/index.js: -------------------------------------------------------------------------------- 1 | const calculateReviewsStats = require('./calculateReviewsStats'); 2 | const groupReviews = require('./groupReviews'); 3 | 4 | module.exports = (pulls) => { 5 | const pullsById = pulls.reduce((acc, pull) => ({ ...acc, [pull.id]: pull }), {}); 6 | 7 | return groupReviews(pulls) 8 | .map(({ userId, reviews }) => { 9 | const stats = calculateReviewsStats(reviews, pullsById); 10 | return { userId, reviews, stats }; 11 | }); 12 | }; 13 | -------------------------------------------------------------------------------- /src/interactors/getUsers/__tests__/findUsers.test.js: -------------------------------------------------------------------------------- 1 | const { pullRequests: input } = require('../../../../tests/mocks'); 2 | const findUsers = require('../findUsers'); 3 | 4 | describe('Interactors | getUsers | .findUsers', () => { 5 | it('finds all the users as authors or reviewers on the pull request', () => { 6 | const result = findUsers(input); 7 | expect(result).toEqual(expect.arrayContaining([ 8 | { 9 | id: '1031639', 10 | url: 'https://github.com/manuelmhtr', 11 | login: 'manuelmhtr', 12 | avatarUrl: 'https://avatars.githubusercontent.com/u/1031639?u=30204017b73f7a1f08005cb8ead3f70b0410486c&v=4', 13 | }, 14 | { 15 | id: '8755542', 16 | url: 'https://github.com/jartmez', 17 | login: 'jartmez', 18 | avatarUrl: 'https://avatars.githubusercontent.com/u/8755542?v=4', 19 | }, 20 | { 21 | id: '2009676', 22 | url: 'https://github.com/javierbyte', 23 | login: 'javierbyte', 24 | avatarUrl: 'https://avatars.githubusercontent.com/u/2009676', 25 | }, 26 | ])); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/interactors/getUsers/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | const { pullRequests: input } = require('../../../../tests/mocks'); 2 | const getUsers = require('../index'); 3 | 4 | const getLogins = (users) => users.map(({ login }) => login); 5 | 6 | describe('Interactors | getUsers', () => { 7 | it('groups reviews by author and calculate its stats', () => { 8 | const result = getUsers(input); 9 | expect(result.length).toEqual(3); 10 | expect(getLogins(result)).toContain('manuelmhtr', 'jartmez', 'javierbyte'); 11 | }); 12 | 13 | it('excludes reviewers when the option is passed', () => { 14 | const result = getUsers(input, { excludeStr: 'manuelmhtr' }); 15 | expect(result.length).toEqual(2); 16 | expect(getLogins(result)).not.toContain('manuelmhtr'); 17 | }); 18 | 19 | it('includes reviewers when the option is passed', () => { 20 | const result = getUsers(input, { includeStr: 'manuelmhtr' }); 21 | expect(result.length).toEqual(1); 22 | expect(getLogins(result)).toContain('manuelmhtr'); 23 | }); 24 | 25 | it('removes the empty usernames', () => { 26 | const emptyAuthor = { id: '1', login: '' }; 27 | const emptyInput = { author: emptyAuthor, reviews: [] }; 28 | const result = getUsers([emptyInput]); 29 | expect(result.length).toEqual(0); 30 | }); 31 | 32 | it('excludes users even if they have uppercase letters', () => { 33 | const author = { id: '1', login: 'UPPERCASE' }; 34 | const customInput = { author, reviews: [] }; 35 | const result = getUsers([customInput], { excludeStr: 'uppercase' }); 36 | expect(result.length).toEqual(0); 37 | }); 38 | 39 | it('includes users even if they have uppercase letters', () => { 40 | const author = { id: '1', login: 'UPPERCASE' }; 41 | const customInput = { author, reviews: [] }; 42 | const result = getUsers([customInput], { includeStr: 'uppercase' }); 43 | expect(result.length).toEqual(1); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/interactors/getUsers/__tests__/parseFilter.test.js: -------------------------------------------------------------------------------- 1 | const parseFilter = require('../parseFilter'); 2 | 3 | describe('Interactors | getUsers | .parseFilter', () => { 4 | it('returns null when the input does not contain usernames or regexp', () => { 5 | expect(parseFilter()).toEqual(null); 6 | expect(parseFilter(null)).toEqual(null); 7 | expect(parseFilter('')).toEqual(null); 8 | expect(parseFilter('@')).toEqual(null); 9 | expect(parseFilter('/@/%^')).toEqual(null); 10 | }); 11 | 12 | it('splits usernames into an array', () => { 13 | expect(parseFilter('user1,user2')).toEqual(['user1', 'user2']); 14 | }); 15 | 16 | it('removes spaces and converts usernames to lowercase', () => { 17 | expect(parseFilter('User1, USER2')).toEqual(['user1', 'user2']); 18 | }); 19 | 20 | it('removes invalid characters from usernames', () => { 21 | expect(parseFilter('@user1, @user%2ñ, keep-dashes-ok')).toEqual(['user1', 'user2', 'keep-dashes-ok']); 22 | }); 23 | 24 | it('parses regexp strings', () => { 25 | expect(parseFilter('/user[0-9]/')).toEqual(/user[0-9]/); 26 | expect(parseFilter('/^bot-.*/ig')).toEqual(/^bot-.*/ig); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/interactors/getUsers/__tests__/testFilter.test.js: -------------------------------------------------------------------------------- 1 | const testFilter = require('../testFilter'); 2 | 3 | describe('Interactors | getUsers | .testFilter', () => { 4 | const reviewers = [ 5 | 'manuelmhtr', 6 | 'jartmez', 7 | 'bot1', 8 | 'bot2', 9 | ]; 10 | 11 | it('filters out reviewers by a list of usernames', () => { 12 | const filter = ['manuelmhtr', 'jartmez']; 13 | const results = reviewers.filter((reviewer) => testFilter(filter, reviewer)); 14 | expect(results.length).toEqual(2); 15 | expect(results).toEqual([ 16 | 'manuelmhtr', 17 | 'jartmez', 18 | ]); 19 | }); 20 | 21 | it('filters out reviewers by a regular expression', () => { 22 | const filter = /bot/; 23 | const results = reviewers.filter((reviewer) => testFilter(filter, reviewer)); 24 | expect(results.length).toEqual(2); 25 | expect(results).toEqual([ 26 | 'bot1', 27 | 'bot2', 28 | ]); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/interactors/getUsers/findUsers.js: -------------------------------------------------------------------------------- 1 | module.exports = (pulls) => { 2 | const byId = {}; 3 | 4 | pulls.forEach((pull) => { 5 | const { author, reviews } = pull; 6 | byId[author.id] = author; 7 | reviews.forEach((review) => { 8 | if (review.author) byId[review.author.id] = review.author; 9 | }); 10 | }); 11 | 12 | return Object.values(byId).filter(Boolean); 13 | }; 14 | -------------------------------------------------------------------------------- /src/interactors/getUsers/index.js: -------------------------------------------------------------------------------- 1 | const testFilter = require('./testFilter'); 2 | const findUsers = require('./findUsers'); 3 | const parseFilter = require('./parseFilter'); 4 | 5 | module.exports = (pulls, { excludeStr, includeStr } = {}) => { 6 | const include = parseFilter(includeStr); 7 | const exclude = parseFilter(excludeStr); 8 | const users = findUsers(pulls); 9 | 10 | return users 11 | .filter(({ login }) => !!login) 12 | .filter(({ login }) => !include || testFilter(include, login)) 13 | .filter(({ login }) => !exclude || !testFilter(exclude, login)); 14 | }; 15 | -------------------------------------------------------------------------------- /src/interactors/getUsers/parseFilter.js: -------------------------------------------------------------------------------- 1 | const REGEXP_PATTERN = /^\/.+\/[a-z]*$/; 2 | 3 | // Github usernames can only contain alphanumeric characters and dashes (-) 4 | const sanitize = (str = '') => (str || '').replace(/[^-a-zA-Z0-9]/g, '').toLowerCase(); 5 | 6 | const isRegExp = (str) => REGEXP_PATTERN.test(str); 7 | 8 | const parseRegExp = (str) => { 9 | const [pattern, flags] = str.split('/').slice(1); 10 | return new RegExp(pattern, flags); 11 | }; 12 | 13 | module.exports = (filterStr) => { 14 | if (!sanitize(filterStr)) return null; 15 | if (isRegExp(filterStr)) return parseRegExp(filterStr); 16 | return filterStr.split(',').map(sanitize); 17 | }; 18 | -------------------------------------------------------------------------------- /src/interactors/getUsers/testFilter.js: -------------------------------------------------------------------------------- 1 | const sanitize = (str) => String(str).toLowerCase(); 2 | 3 | module.exports = (filter, username) => { 4 | if (filter.test) return filter.test(username); 5 | if (filter.includes) return filter.includes(sanitize(username)); 6 | return false; 7 | }; 8 | -------------------------------------------------------------------------------- /src/interactors/index.js: -------------------------------------------------------------------------------- 1 | const alreadyPublished = require('./alreadyPublished'); 2 | const buildTable = require('./buildTable'); 3 | const buildComment = require('./buildComment'); 4 | const buildJsonOutput = require('./buildJsonOutput'); 5 | const buildMarkdown = require('./buildMarkdown'); 6 | const checkSponsorship = require('./checkSponsorship'); 7 | const fulfillEntries = require('./fulfillEntries'); 8 | const getEntries = require('./getEntries'); 9 | const getPulls = require('./getPulls'); 10 | const getPullRequestStats = require('./getPullRequestStats'); 11 | const getReviewStats = require('./getReviewStats'); 12 | const getUsers = require('./getUsers'); 13 | const mergeStats = require('./mergeStats'); 14 | const postComment = require('./postComment'); 15 | const postSlackMessage = require('./postSlackMessage'); 16 | const postSummary = require('./postSummary'); 17 | const postTeamsMessage = require('./postTeamsMessage'); 18 | const postWebhook = require('./postWebhook'); 19 | const publish = require('./publish'); 20 | 21 | module.exports = { 22 | alreadyPublished, 23 | buildTable, 24 | buildComment, 25 | buildJsonOutput, 26 | buildMarkdown, 27 | checkSponsorship, 28 | fulfillEntries, 29 | getEntries, 30 | getPulls, 31 | getPullRequestStats, 32 | getReviewStats, 33 | getUsers, 34 | mergeStats, 35 | postComment, 36 | postSlackMessage, 37 | postSummary, 38 | postTeamsMessage, 39 | postWebhook, 40 | publish, 41 | }; 42 | -------------------------------------------------------------------------------- /src/interactors/mergeStats.js: -------------------------------------------------------------------------------- 1 | const { VALID_STATS } = require('../config/stats'); 2 | 3 | const EMPTY_STATS = VALID_STATS 4 | .reduce((acc, stat) => ({ ...acc, [stat]: null }), {}); 5 | 6 | module.exports = ({ 7 | users, 8 | reviewStats, 9 | pullRequestStats, 10 | }) => { 11 | const reviewsByUserId = reviewStats.reduce((acc, reviewsData) => ({ 12 | ...acc, 13 | [reviewsData.userId]: reviewsData, 14 | }), {}); 15 | 16 | const pullRequestsByUserId = pullRequestStats.reduce((acc, prsData) => ({ 17 | ...acc, 18 | [prsData.userId]: prsData, 19 | }), {}); 20 | 21 | return users.map((user) => ({ 22 | user, 23 | reviews: reviewsByUserId[user.id]?.reviews || [], 24 | stats: { 25 | ...EMPTY_STATS, 26 | ...(reviewsByUserId[user.id]?.stats || {}), 27 | ...(pullRequestsByUserId[user.id]?.stats || {}), 28 | }, 29 | })); 30 | }; 31 | -------------------------------------------------------------------------------- /src/interactors/postComment.js: -------------------------------------------------------------------------------- 1 | const { updatePullRequest, commentOnPullRequest } = require('../fetchers'); 2 | 3 | const buildBody = (currentBody, content) => { 4 | if (!currentBody.trim()) return content; 5 | return `${currentBody}\n\n${content}`; 6 | }; 7 | 8 | module.exports = ({ 9 | octokit, 10 | content, 11 | publishAs, 12 | currentBody, 13 | pullRequestId, 14 | }) => { 15 | if (publishAs === 'NONE') return null; 16 | 17 | if (publishAs === 'DESCRIPTION') { 18 | return updatePullRequest({ 19 | octokit, 20 | id: pullRequestId, 21 | body: buildBody(currentBody || '', content), 22 | }); 23 | } 24 | 25 | return commentOnPullRequest({ 26 | octokit, 27 | pullRequestId, 28 | body: content, 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /src/interactors/postSlackMessage/buildMessage/__tests__/buildRow.test.js: -------------------------------------------------------------------------------- 1 | const { table } = require('../../../../../tests/mocks'); 2 | const buildRow = require('../buildRow'); 3 | 4 | const [row] = table.rows; 5 | const defaultParams = { 6 | row, 7 | maxStats: 0, 8 | statNames: table.headers.slice(1).map(({ text }) => text), 9 | }; 10 | 11 | const DIVIDER = { 12 | type: 'divider', 13 | }; 14 | 15 | const USERNAME = { 16 | type: 'context', 17 | elements: [ 18 | { 19 | type: 'image', 20 | image_url: 'https://avatars.githubusercontent.com/u/user1', 21 | alt_text: 'user1', 22 | }, 23 | { 24 | emoji: true, 25 | type: 'plain_text', 26 | text: 'user1 :first_place_medal:', 27 | }, 28 | ], 29 | }; 30 | 31 | const STATS = { 32 | type: 'section', 33 | fields: [ 34 | { 35 | type: 'mrkdwn', 36 | text: '*Total reviews:* 4', 37 | }, 38 | { 39 | type: 'mrkdwn', 40 | text: '*Time to review:* ', 41 | }, 42 | { 43 | type: 'mrkdwn', 44 | text: '*Total comments:* 1', 45 | }, 46 | { 47 | type: 'mrkdwn', 48 | text: '*Comments per review:* 0.25', 49 | }, 50 | { 51 | type: 'mrkdwn', 52 | text: '*Opened PRs:* 7', 53 | }, 54 | ], 55 | }; 56 | 57 | describe('Interactors | postSlackMessage | .buildRow', () => { 58 | describe('simplest case', () => { 59 | it('builds a reviewers with basic config', () => { 60 | const response = buildRow({ ...defaultParams }); 61 | expect(response).toEqual([ 62 | USERNAME, 63 | STATS, 64 | DIVIDER, 65 | ]); 66 | }); 67 | }); 68 | 69 | describe('when the user has no emoji', () => { 70 | it('adds no medal to the username', () => { 71 | const rowCopy = { 72 | ...row, 73 | user: { 74 | ...row.user, 75 | emoji: null, 76 | }, 77 | }; 78 | const response = buildRow({ ...defaultParams, row: rowCopy }); 79 | expect(response).toEqual([ 80 | { 81 | ...USERNAME, 82 | elements: [ 83 | USERNAME.elements[0], 84 | { 85 | emoji: true, 86 | type: 'plain_text', 87 | text: 'user1', 88 | }, 89 | ], 90 | }, 91 | STATS, 92 | DIVIDER, 93 | ]); 94 | }); 95 | }); 96 | 97 | describe('when limiting the number of stats', () => { 98 | it('shows the correct number of stats', () => { 99 | const response = buildRow({ ...defaultParams, maxStats: 2 }); 100 | expect(response).toEqual([ 101 | USERNAME, 102 | { 103 | ...STATS, 104 | fields: STATS.fields.slice(0, 2), 105 | }, 106 | DIVIDER, 107 | ]); 108 | }); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /src/interactors/postSlackMessage/buildMessage/__tests__/buildSubtitle.test.js: -------------------------------------------------------------------------------- 1 | const { t } = require('../../../../i18n'); 2 | const { getRepoName } = require('../../../../utils'); 3 | const buildSubtitle = require('../buildSubtitle'); 4 | 5 | const ORG = 'org'; 6 | const REPO1 = 'org/repo1'; 7 | const REPO2 = 'org/repo2'; 8 | 9 | const periodLength = 10; 10 | const pullRequest = { 11 | number: 13, 12 | url: 'https://github.com/manuelmhtr/pulls/13', 13 | }; 14 | 15 | const linkOrg = (org) => ``; 16 | 17 | const linkRepo = (repo) => ``; 18 | 19 | describe('Interactors | postSlackMessage | .buildSubtitle', () => { 20 | const baseParams = { 21 | t, 22 | periodLength, 23 | org: ORG, 24 | }; 25 | 26 | describe('when GITHUB_SERVER_URL is present', () => { 27 | it('returns a subtitle with custom github server URL', () => { 28 | process.env.GITHUB_SERVER_URL = 'https://github.example.io'; 29 | const pullRequestWithCustomDomain = { 30 | number: 13, 31 | url: 'https://github.example.io/manuelmhtr/pulls/13', 32 | }; 33 | const linkOrgWithCustomDomain = (org) => ``; 34 | const response = buildSubtitle({ ...baseParams, pullRequest: pullRequestWithCustomDomain }); 35 | const prLinkWithCustomDomain = `(<${pullRequestWithCustomDomain.url}|#${pullRequestWithCustomDomain.number}>)`; 36 | const sources = linkOrgWithCustomDomain(ORG); 37 | delete process.env.GITHUB_SERVER_URL; 38 | expect(response).toEqual([ 39 | { 40 | type: 'section', 41 | text: { 42 | type: 'mrkdwn', 43 | text: `${t('table.subtitle', { sources, count: periodLength })} ${prLinkWithCustomDomain}`, 44 | }, 45 | }, 46 | { 47 | type: 'divider', 48 | }, 49 | ]); 50 | }); 51 | }); 52 | 53 | describe('when sending a pull request', () => { 54 | it('returns a subtitle with no pull request data', () => { 55 | const response = buildSubtitle({ ...baseParams, pullRequest }); 56 | const prLink = `(<${pullRequest.url}|#${pullRequest.number}>)`; 57 | const sources = linkOrg(ORG); 58 | expect(response).toEqual([ 59 | { 60 | type: 'section', 61 | text: { 62 | type: 'mrkdwn', 63 | text: `${t('table.subtitle', { sources, count: periodLength })} ${prLink}`, 64 | }, 65 | }, 66 | { 67 | type: 'divider', 68 | }, 69 | ]); 70 | }); 71 | }); 72 | 73 | describe('when not sending a pull request', () => { 74 | it('returns a subtitle with no pull request data', () => { 75 | const response = buildSubtitle({ ...baseParams, pullRequest: null }); 76 | const sources = linkOrg(ORG); 77 | expect(response).toEqual([ 78 | { 79 | type: 'section', 80 | text: { 81 | type: 'mrkdwn', 82 | text: `${t('table.subtitle', { sources, count: periodLength })}`, 83 | }, 84 | }, 85 | { 86 | type: 'divider', 87 | }, 88 | ]); 89 | }); 90 | }); 91 | 92 | describe('when sending multiple repos', () => { 93 | it('returns a subtitle with no pull request data', () => { 94 | const repos = [REPO1, REPO2]; 95 | const response = buildSubtitle({ ...baseParams, org: null, repos }); 96 | const sources = `${linkRepo(REPO1)} and ${linkRepo(REPO2)}`; 97 | expect(response).toEqual([ 98 | { 99 | type: 'section', 100 | text: { 101 | type: 'mrkdwn', 102 | text: `${t('table.subtitle', { sources, count: periodLength })}`, 103 | }, 104 | }, 105 | { 106 | type: 'divider', 107 | }, 108 | ]); 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /src/interactors/postSlackMessage/buildMessage/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | const { table } = require('../../../../../tests/mocks'); 2 | const buildMessage = require('../index'); 3 | const buildSubtitle = require('../buildSubtitle'); 4 | const buildRow = require('../buildRow'); 5 | 6 | const SUBTITLE = 'SUBTITLE'; 7 | const ROW = 'ROW'; 8 | const statNames = table.headers.slice(1).map(({ text }) => text); 9 | 10 | jest.mock('../buildSubtitle', () => jest.fn(() => [SUBTITLE])); 11 | jest.mock('../buildRow', () => jest.fn(() => [ROW])); 12 | 13 | const defaultOptions = { 14 | table, 15 | org: 'ORG', 16 | repos: 'REPOS', 17 | pullRequest: 'PULL REQUEST', 18 | periodLength: 'PERIOD LENGTH', 19 | }; 20 | 21 | describe('Interactors | postSlackMessage | .buildMessage', () => { 22 | beforeEach(() => { 23 | buildSubtitle.mockClear(); 24 | buildRow.mockClear(); 25 | }); 26 | 27 | it('returns the expected structure', () => { 28 | const tableCopy = { ...table }; 29 | tableCopy.rows = [tableCopy.rows[0]]; 30 | 31 | const response = buildMessage({ ...defaultOptions, table: tableCopy }); 32 | expect(response).toEqual({ 33 | blocks: [ 34 | SUBTITLE, 35 | ROW, 36 | ], 37 | }); 38 | }); 39 | 40 | it('calls builders with the correct parameters', () => { 41 | buildMessage({ ...defaultOptions }); 42 | expect(buildSubtitle).toHaveBeenCalledWith({ 43 | t: expect.anything(), 44 | org: defaultOptions.org, 45 | repos: defaultOptions.repos, 46 | pullRequest: defaultOptions.pullRequest, 47 | periodLength: defaultOptions.periodLength, 48 | }); 49 | expect(buildRow).toHaveBeenCalledWith({ 50 | row: table.rows[0], 51 | statNames, 52 | }); 53 | }); 54 | 55 | it('builds a row per each passed', () => { 56 | buildMessage(defaultOptions); 57 | expect(buildRow).toHaveBeenCalledTimes(table.rows.length); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/interactors/postSlackMessage/buildMessage/buildRow.js: -------------------------------------------------------------------------------- 1 | const EMOJIS_MAP = { 2 | medal1: ':first_place_medal:', /* 🥇 */ 3 | medal2: ':second_place_medal:', /* 🥈 */ 4 | medal3: ':third_place_medal:', /* 🥉 */ 5 | }; 6 | 7 | const getUsername = ({ text, image, emoji }) => { 8 | const medal = EMOJIS_MAP[emoji] || null; 9 | const suffix = medal ? ` ${medal}` : ''; 10 | 11 | return { 12 | type: 'context', 13 | elements: [ 14 | { 15 | type: 'image', 16 | image_url: image, 17 | alt_text: text, 18 | }, 19 | { 20 | emoji: true, 21 | type: 'plain_text', 22 | text: `${text}${suffix}`, 23 | }, 24 | ], 25 | }; 26 | }; 27 | 28 | const getStats = ({ row, maxStats, statNames }) => { 29 | const stats = maxStats > 0 ? row.stats.slice(0, maxStats) : row.stats; 30 | const fields = stats.map(({ text, link }, index) => { 31 | const value = link ? `<${link}|${text}>` : text; 32 | return { 33 | type: 'mrkdwn', 34 | text: `*${statNames[index]}:* ${value}`, 35 | }; 36 | }); 37 | 38 | return { 39 | type: 'section', 40 | fields, 41 | }; 42 | }; 43 | 44 | const getDivider = () => ({ 45 | type: 'divider', 46 | }); 47 | 48 | module.exports = ({ 49 | row, 50 | maxStats, 51 | statNames, 52 | }) => [ 53 | getUsername(row.user), 54 | getStats({ row, maxStats, statNames }), 55 | getDivider(), 56 | ]; 57 | -------------------------------------------------------------------------------- /src/interactors/postSlackMessage/buildMessage/buildSubtitle.js: -------------------------------------------------------------------------------- 1 | const { buildSources } = require('../../../utils'); 2 | const { getGithubServerUrl } = require('../../../config'); 3 | 4 | const getPRText = (pullRequest) => { 5 | const { url, number } = pullRequest || {}; 6 | if (!url || !number) return ''; 7 | return ` (<${url}|#${number}>)`; 8 | }; 9 | 10 | const buildGithubLink = ({ description, path }) => `<${getGithubServerUrl()}/${path}|${description}>`; 11 | 12 | module.exports = ({ 13 | t, 14 | org, 15 | repos, 16 | pullRequest, 17 | periodLength, 18 | }) => { 19 | const sources = buildSources({ buildGithubLink, org, repos }); 20 | return [ 21 | { 22 | type: 'section', 23 | text: { 24 | type: 'mrkdwn', 25 | text: `${t('table.subtitle', { sources, count: periodLength })}${getPRText(pullRequest)}`, 26 | }, 27 | }, 28 | { 29 | type: 'divider', 30 | }, 31 | ]; 32 | }; 33 | -------------------------------------------------------------------------------- /src/interactors/postSlackMessage/buildMessage/index.js: -------------------------------------------------------------------------------- 1 | const { t } = require('../../../i18n'); 2 | const buildSubtitle = require('./buildSubtitle'); 3 | const buildRow = require('./buildRow'); 4 | 5 | const getStatNames = (headers) => headers.slice(1).map(({ text }) => text); 6 | 7 | module.exports = ({ 8 | org, 9 | repos, 10 | table, 11 | pullRequest, 12 | periodLength, 13 | maxStats, 14 | }) => ({ 15 | blocks: [ 16 | ...buildSubtitle({ 17 | t, 18 | org, 19 | repos, 20 | pullRequest, 21 | periodLength, 22 | }), 23 | 24 | ...table.rows.reduce( 25 | (prev, row) => [ 26 | ...prev, 27 | ...buildRow({ 28 | row, 29 | maxStats, 30 | statNames: getStatNames(table.headers), 31 | }), 32 | ], 33 | [], 34 | ), 35 | ], 36 | }); 37 | -------------------------------------------------------------------------------- /src/interactors/postSlackMessage/index.js: -------------------------------------------------------------------------------- 1 | const { t } = require('../../i18n'); 2 | const { postToSlack } = require('../../fetchers'); 3 | const { SlackSplitter } = require('../../services/splitter'); 4 | const buildMessage = require('./buildMessage'); 5 | 6 | const MAX_STATS_PER_BLOCK = 10; // https://api.slack.com/reference/block-kit/blocks 7 | 8 | module.exports = async ({ 9 | core, 10 | org, 11 | repos, 12 | slack, 13 | isSponsor, 14 | table, 15 | periodLength, 16 | pullRequest = null, 17 | }) => { 18 | const { webhook, channel } = slack || {}; 19 | 20 | if (!webhook || !channel) { 21 | core.debug(t('integrations.slack.logs.notConfigured')); 22 | return; 23 | } 24 | 25 | if (!isSponsor) { 26 | core.setFailed(t('integrations.slack.errors.notSponsor')); 27 | return; 28 | } 29 | 30 | const statsCount = table.rows[0]?.stats?.length; 31 | if (statsCount > MAX_STATS_PER_BLOCK) { 32 | core.warning(t('integrations.slack.errors.statsLimitExceeded', { 33 | statsLimit: MAX_STATS_PER_BLOCK, 34 | })); 35 | } 36 | 37 | const send = (message) => { 38 | const params = { 39 | webhook, 40 | channel, 41 | message, 42 | iconUrl: t('table.icon'), 43 | username: t('table.title'), 44 | }; 45 | core.debug(t('integrations.slack.logs.posting', { 46 | params: JSON.stringify(params, null, 2), 47 | })); 48 | return postToSlack(params); 49 | }; 50 | 51 | const fullMessage = buildMessage({ 52 | org, 53 | repos, 54 | table, 55 | pullRequest, 56 | periodLength, 57 | maxStats: MAX_STATS_PER_BLOCK, 58 | }); 59 | 60 | const { chunks } = new SlackSplitter({ message: fullMessage }); 61 | await chunks.reduce(async (promise, message) => { 62 | await promise; 63 | return send(message).catch((error) => { 64 | core.error(t('integrations.slack.errors.requestFailed', { error })); 65 | throw error; 66 | }); 67 | }, Promise.resolve()); 68 | 69 | core.debug(t('integrations.slack.logs.success')); 70 | }; 71 | -------------------------------------------------------------------------------- /src/interactors/postSummary.js: -------------------------------------------------------------------------------- 1 | const { t } = require('../i18n'); 2 | 3 | module.exports = async ({ 4 | core, 5 | content, 6 | }) => { 7 | core.debug(t('integrations.summary.logs.posting', { content })); 8 | 9 | try { 10 | await core 11 | .summary 12 | .addRaw(`\n${content}`, true) 13 | .write(); 14 | core.debug(t('integrations.summary.logs.success')); 15 | } catch (error) { 16 | core.error(t('integrations.summary.errors.writeFailed', { error })); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/interactors/postTeamsMessage/__tests__/buildPayload.test.js: -------------------------------------------------------------------------------- 1 | const buildPayload = require('../buildPayload'); 2 | 3 | describe('Interactors | .postTeamsMessage | .buildPayload', () => { 4 | const body = 'BODY'; 5 | 6 | it('wraps the body into a required structure', () => { 7 | const result = buildPayload(body); 8 | expect(result.type).toEqual('message'); 9 | 10 | const wrappedBody = result?.attachments?.[0]?.content?.body; 11 | expect(wrappedBody).toEqual(body); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/interactors/postTeamsMessage/buildMessage/__tests__/buildHeaders.test.js: -------------------------------------------------------------------------------- 1 | const buildHeaders = require('../buildHeaders'); 2 | 3 | const EXPECTED_HEADERS = [ 4 | 'header1', 5 | 'header2', 6 | 'header3', 7 | 'header4', 8 | ]; 9 | 10 | const headers = EXPECTED_HEADERS.map((text) => ({ text })); 11 | 12 | describe('Interactors | .postTeamsMessage | .buildHeaders', () => { 13 | const result = buildHeaders(headers); 14 | const texts = (result?.columns || []).map((column) => column?.items?.[0]?.text); 15 | 16 | it('includes headers structure', () => { 17 | expect(result).toEqual( 18 | expect.objectContaining({ 19 | type: 'ColumnSet', 20 | padding: 'Small', 21 | horizontalAlignment: 'Left', 22 | style: 'emphasis', 23 | spacing: 'Small', 24 | }), 25 | ); 26 | }); 27 | 28 | EXPECTED_HEADERS.forEach((expectedHeader) => { 29 | it(`includes the header "${expectedHeader}"`, () => { 30 | expect(texts).toContain(expectedHeader); 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/interactors/postTeamsMessage/buildMessage/__tests__/buildRow.test.js: -------------------------------------------------------------------------------- 1 | const buildRow = require('../buildRow'); 2 | const { table } = require('../../../../../tests/mocks'); 3 | 4 | const [row] = table.rows; 5 | const defaultParams = { 6 | row, 7 | }; 8 | 9 | const extractData = (response) => { 10 | const [usernameCol, ...statsCols] = response?.columns || []; 11 | const [imageCol, nameCol] = usernameCol?.items?.[0].columns || []; 12 | const stats = statsCols.map((col) => col?.items?.[0].text); 13 | 14 | return { 15 | avatarUrl: imageCol?.items?.[0]?.url, 16 | login: nameCol?.items?.[0]?.text, 17 | stats, 18 | }; 19 | }; 20 | 21 | describe('Interactors | postTeamsMessage | .buildRow', () => { 22 | const expectedContent = { 23 | avatarUrl: 'https://avatars.githubusercontent.com/u/user1', 24 | login: 'user1 🥇', 25 | stats: [ 26 | '4', 27 | '[34m](https://app.flowwer.dev/charts/review-time/1)', 28 | '1', 29 | '0.25', 30 | '7', 31 | ], 32 | }; 33 | 34 | describe('simplest case', () => { 35 | it('builds a reviewers with basic config', () => { 36 | const response = buildRow({ ...defaultParams }); 37 | expect(extractData(response)).toEqual(expectedContent); 38 | }); 39 | }); 40 | 41 | describe('removing emoji', () => { 42 | it('does not add a medal to the username', () => { 43 | const rowCopy = { ...row }; 44 | rowCopy.user.emoji = null; 45 | 46 | const response = buildRow({ ...defaultParams, row: rowCopy }); 47 | expect(extractData(response)).toEqual({ 48 | ...expectedContent, 49 | login: 'user1', 50 | }); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/interactors/postTeamsMessage/buildMessage/__tests__/buildSubtitle.test.js: -------------------------------------------------------------------------------- 1 | const { t } = require('../../../../i18n'); 2 | const { getRepoName } = require('../../../../utils'); 3 | const buildSubtitle = require('../buildSubtitle'); 4 | 5 | const ORG = 'org'; 6 | const REPO1 = 'org/repo1'; 7 | const REPO2 = 'org/repo2'; 8 | 9 | const periodLength = 10; 10 | const pullRequest = { 11 | number: 13, 12 | url: 'https://github.com/manuelmhtr/pulls/13', 13 | }; 14 | 15 | const linkOrg = (org) => `[${org}](https://github.com/${org})`; 16 | 17 | const linkRepo = (repo) => `[${getRepoName(repo)}](https://github.com/${repo})`; 18 | 19 | const wrapText = (text) => ({ 20 | type: 'Container', 21 | padding: 'Small', 22 | items: [ 23 | { 24 | text, 25 | type: 'TextBlock', 26 | weight: 'Lighter', 27 | wrap: true, 28 | }, 29 | ], 30 | }); 31 | 32 | describe('Interactors | postTeamsMessage | .buildSubtitle', () => { 33 | const baseParams = { 34 | t, 35 | periodLength, 36 | org: ORG, 37 | }; 38 | 39 | describe('when GITHUB_SERVER_URL is present', () => { 40 | it('returns a subtitle with custom github server URL', () => { 41 | process.env.GITHUB_SERVER_URL = 'https://github.example.io'; 42 | const pullRequestWithCustomDomain = { 43 | number: 13, 44 | url: 'https://github.example.io/manuelmhtr/pulls/13', 45 | }; 46 | const linkOrgWithCustomDomain = (org) => `[${org}](https://github.example.io/${org})`; 47 | const response = buildSubtitle({ ...baseParams, pullRequest: pullRequestWithCustomDomain }); 48 | const prLinkWithCustomDomain = `([#${pullRequestWithCustomDomain.number}](${pullRequestWithCustomDomain.url}))`; 49 | const sources = linkOrgWithCustomDomain(ORG); 50 | const text = `${t('table.subtitle', { sources, count: periodLength })} ${prLinkWithCustomDomain}`; 51 | delete process.env.GITHUB_SERVER_URL; 52 | expect(response).toEqual(wrapText(text)); 53 | }); 54 | }); 55 | 56 | describe('when sending a pull request', () => { 57 | it('returns a subtitle with no pull request data', () => { 58 | const response = buildSubtitle({ ...baseParams, pullRequest }); 59 | const prLink = `([#${pullRequest.number}](${pullRequest.url}))`; 60 | const sources = linkOrg(ORG); 61 | const text = `${t('table.subtitle', { sources, count: periodLength })} ${prLink}`; 62 | expect(response).toEqual(wrapText(text)); 63 | }); 64 | }); 65 | 66 | describe('when not sending a pull request', () => { 67 | it('returns a subtitle with no pull request data', () => { 68 | const response = buildSubtitle({ ...baseParams, pullRequest: null }); 69 | const sources = linkOrg(ORG); 70 | const text = `${t('table.subtitle', { sources, count: periodLength })}`; 71 | expect(response).toEqual(wrapText(text)); 72 | }); 73 | }); 74 | 75 | describe('when sending multiple repos', () => { 76 | it('returns a subtitle with no pull request data', () => { 77 | const repos = [REPO1, REPO2]; 78 | const response = buildSubtitle({ ...baseParams, org: null, repos }); 79 | const sources = `${linkRepo(REPO1)} and ${linkRepo(REPO2)}`; 80 | const text = `${t('table.subtitle', { sources, count: periodLength })}`; 81 | expect(response).toEqual(wrapText(text)); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/interactors/postTeamsMessage/buildMessage/__tests__/index.test.js: -------------------------------------------------------------------------------- 1 | const buildMessage = require('../index'); 2 | const buildHeaders = require('../buildHeaders'); 3 | const buildSubtitle = require('../buildSubtitle'); 4 | const buildRow = require('../buildRow'); 5 | 6 | const HEADERS = 'HEADERS'; 7 | const SUBTITLE = 'SUBTITLE'; 8 | const ROW = 'ROW'; 9 | const table = { 10 | headers: [ 11 | { text: 'HEADER 1' }, 12 | { text: 'HEADER 2' }, 13 | ], 14 | rows: [ROW], 15 | }; 16 | 17 | jest.mock('../buildHeaders', () => jest.fn(() => HEADERS)); 18 | jest.mock('../buildSubtitle', () => jest.fn(() => SUBTITLE)); 19 | jest.mock('../buildRow', () => jest.fn(() => ROW)); 20 | 21 | const defaultOptions = { 22 | table, 23 | pullRequest: 'PULL REQUEST', 24 | periodLength: 'PERIOD LENGTH', 25 | }; 26 | 27 | describe('Interactors | postTeamsMessage | .buildMessage', () => { 28 | beforeEach(() => { 29 | buildHeaders.mockClear(); 30 | buildSubtitle.mockClear(); 31 | buildRow.mockClear(); 32 | }); 33 | 34 | it('returns the expected structure', () => { 35 | const response = buildMessage({ ...defaultOptions }); 36 | expect(response).toEqual([ 37 | SUBTITLE, 38 | HEADERS, 39 | ROW, 40 | ]); 41 | }); 42 | 43 | it('calls builders with the correct parameters', () => { 44 | buildMessage({ ...defaultOptions }); 45 | expect(buildSubtitle).toHaveBeenCalledWith({ 46 | t: expect.anything(), 47 | pullRequest: defaultOptions.pullRequest, 48 | periodLength: defaultOptions.periodLength, 49 | }); 50 | expect(buildHeaders).toHaveBeenCalledWith(defaultOptions.table.headers); 51 | expect(buildRow).toHaveBeenCalledWith({ 52 | row: defaultOptions.table.rows[0], 53 | }); 54 | }); 55 | 56 | it('builds a reviewers per each passed', () => { 57 | const rows = ['ROW 1', 'ROW 2', 'ROW 3']; 58 | const tableCopy = { ...table, rows }; 59 | buildMessage({ ...defaultOptions, table: tableCopy }); 60 | expect(buildRow).toHaveBeenCalledTimes(rows.length); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/interactors/postTeamsMessage/buildMessage/buildHeaders.js: -------------------------------------------------------------------------------- 1 | const wrapHeader = (text) => ({ 2 | type: 'Column', 3 | padding: 'None', 4 | width: 'stretch', 5 | verticalContentAlignment: 'Center', 6 | items: [ 7 | { 8 | text, 9 | type: 'TextBlock', 10 | wrap: true, 11 | weight: 'Bolder', 12 | }, 13 | ], 14 | }); 15 | 16 | module.exports = (headers) => ({ 17 | type: 'ColumnSet', 18 | padding: 'Small', 19 | horizontalAlignment: 'Left', 20 | style: 'emphasis', 21 | spacing: 'Small', 22 | columns: headers.map(({ text }) => wrapHeader(text)), 23 | }); 24 | -------------------------------------------------------------------------------- /src/interactors/postTeamsMessage/buildMessage/buildRow.js: -------------------------------------------------------------------------------- 1 | const EMOJIS_MAP = { 2 | medal1: '🥇', 3 | medal2: '🥈', 4 | medal3: '🥉', 5 | }; 6 | 7 | const wrapUsername = ({ 8 | avatarUrl, 9 | login, 10 | }) => ({ 11 | type: 'Column', 12 | padding: 'None', 13 | width: 'stretch', 14 | spacing: 'Small', 15 | separator: true, 16 | items: [ 17 | { 18 | type: 'ColumnSet', 19 | padding: 'None', 20 | columns: [ 21 | { 22 | type: 'Column', 23 | padding: 'None', 24 | width: 'auto', 25 | items: [ 26 | { 27 | type: 'Image', 28 | url: avatarUrl, 29 | altText: login, 30 | size: 'Small', 31 | style: 'Person', 32 | spacing: 'None', 33 | horizontalAlignment: 'Left', 34 | width: '32px', 35 | height: '32px', 36 | }, 37 | ], 38 | }, 39 | { 40 | type: 'Column', 41 | padding: 'None', 42 | width: 'stretch', 43 | verticalContentAlignment: 'Center', 44 | items: [ 45 | { 46 | type: 'TextBlock', 47 | text: login, 48 | wrap: true, 49 | horizontalAlignment: 'Left', 50 | spacing: 'Small', 51 | }, 52 | ], 53 | }, 54 | ], 55 | }, 56 | ], 57 | }); 58 | 59 | const wrapStat = (text) => ({ 60 | type: 'Column', 61 | padding: 'None', 62 | width: 'stretch', 63 | spacing: 'Small', 64 | verticalContentAlignment: 'Center', 65 | items: [ 66 | { 67 | text, 68 | type: 'TextBlock', 69 | wrap: true, 70 | }, 71 | ], 72 | }); 73 | 74 | const getUsername = ({ image, text, emoji }) => { 75 | const medal = EMOJIS_MAP[emoji] || null; 76 | const suffix = medal ? ` ${medal}` : ''; 77 | 78 | return wrapUsername({ 79 | avatarUrl: image, 80 | login: `${text}${suffix}`, 81 | }); 82 | }; 83 | 84 | const getStats = (stats) => stats.map(({ link, text }) => { 85 | const content = link ? `[${text}](${link})` : text; 86 | return wrapStat(content); 87 | }); 88 | 89 | module.exports = ({ row }) => ({ 90 | type: 'ColumnSet', 91 | padding: 'Small', 92 | spacing: 'None', 93 | separator: true, 94 | columns: [ 95 | getUsername(row.user), 96 | ...getStats(row.stats), 97 | ], 98 | }); 99 | -------------------------------------------------------------------------------- /src/interactors/postTeamsMessage/buildMessage/buildSubtitle.js: -------------------------------------------------------------------------------- 1 | const { buildSources } = require('../../../utils'); 2 | const { getGithubServerUrl } = require('../../../config'); 3 | 4 | const getPRText = (pullRequest) => { 5 | const { url, number } = pullRequest || {}; 6 | if (!url || !number) return ''; 7 | return ` ([#${number}](${url}))`; 8 | }; 9 | 10 | const buildGithubLink = ({ description, path }) => `[${description}](${getGithubServerUrl()}/${path})`; 11 | 12 | module.exports = ({ 13 | t, 14 | org, 15 | repos, 16 | pullRequest, 17 | periodLength, 18 | }) => { 19 | const sources = buildSources({ buildGithubLink, org, repos }); 20 | return { 21 | type: 'Container', 22 | padding: 'Small', 23 | items: [ 24 | { 25 | type: 'TextBlock', 26 | weight: 'Lighter', 27 | wrap: true, 28 | text: `${t('table.subtitle', { sources, count: periodLength })}${getPRText(pullRequest)}`, 29 | }, 30 | ], 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /src/interactors/postTeamsMessage/buildMessage/index.js: -------------------------------------------------------------------------------- 1 | const { t } = require('../../../i18n'); 2 | const buildHeaders = require('./buildHeaders'); 3 | const buildSubtitle = require('./buildSubtitle'); 4 | const buildRow = require('./buildRow'); 5 | 6 | module.exports = ({ 7 | org, 8 | repos, 9 | table, 10 | pullRequest, 11 | periodLength, 12 | }) => ([ 13 | buildSubtitle({ 14 | t, 15 | org, 16 | repos, 17 | pullRequest, 18 | periodLength, 19 | }), 20 | 21 | buildHeaders(table.headers), 22 | 23 | ...table.rows.map((row) => buildRow({ row })), 24 | ]); 25 | -------------------------------------------------------------------------------- /src/interactors/postTeamsMessage/buildPayload.js: -------------------------------------------------------------------------------- 1 | module.exports = (body) => ({ 2 | type: 'message', 3 | attachments: [ 4 | { 5 | contentType: 'application/vnd.microsoft.card.adaptive', 6 | contentUrl: null, 7 | content: { 8 | body, 9 | $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', 10 | type: 'AdaptiveCard', 11 | version: '1.0', 12 | msteams: { 13 | width: 'Full', 14 | }, 15 | }, 16 | }, 17 | ], 18 | }); 19 | -------------------------------------------------------------------------------- /src/interactors/postTeamsMessage/index.js: -------------------------------------------------------------------------------- 1 | const { t } = require('../../i18n'); 2 | const { postToWebhook } = require('../../fetchers'); 3 | const { TeamsSplitter } = require('../../services/splitter'); 4 | const buildMessage = require('./buildMessage'); 5 | const buildPayload = require('./buildPayload'); 6 | 7 | const DELAY = 500; 8 | 9 | module.exports = async ({ 10 | core, 11 | org, 12 | repos, 13 | teams, 14 | isSponsor, 15 | table, 16 | periodLength, 17 | pullRequest = null, 18 | }) => { 19 | const { webhook } = teams || {}; 20 | 21 | if (!webhook) { 22 | core.debug(t('integrations.teams.logs.notConfigured')); 23 | return; 24 | } 25 | 26 | if (!isSponsor) { 27 | core.setFailed(t('integrations.teams.errors.notSponsor')); 28 | return; 29 | } 30 | 31 | const send = (body) => { 32 | const params = { 33 | webhook, 34 | payload: buildPayload(body), 35 | }; 36 | core.debug(t('integrations.teams.logs.posting', { 37 | params: JSON.stringify(params, null, 2), 38 | })); 39 | return postToWebhook(params); 40 | }; 41 | 42 | const fullMessage = buildMessage({ 43 | org, 44 | repos, 45 | table, 46 | pullRequest, 47 | periodLength, 48 | }); 49 | 50 | const { chunks } = new TeamsSplitter({ message: fullMessage }); 51 | await chunks.reduce(async (promise, message) => { 52 | await promise; 53 | await send(message).catch((error) => { 54 | core.error(t('integrations.teams.errors.requestFailed', { error })); 55 | throw error; 56 | }); 57 | // Delaying between requests to prevent rate limiting 58 | // https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using?tabs=cURL#rate-limiting-for-connectors 59 | await new Promise((resolve) => { setTimeout(resolve, DELAY); }); 60 | }, Promise.resolve()); 61 | 62 | core.debug(t('integrations.teams.logs.success')); 63 | }; 64 | -------------------------------------------------------------------------------- /src/interactors/postWebhook.js: -------------------------------------------------------------------------------- 1 | const { t } = require('../i18n'); 2 | const { postToWebhook } = require('../fetchers'); 3 | 4 | module.exports = async ({ 5 | core, 6 | payload, 7 | webhook, 8 | }) => { 9 | if (!webhook) { 10 | core.debug(t('integrations.webhook.logs.notConfigured')); 11 | return; 12 | } 13 | 14 | const params = { payload, webhook }; 15 | core.debug(t('integrations.webhook.logs.posting', { 16 | params: JSON.stringify(params, null, 2), 17 | })); 18 | 19 | await postToWebhook(params).catch((error) => { 20 | core.error(t('integrations.webhook.errors.requestFailed', { error })); 21 | throw error; 22 | }); 23 | 24 | core.debug(t('integrations.webhook.logs.success')); 25 | }; 26 | -------------------------------------------------------------------------------- /src/interactors/publish.js: -------------------------------------------------------------------------------- 1 | const buildTable = require('./buildTable'); 2 | const buildComment = require('./buildComment'); 3 | const buildJsonOutput = require('./buildJsonOutput'); 4 | const buildMarkdown = require('./buildMarkdown'); 5 | const postComment = require('./postComment'); 6 | const postSlackMessage = require('./postSlackMessage'); 7 | const postSummary = require('./postSummary'); 8 | const postTeamsMessage = require('./postTeamsMessage'); 9 | const postWebhook = require('./postWebhook'); 10 | 11 | module.exports = async ({ 12 | core, 13 | octokit, 14 | entries, 15 | pullRequest, 16 | inputs, 17 | }) => { 18 | const { 19 | org, 20 | repos, 21 | mainStats, 22 | limit, 23 | sortBy, 24 | periodLength, 25 | disableLinks, 26 | displayCharts, 27 | publishAs, 28 | pullRequestId, 29 | isSponsor, 30 | } = inputs; 31 | 32 | const table = buildTable({ 33 | entries, 34 | limit, 35 | sortBy, 36 | mainStats, 37 | disableLinks, 38 | displayCharts, 39 | }); 40 | core.debug('Table content built successfully'); 41 | 42 | const markdownTable = buildMarkdown({ table }); 43 | core.debug('Markdown table built successfully'); 44 | 45 | const content = buildComment({ 46 | org, 47 | repos, 48 | periodLength, 49 | markdownTable, 50 | isSponsor, 51 | }); 52 | core.debug(`Commit content built successfully: ${content}`); 53 | 54 | const whParams = { 55 | core, 56 | org, 57 | repos, 58 | table, 59 | periodLength, 60 | pullRequest, 61 | isSponsor, 62 | }; 63 | const jsonOutput = buildJsonOutput({ inputs, entries }); 64 | await postWebhook({ core, payload: jsonOutput, webhook: inputs.webhook }); 65 | await postSlackMessage({ ...whParams, slack: inputs.slack }); 66 | await postTeamsMessage({ ...whParams, teams: inputs.teams }); 67 | await postSummary({ core, content }); 68 | await core.setOutput('resultsMd', markdownTable); 69 | await core.setOutput('resultsJson', jsonOutput); 70 | 71 | if (pullRequestId) { 72 | await postComment({ 73 | octokit, 74 | content, 75 | publishAs, 76 | pullRequestId, 77 | currentBody: pullRequest.body, 78 | }); 79 | core.debug('Posted comment successfully'); 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /src/parsers/__tests__/mocks/pullRequest.json: -------------------------------------------------------------------------------- 1 | { 2 | "cursor": "Y3Vyc29yOjQ=", 3 | "node": { 4 | "id": 12345, 5 | "additions": 10, 6 | "deletions": 5, 7 | "publishedAt": "2021-02-12T23:54:38Z", 8 | "author": { 9 | "databaseId": "1031639", 10 | "url": "https://github.com/manuelmhtr", 11 | "login": "manuelmhtr", 12 | "avatarUrl": "https://avatars.githubusercontent.com/u/1031639?u=30204017b73f7a1f08005cb8ead3f70b0410486c&v=4" 13 | }, 14 | "reviews": { 15 | "nodes": [ 16 | { 17 | "submittedAt": "2021-02-12T23:55:22Z", 18 | "commit": { 19 | "pushedDate": "2021-02-12T23:53:13Z" 20 | }, 21 | "comments": { 22 | "totalCount": 1 23 | }, 24 | "author": { 25 | "databaseId": "1031639", 26 | "url": "https://github.com/manuelmhtr", 27 | "login": "manuelmhtr", 28 | "avatarUrl": "https://avatars.githubusercontent.com/u/1031639?u=30204017b73f7a1f08005cb8ead3f70b0410486c&v=4" 29 | } 30 | }, 31 | { 32 | "submittedAt": "2021-02-15T16:00:59Z", 33 | "commit": { 34 | "pushedDate": "2021-02-12T23:59:42Z" 35 | }, 36 | "comments": { 37 | "totalCount": 3 38 | }, 39 | "author": { 40 | "databaseId": "22161828", 41 | "url": "https://github.com/Estebes10", 42 | "login": "Estebes10", 43 | "avatarUrl": "https://avatars.githubusercontent.com/u/22161828?v=4" 44 | } 45 | }, 46 | { 47 | "submittedAt": "2021-02-17T13:00:22Z", 48 | "commit": { 49 | "pushedDate": "2021-02-16T22:39:22Z" 50 | }, 51 | "comments": { 52 | "totalCount": 9 53 | }, 54 | "author": {} 55 | } 56 | ] 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/parsers/__tests__/mocks/review.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 5678, 3 | "body": "This is a review", 4 | "state": "APPROVED", 5 | "submittedAt": "2021-02-12T23:55:22Z", 6 | "commit": { 7 | "pushedDate": "2021-02-12T23:53:13Z" 8 | }, 9 | "comments": { 10 | "totalCount": 1 11 | }, 12 | "author": { 13 | "databaseId": "1031639", 14 | "url": "https://github.com/manuelmhtr", 15 | "login": "manuelmhtr", 16 | "avatarUrl": "https://avatars.githubusercontent.com/u/1031639?u=30204017b73f7a1f08005cb8ead3f70b0410486c&v=4" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/parsers/__tests__/mocks/user.json: -------------------------------------------------------------------------------- 1 | { 2 | "databaseId": "1031639", 3 | "url": "https://github.com/manuelmhtr", 4 | "login": "manuelmhtr", 5 | "avatarUrl": "https://avatars.githubusercontent.com/u/1031639?u=30204017b73f7a1f08005cb8ead3f70b0410486c&v=4" 6 | } 7 | -------------------------------------------------------------------------------- /src/parsers/__tests__/parseInputs.test.js: -------------------------------------------------------------------------------- 1 | const parseInputs = require('../parseInputs'); 2 | const { VALID_STATS, DEFAULT_STATS } = require('../../config/stats'); 3 | 4 | const github = { 5 | context: { 6 | payload: { 7 | pull_request: { 8 | node_id: 'MDExOlB1bGxSZXF1ZXN0MzIwNjYwNjYw', 9 | }, 10 | }, 11 | }, 12 | }; 13 | 14 | const core = { 15 | getInput: jest.fn(), 16 | getBooleanInput: jest.fn(), 17 | }; 18 | 19 | const baseInputs = { 20 | githubToken: 'GITHUB_TOKEN', 21 | token: 'PERSONAL_TOKEN', 22 | organization: 'ORGANIZATION', 23 | repositories: 'REPOSITORY1, REPOSITORY2', 24 | sortBy: 'REVIEWS', 25 | stats: `${VALID_STATS.join(',')}`, 26 | publishAs: 'COMMENT', 27 | period: '30', 28 | charts: 'true', 29 | disableLinks: 'true', 30 | limit: '10', 31 | exclude: 'EXCLUDE', 32 | include: 'INCLUDE', 33 | telemetry: 'true', 34 | webhook: 'WEBHOOK', 35 | slackWebhook: 'SLACK_WEBHOOK', 36 | slackChannel: 'SLACK_CHANNEL', 37 | teamsWebhook: 'TEAMS_WEBHOOK', 38 | }; 39 | 40 | describe('Parsers | .parseInputs', () => { 41 | const currentRepo = 'ORGANIZATION/CURRENT_REPO'; 42 | 43 | const mockCore = (data = {}) => { 44 | const fullData = { ...baseInputs, ...data }; 45 | core.getInput.mockImplementation((key) => fullData[key]); 46 | core.getBooleanInput.mockImplementation((key) => fullData[key] === 'true'); 47 | }; 48 | 49 | it('includes current repo', () => { 50 | mockCore(); 51 | const response = parseInputs({ core, github, currentRepo }); 52 | expect(response).toEqual(expect.objectContaining({ 53 | currentRepo, 54 | })); 55 | }); 56 | 57 | it('parses the inputs from the github variable', () => { 58 | mockCore(); 59 | const response = parseInputs({ core, github, currentRepo }); 60 | expect(response).toEqual(expect.objectContaining({ 61 | pullRequestId: github.context.payload.pull_request.node_id, 62 | })); 63 | }); 64 | 65 | it('parses the inputs from the core variable', () => { 66 | mockCore(); 67 | const response = parseInputs({ core, github, currentRepo }); 68 | expect(response).toEqual(expect.objectContaining({ 69 | githubToken: baseInputs.githubToken, 70 | personalToken: baseInputs.token, 71 | org: baseInputs.organization, 72 | repos: ['REPOSITORY1', 'REPOSITORY2'], 73 | sortBy: baseInputs.sortBy, 74 | mainStats: VALID_STATS, 75 | publishAs: baseInputs.publishAs, 76 | periodLength: 30, 77 | displayCharts: true, 78 | disableLinks: true, 79 | limit: 10, 80 | excludeStr: baseInputs.exclude, 81 | includeStr: baseInputs.include, 82 | telemetry: true, 83 | webhook: baseInputs.webhook, 84 | slack: { 85 | webhook: baseInputs.slackWebhook, 86 | channel: baseInputs.slackChannel, 87 | }, 88 | teams: { 89 | webhook: baseInputs.teamsWebhook, 90 | }, 91 | })); 92 | }); 93 | 94 | describe('tokens', () => { 95 | it('uses the githubToken if token is not provided', () => { 96 | mockCore({ token: '' }); 97 | const response = parseInputs({ core, github, currentRepo }); 98 | expect(response.personalToken).toEqual(baseInputs.githubToken); 99 | }); 100 | }); 101 | 102 | describe('stats', () => { 103 | it('defaults to DEFAULT_STATS if no stats are provided', () => { 104 | mockCore({ stats: '' }); 105 | const response = parseInputs({ core, github, currentRepo }); 106 | expect(response.mainStats).toEqual(DEFAULT_STATS); 107 | }); 108 | 109 | it('filters out invalid stats', () => { 110 | mockCore({ stats: `invalidStat,${VALID_STATS[0]}` }); 111 | const response = parseInputs({ core, github, currentRepo }); 112 | expect(response.mainStats).toEqual([VALID_STATS[0]]); 113 | }); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/parsers/__tests__/parsePullRequest.test.js: -------------------------------------------------------------------------------- 1 | const input = require('./mocks/pullRequest.json'); 2 | const parsePullRequest = require('../parsePullRequest'); 3 | 4 | describe('Parsers | .parsePullRequest', () => { 5 | it('parses the main fields of the pull request', () => { 6 | const response = parsePullRequest(input); 7 | 8 | expect(response).toHaveProperty('id', 12345); 9 | expect(response).toHaveProperty('additions', 10); 10 | expect(response).toHaveProperty('deletions', 5); 11 | expect(response).toHaveProperty('lines', 15); 12 | expect(response).toHaveProperty('cursor', 'Y3Vyc29yOjQ='); 13 | expect(response).toHaveProperty('publishedAt', new Date('2021-02-12T23:54:38Z')); 14 | expect(response).toHaveProperty('author', { 15 | id: '1031639', 16 | url: 'https://github.com/manuelmhtr', 17 | login: 'manuelmhtr', 18 | avatarUrl: 'https://avatars.githubusercontent.com/u/1031639?u=30204017b73f7a1f08005cb8ead3f70b0410486c&v=4', 19 | }); 20 | expect(response).toHaveProperty('reviews'); 21 | expect(response.reviews).toHaveLength(2); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/parsers/__tests__/parseReview.test.js: -------------------------------------------------------------------------------- 1 | const input = require('./mocks/review.json'); 2 | const parseReview = require('../parseReview'); 3 | 4 | describe('Parsers | .parseReview', () => { 5 | const submittedAt = new Date('2021-02-12T23:55:22Z'); 6 | const pullRequest = { authorLogin: 'javierbyte', publishedAt: new Date('2021-02-12T23:54:38Z') }; 7 | 8 | it('parses the main fields', () => { 9 | const response = parseReview(input, pullRequest); 10 | expect(response).toHaveProperty('id', 5678); 11 | expect(response).toHaveProperty('body', 'This is a review'); 12 | expect(response).toHaveProperty('state', 'APPROVED'); 13 | expect(response).toHaveProperty('isApproved', true); 14 | expect(response).toHaveProperty('submittedAt', submittedAt); 15 | expect(response).toHaveProperty('commentsCount', 2); 16 | expect(response).toHaveProperty('author', { 17 | id: '1031639', 18 | url: 'https://github.com/manuelmhtr', 19 | login: 'manuelmhtr', 20 | avatarUrl: 'https://avatars.githubusercontent.com/u/1031639?u=30204017b73f7a1f08005cb8ead3f70b0410486c&v=4', 21 | }); 22 | }); 23 | 24 | it('does not count body as a comment when it has no content', () => { 25 | const response = parseReview({ ...input, body: ' ' }, pullRequest); 26 | expect(response).toHaveProperty('commentsCount', 1); 27 | }); 28 | 29 | it('returns isApproved as false when the review is not approved', () => { 30 | const response = parseReview({ ...input, state: 'CHANGES_REQUESTED' }, pullRequest); 31 | expect(response).toHaveProperty('isApproved', false); 32 | }); 33 | 34 | describe('isOwnPull', () => { 35 | it('returns false when the pull request author is different', () => { 36 | const response = parseReview(input, pullRequest); 37 | expect(response).toHaveProperty('isOwnPull', false); 38 | }); 39 | 40 | it('returns true when the pull request author is the same', () => { 41 | const response = parseReview(input, { ...pullRequest, authorLogin: 'manuelmhtr' }); 42 | expect(response).toHaveProperty('isOwnPull', true); 43 | }); 44 | }); 45 | 46 | describe('timeToReview', () => { 47 | const pushedAt = new Date('2021-02-12T23:53:13Z'); 48 | 49 | it('compares vs the pull request submittedAt when the date is after the commit pushedAt', () => { 50 | const response = parseReview(input, pullRequest); 51 | expect(response).toHaveProperty('timeToReview', submittedAt - pullRequest.publishedAt); 52 | }); 53 | 54 | it('compares vs the commit pushedAt when the date is after the pull request submittedAt', () => { 55 | const response = parseReview(input, { ...pullRequest, publishedAt: new Date(0) }); 56 | expect(response).toHaveProperty('timeToReview', submittedAt - pushedAt); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/parsers/__tests__/parseUser.test.js: -------------------------------------------------------------------------------- 1 | const input = require('./mocks/user.json'); 2 | const parseUser = require('../parseUser'); 3 | 4 | describe('Parsers | .parseUser', () => { 5 | it('parses the main fields of a user', () => { 6 | const response = parseUser(input); 7 | expect(response).toHaveProperty('id', '1031639'); 8 | expect(response).toHaveProperty('url', 'https://github.com/manuelmhtr'); 9 | expect(response).toHaveProperty('login', 'manuelmhtr'); 10 | expect(response).toHaveProperty('avatarUrl', 'https://avatars.githubusercontent.com/u/1031639?u=30204017b73f7a1f08005cb8ead3f70b0410486c&v=4'); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/parsers/index.js: -------------------------------------------------------------------------------- 1 | const parseInputs = require('./parseInputs'); 2 | const parsePullRequest = require('./parsePullRequest'); 3 | const parseReview = require('./parseReview'); 4 | const parseUser = require('./parseUser'); 5 | 6 | module.exports = { 7 | parseInputs, 8 | parsePullRequest, 9 | parseReview, 10 | parseUser, 11 | }; 12 | -------------------------------------------------------------------------------- /src/parsers/parseInputs.js: -------------------------------------------------------------------------------- 1 | const get = require('lodash.get'); 2 | const { VALID_STATS, DEFAULT_STATS } = require('../config/stats'); 3 | 4 | const parseArray = (value) => (value || '') 5 | .split(',') 6 | .map((s) => s.trim()) 7 | .filter(Boolean); 8 | 9 | const getPeriod = (input) => { 10 | const MAX_PERIOD_DATE = 365; 11 | const value = parseInt(input, 10); 12 | return Math.min(value, MAX_PERIOD_DATE); 13 | }; 14 | 15 | const getRepositories = (input, currentRepo) => (input ? parseArray(input) : [currentRepo]); 16 | 17 | const getPrId = (github) => get(github, 'context.payload.pull_request.node_id'); 18 | 19 | const getStats = (input) => { 20 | const statsList = parseArray(input).filter((s) => VALID_STATS.includes(s)); 21 | return statsList.length > 0 ? statsList : DEFAULT_STATS; 22 | }; 23 | 24 | module.exports = ({ core, github, currentRepo }) => { 25 | const githubToken = core.getInput('githubToken'); 26 | const personalToken = core.getInput('token') || githubToken; 27 | 28 | return { 29 | currentRepo, 30 | githubToken, 31 | personalToken, 32 | pullRequestId: getPrId(github), 33 | org: core.getInput('organization'), 34 | repos: getRepositories(core.getInput('repositories'), currentRepo), 35 | sortBy: core.getInput('sortBy'), 36 | mainStats: getStats(core.getInput('stats')), 37 | publishAs: core.getInput('publishAs'), 38 | periodLength: getPeriod(core.getInput('period')), 39 | displayCharts: core.getBooleanInput('charts'), 40 | disableLinks: core.getBooleanInput('disableLinks'), 41 | limit: parseInt(core.getInput('limit'), 10), 42 | excludeStr: core.getInput('exclude'), 43 | includeStr: core.getInput('include'), 44 | telemetry: core.getBooleanInput('telemetry'), 45 | webhook: core.getInput('webhook'), 46 | slack: { 47 | webhook: core.getInput('slackWebhook'), 48 | channel: core.getInput('slackChannel'), 49 | }, 50 | teams: { 51 | webhook: core.getInput('teamsWebhook'), 52 | }, 53 | }; 54 | }; 55 | -------------------------------------------------------------------------------- /src/parsers/parsePullRequest.js: -------------------------------------------------------------------------------- 1 | const get = require('lodash.get'); 2 | const parseUser = require('./parseUser'); 3 | const parseReview = require('./parseReview'); 4 | 5 | const filterNullAuthor = ({ author }) => !!(author || {}).login; 6 | 7 | const getFilteredReviews = (data) => get(data, 'node.reviews.nodes', []).filter(filterNullAuthor); 8 | 9 | module.exports = (data = {}) => { 10 | const author = parseUser(get(data, 'node.author')); 11 | const publishedAt = new Date(get(data, 'node.publishedAt')); 12 | const additions = get(data, 'node.additions'); 13 | const deletions = get(data, 'node.deletions'); 14 | const handleReviews = (review) => parseReview(review, { publishedAt, authorLogin: author.login }); 15 | 16 | return { 17 | author, 18 | additions, 19 | deletions, 20 | publishedAt, 21 | cursor: data.cursor, 22 | id: get(data, 'node.id'), 23 | lines: additions + deletions, 24 | reviews: getFilteredReviews(data).map(handleReviews), 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /src/parsers/parseReview.js: -------------------------------------------------------------------------------- 1 | const get = require('lodash.get'); 2 | const parseUser = require('./parseUser'); 3 | 4 | const APPROVED = 'APPROVED'; 5 | 6 | module.exports = (data = {}, pullRequest = {}) => { 7 | const author = parseUser(data.author); 8 | const isOwnPull = author.login === pullRequest.authorLogin; 9 | const submittedAt = new Date(data.submittedAt); 10 | const body = get(data, 'body'); 11 | const state = get(data, 'state'); 12 | const commitDate = new Date(get(data, 'commit.pushedDate')); 13 | const startDate = Math.max(pullRequest.publishedAt, commitDate); 14 | const hasBody = !!((body || '').trim()); 15 | const extraComment = hasBody ? 1 : 0; 16 | 17 | return { 18 | author, 19 | isOwnPull, 20 | submittedAt, 21 | body, 22 | id: get(data, 'id'), 23 | state: get(data, 'state'), 24 | isApproved: state === APPROVED, 25 | commentsCount: get(data, 'comments.totalCount') + extraComment, 26 | timeToReview: submittedAt - startDate, 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/parsers/parseUser.js: -------------------------------------------------------------------------------- 1 | module.exports = (data = {}) => ({ 2 | id: data.databaseId, 3 | url: data.url, 4 | login: data.login, 5 | avatarUrl: data.avatarUrl, 6 | }); 7 | -------------------------------------------------------------------------------- /src/services/index.js: -------------------------------------------------------------------------------- 1 | const Telemetry = require('./telemetry'); 2 | 3 | module.exports = { 4 | Telemetry, 5 | }; 6 | -------------------------------------------------------------------------------- /src/services/splitter/__tests__/slack.test.js: -------------------------------------------------------------------------------- 1 | const SlackSplitter = require('../slack'); 2 | const { median } = require('../../../utils'); 3 | 4 | jest.mock('../../../config', () => ({ 5 | getSlackLimits: () => ({ chars: 100, blocks: 5 }), 6 | })); 7 | 8 | describe('Services | Splitter | SlackSplitter', () => { 9 | const block1 = { 10 | type: 'section', 11 | text: 'Some text 1', 12 | }; 13 | const block2 = { type: 'divider' }; 14 | const block3 = { 15 | type: 'section', 16 | text: 'Some text 2', 17 | }; 18 | const block4 = { type: 'divider' }; 19 | const block5 = { 20 | type: 'section', 21 | text: 'Some text 3', 22 | }; 23 | const block6 = { type: 'divider' }; 24 | const block7 = { 25 | type: 'section', 26 | text: 'Some text 4', 27 | }; 28 | const message = { 29 | blocks: [ 30 | block1, 31 | block2, 32 | block3, 33 | block4, 34 | block5, 35 | block6, 36 | block7, 37 | ], 38 | }; 39 | 40 | describe('limits', () => { 41 | it('returns limits from config', () => { 42 | const splitter = new SlackSplitter(); 43 | expect(splitter.limit).toEqual(100); 44 | expect(splitter.maxBlocksLength).toEqual(5); 45 | }); 46 | }); 47 | 48 | describe('.splitBlocks', () => { 49 | it('splits message in 2 blocks given an index', () => { 50 | const [results1, results2] = SlackSplitter.splitBlocks(message, 3); 51 | expect(results1).toEqual({ 52 | blocks: [block1, block2, block3], 53 | }); 54 | expect(results2).toEqual({ 55 | blocks: [block4, block5, block6, block7], 56 | }); 57 | }); 58 | 59 | it('returns full message as the first split when index is last', () => { 60 | const [results1, results2] = SlackSplitter.splitBlocks(message, 7); 61 | expect(results1).toEqual(message); 62 | expect(results2).toEqual({ blocks: [] }); 63 | }); 64 | 65 | it('returns full message as the last split when index is 0', () => { 66 | const [results1, results2] = SlackSplitter.splitBlocks(message, 0); 67 | expect(results1).toEqual({ blocks: [] }); 68 | expect(results2).toEqual(message); 69 | }); 70 | }); 71 | 72 | describe('.calculateSize', () => { 73 | it('returns the length of the message parsed to JSON', () => { 74 | const result = SlackSplitter.calculateSize(message); 75 | expect(result > 0).toEqual(true); 76 | expect(result).toEqual(JSON.stringify(message).length); 77 | }); 78 | }); 79 | 80 | describe('.getBlocksCount', () => { 81 | it('returns the number of blocks in a message', () => { 82 | const result = SlackSplitter.getBlocksCount(message); 83 | expect(result > 0).toEqual(true); 84 | expect(result).toEqual(message.blocks.length); 85 | }); 86 | }); 87 | 88 | describe('.calculateSizePerBlock', () => { 89 | it('returns the median size of the blocks with type "section"', () => { 90 | const size1 = JSON.stringify(block1).length; 91 | const size2 = JSON.stringify(block3).length; 92 | const size3 = JSON.stringify(block5).length; 93 | const size4 = JSON.stringify(block7).length; 94 | const expected = Math.ceil(median([size1, size2, size3, size4])); 95 | const result = SlackSplitter.calculateSizePerBlock(message); 96 | expect(result > 0).toEqual(true); 97 | expect(result).toEqual(expected); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/services/splitter/__tests__/teams.test.js: -------------------------------------------------------------------------------- 1 | const TeamsSplitter = require('../teams'); 2 | const { median } = require('../../../utils'); 3 | 4 | jest.mock('../../../config', () => ({ 5 | getTeamsBytesLimit: () => 5000, 6 | })); 7 | 8 | const byteLength = (input) => Buffer.byteLength(JSON.stringify(input)); 9 | 10 | describe('Services | Splitter | TeamsSplitter', () => { 11 | const block1 = { 12 | type: 'ColumnSet', 13 | text: 'Some text 1', 14 | }; 15 | const block2 = { type: 'Container' }; 16 | const block3 = { 17 | type: 'ColumnSet', 18 | text: 'Some text 2', 19 | }; 20 | const block4 = { type: 'Container' }; 21 | const block5 = { 22 | type: 'ColumnSet', 23 | text: 'Some text 3', 24 | }; 25 | const block6 = { type: 'Container' }; 26 | const block7 = { 27 | type: 'ColumnSet', 28 | text: 'Some text 4', 29 | }; 30 | const message = [ 31 | block1, 32 | block2, 33 | block3, 34 | block4, 35 | block5, 36 | block6, 37 | block7, 38 | ]; 39 | 40 | describe('limits', () => { 41 | it('returns limit from config', () => { 42 | const splitter = new TeamsSplitter(); 43 | expect(splitter.limit).toEqual(5000); 44 | }); 45 | }); 46 | 47 | describe('.splitBlocks', () => { 48 | it('splits message in 2 blocks given an index', () => { 49 | const [results1, results2] = TeamsSplitter.splitBlocks(message, 3); 50 | expect(results1).toEqual([block1, block2, block3]); 51 | expect(results2).toEqual([block4, block5, block6, block7]); 52 | }); 53 | 54 | it('returns full message as the first split when index is last', () => { 55 | const [results1, results2] = TeamsSplitter.splitBlocks(message, 7); 56 | expect(results1).toEqual(message); 57 | expect(results2).toEqual([]); 58 | }); 59 | 60 | it('returns full message as the last split when index is 0', () => { 61 | const [results1, results2] = TeamsSplitter.splitBlocks(message, 0); 62 | expect(results1).toEqual([]); 63 | expect(results2).toEqual(message); 64 | }); 65 | }); 66 | 67 | describe('.calculateSize', () => { 68 | it('returns the length of the message parsed to JSON', () => { 69 | const result = TeamsSplitter.calculateSize(message); 70 | expect(result > 0).toEqual(true); 71 | expect(result).toEqual(byteLength(message)); 72 | }); 73 | }); 74 | 75 | describe('.getBlocksCount', () => { 76 | it('returns the number of blocks in a message', () => { 77 | const result = TeamsSplitter.getBlocksCount(message); 78 | expect(result > 0).toEqual(true); 79 | expect(result).toEqual(message.length); 80 | }); 81 | }); 82 | 83 | describe('.calculateSizePerBlock', () => { 84 | it('returns the median size of the blocks with type "ColumnSet"', () => { 85 | const size1 = byteLength(block1); 86 | const size2 = byteLength(block3); 87 | const size3 = byteLength(block5); 88 | const size4 = byteLength(block7); 89 | const expected = Math.ceil(median([size1, size2, size3, size4])); 90 | const result = TeamsSplitter.calculateSizePerBlock(message); 91 | expect(result > 0).toEqual(true); 92 | expect(result).toEqual(expected); 93 | }); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /src/services/splitter/base.js: -------------------------------------------------------------------------------- 1 | class BaseSplitter { 2 | constructor({ message, limit = null, maxBlocksLength = null }) { 3 | this.message = message; 4 | this.limit = limit || Infinity; 5 | this.maxBlocksLength = maxBlocksLength || Infinity; 6 | } 7 | 8 | get blockSize() { 9 | if (!this.blockSizeMemo) { 10 | this.blockSizeMemo = Math.max(1, this.constructor.calculateSizePerBlock(this.message)); 11 | } 12 | return this.blockSizeMemo; 13 | } 14 | 15 | get chunks() { 16 | if (!this.chunksMemo) this.chunksMemo = this.split([], this.message); 17 | return this.chunksMemo; 18 | } 19 | 20 | split(prev, message) { 21 | const blocksToSplit = this.calculateBlocksToSplit(message); 22 | if (!blocksToSplit) return [...prev, message]; 23 | const [first, last] = this.constructor.splitBlocks(message, blocksToSplit); 24 | return this.split([...prev, first], last); 25 | } 26 | 27 | calculateBlocksToSplit(message) { 28 | const blocksCount = this.constructor.getBlocksCount(message); 29 | const currentSize = this.constructor.calculateSize(message); 30 | const diff = currentSize - this.limit; 31 | const onLimit = diff <= 0 && blocksCount <= this.maxBlocksLength; 32 | if (onLimit || blocksCount === 1) return 0; 33 | 34 | const blocksSpace = Math.ceil(diff / this.blockSize); 35 | const upperBound = Math.min(blocksCount - 1, blocksSpace); 36 | const exceedingBlocks = Math.max(0, blocksCount - this.maxBlocksLength); 37 | const blocksToSplit = Math.max(1, upperBound, exceedingBlocks); 38 | const [firsts] = this.constructor.splitBlocks(message, blocksToSplit); 39 | return this.calculateBlocksToSplit(firsts) || blocksToSplit; 40 | } 41 | 42 | static splitBlocks() { 43 | throw new Error('Not implemented'); 44 | } 45 | 46 | static calculateSize() { 47 | throw new Error('Not implemented'); 48 | } 49 | 50 | static getBlocksCount() { 51 | throw new Error('Not implemented'); 52 | } 53 | 54 | static calculateSizePerBlock() { 55 | throw new Error('Not implemented'); 56 | } 57 | } 58 | 59 | module.exports = BaseSplitter; 60 | -------------------------------------------------------------------------------- /src/services/splitter/index.js: -------------------------------------------------------------------------------- 1 | const SlackSplitter = require('./slack'); 2 | const TeamsSplitter = require('./teams'); 3 | 4 | module.exports = { 5 | SlackSplitter, 6 | TeamsSplitter, 7 | }; 8 | -------------------------------------------------------------------------------- /src/services/splitter/slack.js: -------------------------------------------------------------------------------- 1 | const { getSlackLimits } = require('../../config'); 2 | const { median } = require('../../utils'); 3 | const BaseSplitter = require('./base'); 4 | 5 | class SlackSplitter extends BaseSplitter { 6 | constructor(args = {}) { 7 | const limits = getSlackLimits(); 8 | super({ 9 | ...args, 10 | limit: limits.chars, 11 | maxBlocksLength: limits.blocks, 12 | }); 13 | } 14 | 15 | static splitBlocks(message, count) { 16 | const { blocks } = message; 17 | const firsts = blocks.slice(0, count); 18 | const lasts = blocks.slice(count); 19 | return [{ blocks: firsts }, { blocks: lasts }]; 20 | } 21 | 22 | static calculateSize(message) { 23 | return JSON.stringify(message).length; 24 | } 25 | 26 | static getBlocksCount(message) { 27 | return message.blocks.length; 28 | } 29 | 30 | static calculateSizePerBlock(message) { 31 | const blockLengths = message 32 | .blocks 33 | .filter(({ type }) => type === 'section') 34 | .map((block) => this.calculateSize(block)); 35 | 36 | return Math.ceil(median(blockLengths)); 37 | } 38 | } 39 | 40 | module.exports = SlackSplitter; 41 | -------------------------------------------------------------------------------- /src/services/splitter/teams.js: -------------------------------------------------------------------------------- 1 | const { getTeamsBytesLimit } = require('../../config'); 2 | const { median } = require('../../utils'); 3 | const BaseSplitter = require('./base'); 4 | 5 | class TeamsSplitter extends BaseSplitter { 6 | constructor(args = {}) { 7 | super({ 8 | ...args, 9 | limit: getTeamsBytesLimit(), 10 | }); 11 | } 12 | 13 | static splitBlocks(body, count) { 14 | const firsts = body.slice(0, count); 15 | const lasts = body.slice(count); 16 | return [firsts, lasts]; 17 | } 18 | 19 | static calculateSize(body) { 20 | return Buffer.byteLength(JSON.stringify(body)); 21 | } 22 | 23 | static getBlocksCount(body) { 24 | return body.length; 25 | } 26 | 27 | static calculateSizePerBlock(body) { 28 | const blockLengths = body 29 | .filter(({ type }) => type === 'ColumnSet') 30 | .map((block) => this.calculateSize(block)); 31 | 32 | return Math.ceil(median(blockLengths)); 33 | } 34 | } 35 | 36 | module.exports = TeamsSplitter; 37 | -------------------------------------------------------------------------------- /src/services/telemetry/__tests__/sendSuccess.test.js: -------------------------------------------------------------------------------- 1 | const sendSuccess = require('../sendSuccess'); 2 | 3 | describe('sendSuccess', () => { 4 | const track = jest.fn(); 5 | const tracker = { track }; 6 | const timeMs = 1234567; 7 | const pullRequest = { 8 | author: { 9 | login: 'author', 10 | }, 11 | }; 12 | const entries = [ 13 | { user: { login: 'reviewer1' } }, 14 | { user: { login: 'reviewer2' } }, 15 | ]; 16 | 17 | const setup = () => sendSuccess({ 18 | timeMs, 19 | tracker, 20 | pullRequest, 21 | entries, 22 | }); 23 | 24 | beforeEach(() => { 25 | track.mockClear(); 26 | }); 27 | 28 | it('send the timing parameters correctly', () => { 29 | setup(); 30 | expect(track).toHaveBeenCalledWith('success', expect.objectContaining({ 31 | timeMs, 32 | timeSec: 1234, 33 | timeMin: 20, 34 | })); 35 | }); 36 | 37 | it('sends the PR author correctly', () => { 38 | setup(); 39 | expect(track).toHaveBeenCalledWith('success', expect.objectContaining({ 40 | prAuthor: 'author', 41 | })); 42 | }); 43 | 44 | it('sends the reviewers correctly', () => { 45 | setup(); 46 | expect(track).toHaveBeenCalledWith('success', expect.objectContaining({ 47 | reviewers: ['reviewer1', 'reviewer2'], 48 | reviewersCount: 2, 49 | })); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/services/telemetry/buildTracker.js: -------------------------------------------------------------------------------- 1 | const Mixpanel = require('mixpanel'); 2 | const project = require('../../../package.json'); 3 | 4 | const MIXPANEL_TOKEN = '6a91c23a5c49e341a337954443e1f2a0'; 5 | 6 | const getContext = () => ({ version: project.version }); 7 | 8 | const buildTracker = () => { 9 | const mixpanel = Mixpanel.init(MIXPANEL_TOKEN); 10 | const context = getContext(); 11 | 12 | const track = (event, properties) => mixpanel.track(event, { 13 | ...context, 14 | ...properties, 15 | }); 16 | 17 | return { 18 | track, 19 | }; 20 | }; 21 | 22 | module.exports = buildTracker; 23 | -------------------------------------------------------------------------------- /src/services/telemetry/index.js: -------------------------------------------------------------------------------- 1 | const sendError = require('./sendError'); 2 | const sendStart = require('./sendStart'); 3 | const sendSuccess = require('./sendSuccess'); 4 | const buildTracker = require('./buildTracker'); 5 | 6 | class Telemetry { 7 | constructor({ core, isSponsor, telemetry }) { 8 | this.useTelemetry = !isSponsor || telemetry; 9 | this.tracker = this.useTelemetry ? buildTracker() : null; 10 | if (!this.useTelemetry) core.debug('Telemetry disabled correctly'); 11 | if (!telemetry && !isSponsor) core.setFailed('Disabling telemetry is a premium feature, available to sponsors.'); 12 | } 13 | 14 | start(params) { 15 | if (!this.useTelemetry) return; 16 | this.startDate = new Date(); 17 | sendStart({ 18 | ...params, 19 | tracker: this.tracker, 20 | }); 21 | } 22 | 23 | error(error) { 24 | if (!this.useTelemetry) return; 25 | sendError({ 26 | error, 27 | tracker: this.tracker, 28 | }); 29 | } 30 | 31 | success(results) { 32 | if (!this.useTelemetry) return; 33 | sendSuccess({ 34 | timeMs: new Date() - this.startDate, 35 | tracker: this.tracker, 36 | ...(results || {}), 37 | }); 38 | } 39 | } 40 | 41 | module.exports = Telemetry; 42 | -------------------------------------------------------------------------------- /src/services/telemetry/sendError.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ tracker, error }) => { 2 | const { message } = error || {}; 3 | 4 | tracker.track('error', { message }); 5 | }; 6 | -------------------------------------------------------------------------------- /src/services/telemetry/sendStart.js: -------------------------------------------------------------------------------- 1 | const { getRepoOwner } = require('../../utils/repos'); 2 | 3 | module.exports = ({ 4 | org, 5 | repos, 6 | sortBy, 7 | periodLength, 8 | displayCharts, 9 | disableLinks, 10 | currentRepo, 11 | limit, 12 | tracker, 13 | slack, 14 | teams, 15 | webhook, 16 | }) => { 17 | const owner = getRepoOwner(currentRepo); 18 | const reposCount = (repos || []).length; 19 | const orgsCount = org ? 1 : 0; 20 | const usingSlack = !!(slack || {}).webhook; 21 | const usingTeams = !!(teams || {}).webhook; 22 | const usingWebhook = !!webhook; 23 | 24 | tracker.track('run', { 25 | // Necessary to build the "Used by" section in Readme: 26 | owner, 27 | // Necessary to learn if used against specific repos or full organizations: 28 | orgsCount, 29 | reposCount, 30 | currentRepo, 31 | // Necessary to learn which options are commonly used and improve them: 32 | sortBy, 33 | periodLength, 34 | displayCharts, 35 | disableLinks, 36 | limit, 37 | usingSlack, 38 | usingTeams, 39 | usingWebhook, 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /src/services/telemetry/sendSuccess.js: -------------------------------------------------------------------------------- 1 | module.exports = ({ 2 | timeMs, 3 | tracker, 4 | pullRequest, 5 | entries, 6 | }) => { 7 | const timeSec = Math.floor(timeMs / 1000); 8 | const timeMin = Math.floor(timeMs / 60000); 9 | const prAuthor = pullRequest?.author?.login; 10 | const reviewers = (entries || []).map((e) => e?.user?.login); 11 | const reviewersCount = reviewers.length; 12 | 13 | tracker.track('success', { 14 | timeMs, 15 | timeSec, 16 | timeMin, 17 | prAuthor, 18 | reviewers, 19 | reviewersCount, 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /src/utils/__test__/average.test.js: -------------------------------------------------------------------------------- 1 | const average = require('../average'); 2 | 3 | describe('Utils | .average', () => { 4 | it('returns the average of the numbers in a list', () => { 5 | const input = [1, 7, 3, 0, 4]; 6 | expect(average(input)).toBe(3); 7 | }); 8 | 9 | it('works with floats', () => { 10 | const input = [0, 7, 2.5, 0.5]; 11 | expect(average(input)).toBe(2.5); 12 | }); 13 | 14 | it('returns null when input is empty', () => { 15 | const input = []; 16 | expect(average(input)).toBe(null); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/utils/__test__/buildSources.test.js: -------------------------------------------------------------------------------- 1 | const buildSources = require('../buildSources'); 2 | const { getRepoName } = require('../repos'); 3 | 4 | const ORG = 'org'; 5 | const REPO1 = 'org/repo1'; 6 | const REPO2 = 'org/repo2'; 7 | const REPO3 = 'org/repo3'; 8 | const REPO4 = 'org/repo4'; 9 | 10 | const buildGithubLink = ({ description, path }) => `[${description}](https://github.com/${path})`; 11 | 12 | const linkOrg = (org) => `[${org}](https://github.com/${org})`; 13 | 14 | const linkRepo = (repo) => `[${getRepoName(repo)}](https://github.com/${repo})`; 15 | 16 | describe('Interactors | .sources', () => { 17 | describe('when sending and organization', () => { 18 | const expected = `${linkOrg(ORG)}`; 19 | 20 | it('builds the message in singular', () => { 21 | const response = buildSources({ buildGithubLink, org: ORG, repos: null }); 22 | expect(response).toEqual(expected); 23 | }); 24 | }); 25 | 26 | describe('when sending 1 repo', () => { 27 | const repos = [REPO1]; 28 | const expected = `${linkRepo(REPO1)}`; 29 | 30 | it('builds the message in singular', () => { 31 | const response = buildSources({ buildGithubLink, repos }); 32 | expect(response).toEqual(expected); 33 | }); 34 | }); 35 | 36 | describe('when sending 2 repos', () => { 37 | const repos = [REPO1, REPO2]; 38 | const expected = `${linkRepo(REPO1)} and ${linkRepo(REPO2)}`; 39 | 40 | it('builds the message in singular', () => { 41 | const response = buildSources({ buildGithubLink, repos }); 42 | expect(response).toEqual(expected); 43 | }); 44 | }); 45 | 46 | describe('when sending 3 repos', () => { 47 | const repos = [REPO1, REPO2, REPO3]; 48 | const expectedLength = repos.length; 49 | const expected = `${linkRepo(REPO1)}, ${linkRepo(REPO2)} and ${linkRepo(REPO3)}`; 50 | 51 | it('builds the message in singular', () => { 52 | const response = buildSources({ buildGithubLink, repos }); 53 | expect(response).toEqual(expected); 54 | expect(repos.length).toEqual(expectedLength); 55 | }); 56 | }); 57 | 58 | describe('when sending 4 or more repos', () => { 59 | const repos = [REPO1, REPO2, REPO3, REPO4]; 60 | const expected = `${linkRepo(REPO1)}, ${linkRepo(REPO2)} and 2 others`; 61 | 62 | it('builds the message in singular', () => { 63 | const response = buildSources({ buildGithubLink, repos }); 64 | expect(response).toEqual(expected); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/utils/__test__/divide.test.js: -------------------------------------------------------------------------------- 1 | const divide = require('../divide'); 2 | 3 | describe('Utils | .divide', () => { 4 | it('returns a division of 2 numbers', () => { 5 | expect(divide(-27, 3)).toBe(-9); 6 | }); 7 | 8 | it('returns null when denominator is 0', () => { 9 | expect(divide(1, 0)).toBe(null); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/utils/__test__/median.test.js: -------------------------------------------------------------------------------- 1 | const median = require('../median'); 2 | 3 | describe('Utils | .median', () => { 4 | it('returns the number in the middle when list length is odd', () => { 5 | const input = [1, 7, 3, 0, 2]; 6 | expect(median(input)).toBe(2); 7 | }); 8 | 9 | it('returns the average of the 2 numbers in the middle when is pair', () => { 10 | const input = [1, 7, 3, 0, 2, 9]; 11 | expect(median(input)).toBe(2.5); 12 | }); 13 | 14 | it('returns null when input is empty', () => { 15 | const input = []; 16 | expect(median(input)).toBe(null); 17 | }); 18 | 19 | it('returns the only element when list has a length of 1', () => { 20 | const input = [5]; 21 | expect(median(input)).toBe(5); 22 | }); 23 | 24 | it('returns the average when list has a length of 2', () => { 25 | const input = [5, 3]; 26 | expect(median(input)).toBe(4); 27 | }); 28 | 29 | it('returns 0 when input is array of 0s', () => { 30 | const input = [0, 0, 0, 0]; 31 | expect(median(input)).toBe(0); 32 | }); 33 | 34 | it('returns the number in the middle', () => { 35 | const input = [492000, 4865000, 188000, 25000, 107000]; 36 | expect(median(input)).toBe(188000); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/utils/__test__/repos.test.js: -------------------------------------------------------------------------------- 1 | const { getRepoComponents, getRepoOwner, getRepoName } = require('../repos'); 2 | 3 | const repo = 'org1/repo1'; 4 | 5 | describe('Utils | repos', () => { 6 | describe('.getRepoComponents', () => { 7 | it('return the components of a repo name', () => { 8 | const expected = ['org1', 'repo1']; 9 | const result = getRepoComponents(repo); 10 | expect(result).toEqual(expected); 11 | }); 12 | }); 13 | 14 | describe('.getRepoOwner', () => { 15 | it('return the name of the repo owner', () => { 16 | const expected = 'org1'; 17 | const result = getRepoOwner(repo); 18 | expect(result).toEqual(expected); 19 | }); 20 | }); 21 | 22 | describe('.getRepoName', () => { 23 | it('return the name of the repo', () => { 24 | const expected = 'repo1'; 25 | const result = getRepoName(repo); 26 | expect(result).toEqual(expected); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/utils/__test__/sum.test.js: -------------------------------------------------------------------------------- 1 | const sum = require('../sum'); 2 | 3 | describe('Utils | .sum', () => { 4 | it('returns the sum of elements in a list', () => { 5 | const input = [1, 3, 7, 8]; 6 | expect(sum(input)).toBe(19); 7 | }); 8 | 9 | it('returns the sum of floats', () => { 10 | const input = [1, 3.2, 7.6, 8]; 11 | expect(sum(input)).toBe(19.8); 12 | }); 13 | 14 | it('returns 0 when input is empty', () => { 15 | const input = []; 16 | expect(sum(input)).toBe(0); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/utils/average.js: -------------------------------------------------------------------------------- 1 | const sum = require('./sum'); 2 | const divide = require('./divide'); 3 | 4 | module.exports = (list) => divide(sum(list), list.length); 5 | -------------------------------------------------------------------------------- /src/utils/buildSources.js: -------------------------------------------------------------------------------- 1 | const { t } = require('../i18n'); 2 | const { getRepoComponents } = require('./repos'); 3 | 4 | module.exports = ({ 5 | org, 6 | repos, 7 | buildGithubLink, 8 | limit = 3, 9 | }) => { 10 | const buildLink = (path) => { 11 | const [owner, name] = getRepoComponents(path); 12 | const description = name || owner; 13 | return buildGithubLink({ description, path }); 14 | }; 15 | 16 | const buildLimitedSources = (sources) => { 17 | const firsts = sources.slice(0, limit - 1); 18 | const othersCount = sources.length - firsts.length; 19 | return t('table.sources.andOthers', { 20 | firsts: firsts.map(buildLink).join(t('table.sources.separator')), 21 | count: othersCount, 22 | }); 23 | }; 24 | 25 | const buildFullList = (sources) => { 26 | const firsts = sources.slice(0, sources.length - 1); 27 | const last = sources[sources.length - 1]; 28 | return t('table.sources.fullList', { 29 | firsts: firsts.map(buildLink).join(t('table.sources.separator')), 30 | last: buildLink(last), 31 | }); 32 | }; 33 | 34 | const getSources = () => { 35 | if (org) return buildLink(org); 36 | if (repos.length === 1) return buildLink(repos[0]); 37 | if (repos.length > limit) return buildLimitedSources(repos); 38 | return buildFullList(repos); 39 | }; 40 | 41 | return getSources(); 42 | }; 43 | -------------------------------------------------------------------------------- /src/utils/divide.js: -------------------------------------------------------------------------------- 1 | module.exports = (numerator, denominator) => { 2 | if (!denominator) return null; 3 | return numerator / denominator; 4 | }; 5 | -------------------------------------------------------------------------------- /src/utils/durationToString.js: -------------------------------------------------------------------------------- 1 | const humanizeDuration = require('humanize-duration'); 2 | 3 | const parser = humanizeDuration.humanizer({ 4 | language: 'shortEn', 5 | languages: { 6 | shortEn: { 7 | y: () => 'y', 8 | mo: () => 'mo', 9 | w: () => 'w', 10 | d: () => 'd', 11 | h: () => 'h', 12 | m: () => 'm', 13 | s: () => 's', 14 | ms: () => 'ms', 15 | }, 16 | }, 17 | }); 18 | 19 | module.exports = (value) => parser(value, { 20 | delimiter: ' ', 21 | spacer: '', 22 | units: ['d', 'h', 'm'], 23 | round: true, 24 | }); 25 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | const average = require('./average'); 2 | const buildSources = require('./buildSources'); 3 | const divide = require('./divide'); 4 | const durationToString = require('./durationToString'); 5 | const isNil = require('./isNil'); 6 | const median = require('./median'); 7 | const repos = require('./repos'); 8 | const subtractDaysToDate = require('./subtractDaysToDate'); 9 | const sum = require('./sum'); 10 | 11 | module.exports = { 12 | ...repos, 13 | average, 14 | buildSources, 15 | divide, 16 | durationToString, 17 | isNil, 18 | median, 19 | subtractDaysToDate, 20 | sum, 21 | }; 22 | -------------------------------------------------------------------------------- /src/utils/isNil.js: -------------------------------------------------------------------------------- 1 | module.exports = (value) => value === null || value === undefined; 2 | -------------------------------------------------------------------------------- /src/utils/median.js: -------------------------------------------------------------------------------- 1 | const average = require('./average'); 2 | 3 | const intSort = (a, b) => a - b; 4 | 5 | module.exports = (list) => { 6 | const sorted = (list || []).sort(intSort); 7 | const middle = Math.floor(sorted.length / 2); 8 | const isOdd = sorted.length % 2 !== 0; 9 | if (isOdd) return sorted[middle] || null; 10 | return average(sorted.slice(middle - 1, middle + 1)); 11 | }; 12 | -------------------------------------------------------------------------------- /src/utils/repos.js: -------------------------------------------------------------------------------- 1 | const getRepoComponents = (repo) => repo.split('/'); 2 | 3 | const getRepoOwner = (repo) => { 4 | const [owner] = getRepoComponents(repo); 5 | return owner; 6 | }; 7 | 8 | const getRepoName = (repo) => { 9 | const [, name] = getRepoComponents(repo); 10 | return name; 11 | }; 12 | 13 | module.exports = { 14 | getRepoComponents, 15 | getRepoOwner, 16 | getRepoName, 17 | }; 18 | -------------------------------------------------------------------------------- /src/utils/subtractDaysToDate.js: -------------------------------------------------------------------------------- 1 | const DAY_IN_SEC = 24 * 60 * 60 * 1000; 2 | 3 | module.exports = (date, days) => new Date(date.getTime() - days * DAY_IN_SEC); 4 | -------------------------------------------------------------------------------- /src/utils/sum.js: -------------------------------------------------------------------------------- 1 | module.exports = (list) => (list || []).reduce((a, b) => a + b, 0); 2 | -------------------------------------------------------------------------------- /tests/mocks/entries.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | user: { 4 | id: 'user1', 5 | url: 'https://github.com/user1', 6 | login: 'user1', 7 | avatarUrl: 'https://avatars.githubusercontent.com/u/user1', 8 | }, 9 | reviews: [ 10 | { 11 | submittedAt: '2021-03-12T17:41:18.000Z', 12 | id: 611016770, 13 | commentsCount: 0, 14 | timeToReview: 363000, 15 | pullRequestId: 591820759, 16 | }, 17 | { 18 | submittedAt: '2021-03-12T22:13:17.000Z', 19 | id: 611189051, 20 | commentsCount: 0, 21 | timeToReview: 4674000, 22 | pullRequestId: 591932158, 23 | }, 24 | { 25 | submittedAt: '2021-03-10T22:54:45.000Z', 26 | id: 609241758, 27 | commentsCount: 0, 28 | timeToReview: 292000, 29 | pullRequestId: 590242592, 30 | }, 31 | { 32 | submittedAt: '2021-03-29T18:25:17.000Z', 33 | id: 623480404, 34 | commentsCount: 1, 35 | timeToReview: 3742000, 36 | pullRequestId: 602948274, 37 | }, 38 | ], 39 | stats: { 40 | totalReviews: 4, 41 | totalComments: 1, 42 | commentsPerReview: 0.25, 43 | timeToReview: 2_052_500, 44 | openedPullRequests: 7, 45 | }, 46 | urls: { 47 | timeToReview: 'https://app.flowwer.dev/charts/review-time/1', 48 | }, 49 | contributions: { 50 | totalReviews: 0.8, 51 | totalComments: 0.166666, 52 | commentsPerReview: 0.04, 53 | timeToReview: 0.178, 54 | openedPullRequests: 0.175, 55 | }, 56 | }, 57 | { 58 | user: { 59 | id: 'user2', 60 | url: 'https://github.com/user2', 61 | login: 'user2', 62 | avatarUrl: 'https://avatars.githubusercontent.com/u/user2', 63 | }, 64 | reviews: [ 65 | { 66 | submittedAt: '2021-03-15T12:14:00.000Z', 67 | id: 611015896, 68 | commentsCount: 5, 69 | timeToReview: 8465000, 70 | pullRequestId: 5918228571, 71 | }, 72 | ], 73 | stats: { 74 | totalReviews: 1, 75 | totalComments: 5, 76 | commentsPerReview: 5, 77 | timeToReview: 8_465_000, 78 | openedPullRequests: 3, 79 | }, 80 | urls: { 81 | timeToReview: 'https://app.flowwer.dev/charts/review-time/2', 82 | }, 83 | contributions: { 84 | totalReviews: 0.2, 85 | totalComments: 0.83333333, 86 | commentsPerReview: 0.8, 87 | timeToReview: 0.736, 88 | openedPullRequests: 0.075, 89 | }, 90 | }, 91 | { 92 | user: { 93 | id: 'user3', 94 | url: 'https://github.com/user3', 95 | login: 'user3', 96 | avatarUrl: 'https://avatars.githubusercontent.com/u/user3', 97 | }, 98 | reviews: [], 99 | stats: { 100 | totalReviews: 0, 101 | totalComments: 0, 102 | commentsPerReview: 1, 103 | timeToReview: 1_000_000, 104 | openedPullRequests: 30, 105 | }, 106 | urls: { 107 | timeToReview: 'https://app.flowwer.dev/charts/review-time/3', 108 | }, 109 | contributions: { 110 | totalReviews: 0, 111 | totalComments: 0, 112 | commentsPerReview: 0.16, 113 | timeToReview: 0.086, 114 | openedPullRequests: 0.75, 115 | }, 116 | }, 117 | ]; 118 | -------------------------------------------------------------------------------- /tests/mocks/index.js: -------------------------------------------------------------------------------- 1 | const entries = require('./entries'); 2 | const pullRequests = require('./pullRequests'); 3 | const pullRequestStats = require('./pullRequestStats'); 4 | const reviewStats = require('./reviewStats'); 5 | const reviews = require('./reviews'); 6 | const table = require('./table'); 7 | const users = require('./users'); 8 | 9 | module.exports = { 10 | entries, 11 | pullRequests, 12 | pullRequestStats, 13 | reviewStats, 14 | reviews, 15 | table, 16 | users, 17 | }; 18 | -------------------------------------------------------------------------------- /tests/mocks/pullRequestStats.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | userId: '1234', 4 | stats: { 5 | openedPullRequests: 17, 6 | totalObservations: 68, 7 | medianObservations: 4, 8 | revisionSuccessRate: 0.35, 9 | additions: 100, 10 | deletions: 50, 11 | lines: 150, 12 | }, 13 | }, 14 | { 15 | userId: '5678', 16 | stats: { 17 | openedPullRequests: 25, 18 | totalObservations: 200, 19 | medianObservations: 8, 20 | revisionSuccessRate: 0.45, 21 | additions: 2000, 22 | deletions: 300, 23 | lines: 2300, 24 | }, 25 | }, 26 | { 27 | userId: '9090', 28 | stats: { 29 | openedPullRequests: 1, 30 | totalObservations: 2, 31 | medianObservations: 2, 32 | revisionSuccessRate: 0, 33 | additions: 0, 34 | deletions: 10, 35 | lines: 10, 36 | }, 37 | }, 38 | ]; 39 | -------------------------------------------------------------------------------- /tests/mocks/pullRequests.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | id: 12345, 4 | additions: 100, 5 | deletions: 50, 6 | cursor: 'Y3Vyc29yOjQ=', 7 | publishedAt: new Date('2021-02-12T23:54:38Z'), 8 | author: { 9 | id: '1031639', 10 | url: 'https://github.com/manuelmhtr', 11 | login: 'manuelmhtr', 12 | avatarUrl: 'https://avatars.githubusercontent.com/u/1031639?u=30204017b73f7a1f08005cb8ead3f70b0410486c&v=4', 13 | }, 14 | reviews: [ 15 | { 16 | id: 5678, 17 | submittedAt: new Date('2021-02-12T23:55:22Z'), 18 | commentsCount: 1, 19 | isApproved: true, 20 | body: 'This is an approved review', 21 | author: { 22 | id: '1031639', 23 | url: 'https://github.com/manuelmhtr', 24 | login: 'manuelmhtr', 25 | avatarUrl: 'https://avatars.githubusercontent.com/u/1031639?u=30204017b73f7a1f08005cb8ead3f70b0410486c&v=4', 26 | }, 27 | isOwnPull: true, 28 | timeToReview: 123000, 29 | }, 30 | { 31 | id: 5679, 32 | submittedAt: new Date('2021-02-15T16:00:59Z'), 33 | commentsCount: 3, 34 | isApproved: false, 35 | body: ' ', 36 | author: { 37 | id: '8755542', 38 | url: 'https://github.com/jartmez', 39 | login: 'jartmez', 40 | avatarUrl: 'https://avatars.githubusercontent.com/u/8755542?v=4', 41 | }, 42 | isOwnPull: false, 43 | timeToReview: 75000, 44 | }, 45 | ], 46 | }, 47 | { 48 | id: 56789, 49 | additions: 73, 50 | deletions: 37, 51 | cursor: 'Y3Vmh29yO21=', 52 | publishedAt: new Date('2021-02-07T00:14:38Z'), 53 | author: { 54 | id: '2009676', 55 | url: 'https://github.com/javierbyte', 56 | login: 'javierbyte', 57 | avatarUrl: 'https://avatars.githubusercontent.com/u/2009676', 58 | }, 59 | reviews: [ 60 | { 61 | id: 9876, 62 | submittedAt: new Date('2021-02-08T00:00:00Z'), 63 | commentsCount: 3, 64 | isApproved: false, 65 | body: 'This is a rejected review', 66 | author: { 67 | id: '1031639', 68 | url: 'https://github.com/manuelmhtr', 69 | login: 'manuelmhtr', 70 | avatarUrl: 'https://avatars.githubusercontent.com/u/1031639?u=30204017b73f7a1f08005cb8ead3f70b0410486c&v=4', 71 | }, 72 | isOwnPull: false, 73 | timeToReview: 55000, 74 | }, 75 | { 76 | id: 9877, 77 | submittedAt: new Date('2021-02-09T00:00:00Z'), 78 | commentsCount: 2, 79 | isApproved: true, 80 | body: 'It\'s something', 81 | author: { 82 | id: '8755542', 83 | url: 'https://github.com/jartmez', 84 | login: 'jartmez', 85 | avatarUrl: 'https://avatars.githubusercontent.com/u/8755542?v=4', 86 | }, 87 | isOwnPull: false, 88 | timeToReview: 145000, 89 | }, 90 | ], 91 | }, 92 | ]; 93 | -------------------------------------------------------------------------------- /tests/mocks/reviewStats.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | userId: '1234', 4 | reviews: [ 5 | { 6 | submittedAt: '2021-03-12T17:41:18.000Z', 7 | id: 611016770, 8 | commentsCount: 0, 9 | timeToReview: 363000, 10 | pullRequestId: 591820759, 11 | }, 12 | { 13 | submittedAt: '2021-03-12T22:13:17.000Z', 14 | id: 611189051, 15 | commentsCount: 0, 16 | timeToReview: 4674000, 17 | pullRequestId: 591932158, 18 | }, 19 | { 20 | submittedAt: '2021-03-10T22:54:45.000Z', 21 | id: 609241758, 22 | commentsCount: 0, 23 | timeToReview: 292000, 24 | pullRequestId: 590242592, 25 | }, 26 | { 27 | submittedAt: '2021-03-29T18:25:17.000Z', 28 | id: 623480404, 29 | commentsCount: 1, 30 | timeToReview: 3742000, 31 | pullRequestId: 602948274, 32 | }, 33 | ], 34 | stats: { 35 | totalReviews: 4, 36 | totalComments: 1, 37 | timeToReview: 2052500, 38 | commentsPerReview: 0.25, 39 | reviewedAdditions: 1_000, 40 | reviewedDeletions: 500, 41 | reviewedLines: 1_500, 42 | }, 43 | }, 44 | { 45 | userId: '5678', 46 | reviews: [ 47 | { 48 | submittedAt: '2021-03-15T12:14:00.000Z', 49 | id: 611015896, 50 | commentsCount: 5, 51 | timeToReview: 8465000, 52 | pullRequestId: 5918228571, 53 | }, 54 | ], 55 | stats: { 56 | totalReviews: 1, 57 | totalComments: 5, 58 | timeToReview: 8465000, 59 | commentsPerReview: 5, 60 | reviewedAdditions: 5_000, 61 | reviewedDeletions: 4_500, 62 | reviewedLines: 9_500, 63 | }, 64 | }, 65 | ]; 66 | -------------------------------------------------------------------------------- /tests/mocks/reviews.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | id: 5679, 4 | pullRequestId: 12345, 5 | submittedAt: new Date('2021-02-15T16:00:59.000Z'), 6 | commentsCount: 3, 7 | timeToReview: 75000, 8 | }, 9 | { 10 | id: 9877, 11 | pullRequestId: 12345, 12 | submittedAt: new Date('2021-02-09T00:00:00.000Z'), 13 | commentsCount: 2, 14 | timeToReview: 145000, 15 | }, 16 | { 17 | id: 9855, 18 | pullRequestId: 56789, 19 | submittedAt: new Date('2021-02-11T00:00:00.000Z'), 20 | commentsCount: 1, 21 | timeToReview: 25000, 22 | }, 23 | ]; 24 | -------------------------------------------------------------------------------- /tests/mocks/table.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | headers: [ 3 | { 4 | text: 'User', 5 | }, 6 | { 7 | text: 'Total reviews', 8 | }, 9 | { 10 | text: 'Time to review', 11 | }, 12 | { 13 | text: 'Total comments', 14 | }, 15 | { 16 | text: 'Comments per review', 17 | }, 18 | { 19 | text: 'Opened PRs', 20 | }, 21 | ], 22 | rows: [ 23 | { 24 | user: { 25 | link: 'https://github.com/user1', 26 | image: 'https://avatars.githubusercontent.com/u/user1', 27 | text: 'user1', 28 | emoji: 'medal1', 29 | }, 30 | stats: [ 31 | { 32 | text: '4', 33 | link: null, 34 | chartValue: 0.8, 35 | bold: true, 36 | }, 37 | { 38 | text: '34m', 39 | link: 'https://app.flowwer.dev/charts/review-time/1', 40 | chartValue: 0.178, 41 | bold: false, 42 | }, 43 | { 44 | text: '1', 45 | link: null, 46 | chartValue: 0.166666, 47 | bold: false, 48 | }, 49 | { 50 | text: '0.25', 51 | link: null, 52 | chartValue: 0.04, 53 | bold: false, 54 | }, 55 | { 56 | text: '7', 57 | link: null, 58 | chartValue: 0.175, 59 | bold: false, 60 | }, 61 | ], 62 | }, 63 | { 64 | user: { 65 | link: 'https://github.com/user2', 66 | image: 'https://avatars.githubusercontent.com/u/user2', 67 | text: 'user2', 68 | emoji: 'medal2', 69 | }, 70 | stats: [ 71 | { 72 | text: '1', 73 | link: null, 74 | chartValue: 0.2, 75 | bold: false, 76 | }, 77 | { 78 | text: '2h 21m', 79 | link: 'https://app.flowwer.dev/charts/review-time/2', 80 | chartValue: 0.736, 81 | bold: false, 82 | }, 83 | { 84 | text: '5', 85 | link: null, 86 | chartValue: 0.83333333, 87 | bold: true, 88 | }, 89 | { 90 | text: '5', 91 | link: null, 92 | chartValue: 0.8, 93 | bold: true, 94 | }, 95 | { 96 | text: '3', 97 | link: null, 98 | chartValue: 0.075, 99 | bold: false, 100 | }, 101 | ], 102 | }, 103 | { 104 | user: { 105 | link: 'https://github.com/user3', 106 | image: 'https://avatars.githubusercontent.com/u/user3', 107 | text: 'user3', 108 | emoji: 'medal3', 109 | }, 110 | stats: [ 111 | { 112 | text: '0', 113 | link: null, 114 | chartValue: 0, 115 | bold: false, 116 | }, 117 | { 118 | text: '17m', 119 | link: 'https://app.flowwer.dev/charts/review-time/3', 120 | chartValue: 0.086, 121 | bold: true, 122 | }, 123 | { 124 | text: '0', 125 | link: null, 126 | chartValue: 0, 127 | bold: false, 128 | }, 129 | { 130 | text: '1', 131 | link: null, 132 | chartValue: 0.16, 133 | bold: false, 134 | }, 135 | { 136 | text: '30', 137 | link: null, 138 | chartValue: 0.75, 139 | bold: true, 140 | }, 141 | ], 142 | }, 143 | ], 144 | }; 145 | -------------------------------------------------------------------------------- /tests/mocks/users.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | id: '1234', 4 | url: 'https://github.com/user1', 5 | login: 'user1', 6 | avatarUrl: 'https://avatars.githubusercontent.com/u/1234', 7 | }, 8 | { 9 | id: '5678', 10 | url: 'https://github.com/user2', 11 | login: 'user2', 12 | avatarUrl: 'https://avatars.githubusercontent.com/u/5678', 13 | }, 14 | { 15 | id: '9090', 16 | url: 'https://github.com/user3', 17 | login: 'user3', 18 | avatarUrl: 'https://avatars.githubusercontent.com/u/9090', 19 | }, 20 | { 21 | id: '0101', 22 | url: 'https://github.com/user4', 23 | login: 'user4', 24 | avatarUrl: 'https://avatars.githubusercontent.com/u/0101', 25 | }, 26 | ]; 27 | --------------------------------------------------------------------------------