├── .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 | 
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 | 
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 |
--------------------------------------------------------------------------------