diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7f2489d..0127539 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,10 @@
# Changelog
All notable changes to this project will be documented in this file.
+## [2.6.0] - 2022-10-24
+### Added
+- Microsoft Teams integration.
+
## [2.5.1] - 2022-10-17
### Changed
- Running on NodeJS 16.
diff --git a/README.md b/README.md
index e44ce9c..38b11dd 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ The objective of this action is to:
* Reduce the time taken to review the pull requests.
* Encourage quality on reviews.
-* Help deciding which people to assign as reviewers.
+* Help to decide which people to assign as reviewers.
Running this action will add a section at the bottom of your pull requests description:
@@ -19,9 +19,11 @@ Each reviewer has a link pointing to their [historical behavior](https://app.flo
![](/assets/historical.png)
-Or integrate this action with **Slack**:
+Or send the data to your favorite tools by using the integrations available:
+
+|
[Slack](/docs/slack.md) |
[MS Teams](/docs/teams.md) |
[Webhooks](/docs/webhook.md) |
+| :--: | :--: | :--: |
-![](/assets/slack.png)
## Privacy
* **No repository data is collected**, stored or distributed by this GitHub action. This action is **state-less**.
@@ -53,8 +55,9 @@ The possible inputs for this action are:
| `sort-by` | The column used to sort the data. Possible values: `REVIEWS`, `TIME`, `COMMENTS`. | `REVIEWS` |
| `publish-as` | Where to publish the results. Possible values: as a `COMMENT`, on the pull request `DESCRIPTION`. | `COMMENT` |
| `telemetry` | Indicates if the action is allowed to send monitoring data to the developer. This data is [minimal](/src/services/telemetry/sendStart.js) and helps me improve this action. **This option is a premium feature reserved for [sponsors](#premium-features-).** |`true`|
-| `slack-webhook` | **š„ New.** A Slack webhook URL to post resulting stats. **This option is a premium feature reserved for [sponsors](#premium-features-).** |`null`|
+| `slack-webhook` | **š„ New.** A Slack webhook URL to post resulting stats. **This option is a premium feature reserved for [sponsors](#premium-features-).** See [full documentation here](/docs/slack.md). |`null`|
| `slack-channel` | The Slack channel where stats will be posted. Include the `#` character (eg. `#mychannel`). Required when a `slack-webhook` is configured. |`null`|
+| `teams-webhook` | **š„ New.** A Microsoft Teams webhook URL to post resulting stats. **This option is a premium feature reserved for [sponsors](#premium-features-).** See [full documentation here](/docs/teams.md). |`null`|
| `webhook` | **š„ New.** A webhook URL to send the resulting stats as JSON (integrate with Zapier, IFTTT...). See [full documentation here](/docs/webhook.md). |`null`|
@@ -150,34 +153,13 @@ The stats are calculated as following:
* **Total reviews:** It is the count of all Pull Requests reviewed by a person in the period.
* **Total comments:** It is the count of all the comments while reviewing other user's Pull Requests in the period (comments in own PRs don't count).
-## Slack integration
-
-To configure the Slack, integration:
-
-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 on how to get the webhook URL.
-2. Set the `slack-webhook` (from previous step) and `slack-channel` (don't forget to include the `#` character) parameters in this action.
-3. Ready to go!
-
-Since it may be quite annoying to receive a Slack notification everytime 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:
+## Integrations š
-```yml
-name: Pull Request Stats
-
-on:
- schedule:
- - cron: '0 9 * * 1'
+Check the guide for the tool you want to integrate:
-jobs:
- stats:
- runs-on: ubuntu-latest
- steps:
- - name: Run pull request stats
- uses: flowwer-dev/pull-request-stats@master
- with:
- slack-channel: '#mystatschannel'
- slack-webhook: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
- # slack-webhook: ${{ secrets.SLACK_WEBHOOK }} You may want to store this value as a secret.
-```
+* [Slack](/docs/slack.md)
+* [Microsoft Teams](/docs/teams.md)
+* [Webhooks](/docs/webhook.md)
## Troubleshooting
@@ -202,17 +184,18 @@ This action offers some premium features only for sponsors:
* Disabling telemetry.
* Slack integration.
-* Comming soon: Microsoft teams and discord integrations.
+* Microsoft Teams integration.
+* Comming soon: Discord integration, web version.
No minimum amount is required for now. In the future, a minimum monthly sponsorship will be inforced to access premium features.
-Suggested sponsorship is $20 usd / month. Thanks for your support! š
+The minimum suggested sponsorship is $20 usd / month. Thanks for your support! š
## Used by
Used by hundreds of successful teams:
-|
Sixt |
Lululemon |
Delivery H |
JOKR |
Qatalog |
LOOP |
Hatch |
Zenfi |
+|
Sixt |
Lululemon |
Delivery H |
JOKR |
Lego |
LOOP |
Hatch |
Zenfi |
| :--: | :--: | :--: | :--: | :--: | :--: | :--: | :--: |
|
**Trivago** |
**Discovery** |
**Addition** |
**Fauna** |
**CDC** |
**Wecasa** |
**Bolt** |
**Republic** |
diff --git a/action.yml b/action.yml
index df0abc3..7f68e5c 100644
--- a/action.yml
+++ b/action.yml
@@ -39,9 +39,6 @@ inputs:
description: 'Prevents from adding any external links in the stats'
required: false
default: false
- webhook:
- description: 'A webhook URL to post resulting stats.'
- required: false
telemetry:
description: 'Indicates if the action is allowed to send monitoring data to the developer.'
required: false
@@ -52,6 +49,12 @@ inputs:
slack-channel:
description: 'The Slack channel where stats will be posted. Required when a Slack webhook is configured.'
required: false
+ teams-webhook:
+ description: 'A Microsoft Teams webhook URL to post resulting stats.'
+ required: false
+ webhook:
+ description: 'A webhook URL to post resulting stats.'
+ required: false
runs:
using: 'node16'
main: 'dist/index.js'
diff --git a/assets/slack-logo.jpg b/assets/slack-logo.jpg
new file mode 100644
index 0000000..e5910a1
Binary files /dev/null and b/assets/slack-logo.jpg differ
diff --git a/assets/teams-logo.jpg b/assets/teams-logo.jpg
new file mode 100644
index 0000000..ac1bb21
Binary files /dev/null and b/assets/teams-logo.jpg differ
diff --git a/assets/teams.png b/assets/teams.png
new file mode 100644
index 0000000..0efa5cd
Binary files /dev/null and b/assets/teams.png differ
diff --git a/assets/webhook-logo.jpg b/assets/webhook-logo.jpg
new file mode 100644
index 0000000..0854970
Binary files /dev/null and b/assets/webhook-logo.jpg differ
diff --git a/dist/index.js b/dist/index.js
index d6f8652..503039e 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -180,6 +180,46 @@ module.exports = require("tls");
module.exports = eval("require")("encoding");
+/***/ }),
+
+/***/ 22:
+/***/ (function(module, __unusedexports, __webpack_require__) {
+
+const { getTeamsBytesLimit } = __webpack_require__(508);
+const { median } = __webpack_require__(353);
+const BaseSplitter = __webpack_require__(583);
+
+class TeamsSplitter extends BaseSplitter {
+ static defaultLimit() {
+ return getTeamsBytesLimit();
+ }
+
+ static splitBlocks(body, count) {
+ const firsts = body.slice(0, count);
+ const lasts = body.slice(count);
+ return [firsts, lasts];
+ }
+
+ static calculateSize(body) {
+ return Buffer.byteLength(JSON.stringify(body));
+ }
+
+ static getBlocksCount(body) {
+ return body.length;
+ }
+
+ static calculateSizePerBlock(body) {
+ const blockLengths = body
+ .filter(({ type }) => type === 'ColumnSet')
+ .map((block) => this.calculateSize(block));
+
+ return Math.ceil(median(blockLengths));
+ }
+}
+
+module.exports = TeamsSplitter;
+
+
/***/ }),
/***/ 26:
@@ -563,6 +603,20 @@ module.exports = {
};
+/***/ }),
+
+/***/ 40:
+/***/ (function(module, __unusedexports, __webpack_require__) {
+
+const SlackSplitter = __webpack_require__(681);
+const TeamsSplitter = __webpack_require__(22);
+
+module.exports = {
+ SlackSplitter,
+ TeamsSplitter,
+};
+
+
/***/ }),
/***/ 49:
@@ -647,7 +701,7 @@ module.exports = (reviews) => {
/***/ 61:
/***/ (function(module) {
-module.exports = {"slack":{"logs":{"notConfigured":"Slack integration is disabled. No webhook or channel configured.","posting":"Post a Slack message with params: {{params}}","success":"Successfully posted to slack"},"errors":{"notSponsor":"Slack integration is a premium feature, available to sponsors.\n(If you are already an sponsor, please make sure it configured as public).","requestFailed":"Error posting Slack message: {{error}}"}},"webhook":{"logs":{"notConfigured":"Webhook integration is disabled.","posting":"Post a Slack message with params: {{params}}","success":"Successfully posted to slack"},"errors":{"requestFailed":"Error posting Webhook: {{error}}"}}};
+module.exports = {"slack":{"logs":{"notConfigured":"Slack integration is disabled. No webhook or channel configured.","posting":"Post a Slack message with params: {{params}}","success":"Successfully posted to slack"},"errors":{"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).","requestFailed":"Error posting Slack message: {{error}}"}},"teams":{"logs":{"notConfigured":"Microsoft Teams integration is disabled. No webhook configured.","posting":"Post a MS Teams message with params: {{params}}","success":"Successfully posted to MS Teams"},"errors":{"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).","requestFailed":"Error posting MS Teams message: {{error}}"}},"webhook":{"logs":{"notConfigured":"Webhook integration is disabled.","posting":"Post a Slack message with params: {{params}}","success":"Successfully posted to slack"},"errors":{"requestFailed":"Error posting Webhook: {{error}}"}}};
/***/ }),
@@ -2305,85 +2359,6 @@ module.exports = ({
};
-/***/ }),
-
-/***/ 183:
-/***/ (function(module, __unusedexports, __webpack_require__) {
-
-const { durationToString } = __webpack_require__(353);
-
-const MEDALS = [
- ':first_place_medal:',
- ':second_place_medal:',
- ':third_place_medal:',
-]; /* š„š„š„ */
-
-const getUsername = ({ index, reviewer, displayCharts }) => {
- const { login, avatarUrl } = reviewer.author;
-
- const medal = displayCharts ? MEDALS[index] : null;
- const suffix = medal ? ` ${medal}` : '';
-
- return {
- type: 'context',
- elements: [
- {
- type: 'image',
- image_url: avatarUrl,
- alt_text: login,
- },
- {
- emoji: true,
- type: 'plain_text',
- text: `${login}${suffix}`,
- },
- ],
- };
-};
-
-const getStats = ({ t, reviewer, disableLinks }) => {
- const { stats, urls } = reviewer;
- const timeToReviewStr = durationToString(stats.timeToReview);
- const timeToReview = disableLinks
- ? timeToReviewStr
- : `<${urls.timeToReview}|${timeToReviewStr}>`;
-
- return {
- type: 'section',
- fields: [
- {
- type: 'mrkdwn',
- text: `*${t('table.columns.totalReviews')}:* ${stats.totalReviews}`,
- },
- {
- type: 'mrkdwn',
- text: `*${t('table.columns.totalComments')}:* ${stats.totalComments}`,
- },
- {
- type: 'mrkdwn',
- text: `*${t('table.columns.timeToReview')}:* ${timeToReview}`,
- },
- ],
- };
-};
-
-const getDivider = () => ({
- type: 'divider',
-});
-
-module.exports = ({
- t,
- index,
- reviewer,
- disableLinks,
- displayCharts,
-}) => [
- getUsername({ index, reviewer, displayCharts }),
- getStats({ t, reviewer, disableLinks }),
- getDivider(),
-];
-
-
/***/ }),
/***/ 191:
@@ -5774,47 +5749,6 @@ module.exports = ({
};
-/***/ }),
-
-/***/ 337:
-/***/ (function(module, __unusedexports, __webpack_require__) {
-
-const { t } = __webpack_require__(781);
-const buildSubtitle = __webpack_require__(859);
-const buildReviewer = __webpack_require__(183);
-
-module.exports = ({
- org,
- repos,
- reviewers,
- pullRequest,
- periodLength,
- disableLinks,
- displayCharts,
-}) => ({
- blocks: [
- ...buildSubtitle({
- t,
- org,
- repos,
- pullRequest,
- periodLength,
- }),
-
- ...reviewers.reduce((prev, reviewer, index) => [
- ...prev,
- ...buildReviewer({
- t,
- index,
- reviewer,
- disableLinks,
- displayCharts,
- })],
- []),
- ],
-});
-
-
/***/ }),
/***/ 352:
@@ -5908,6 +5842,129 @@ module.exports = {
};
+/***/ }),
+
+/***/ 354:
+/***/ (function(module, __unusedexports, __webpack_require__) {
+
+const { durationToString } = __webpack_require__(353);
+
+const MEDALS = [
+ 'š„',
+ 'š„',
+ 'š„',
+];
+
+const wrapUsername = ({
+ avatarUrl,
+ login,
+}) => ({
+ type: 'Column',
+ padding: 'None',
+ width: 'stretch',
+ spacing: 'Small',
+ separator: true,
+ items: [
+ {
+ type: 'ColumnSet',
+ padding: 'None',
+ columns: [
+ {
+ type: 'Column',
+ padding: 'None',
+ width: 'auto',
+ items: [
+ {
+ type: 'Image',
+ url: avatarUrl,
+ altText: login,
+ size: 'Small',
+ style: 'Person',
+ spacing: 'None',
+ horizontalAlignment: 'Left',
+ width: '32px',
+ height: '32px',
+ },
+ ],
+ },
+ {
+ type: 'Column',
+ padding: 'None',
+ width: 'stretch',
+ verticalContentAlignment: 'Center',
+ items: [
+ {
+ type: 'TextBlock',
+ text: login,
+ wrap: true,
+ horizontalAlignment: 'Left',
+ spacing: 'Small',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+});
+
+const wrapStat = (text) => ({
+ type: 'Column',
+ padding: 'None',
+ width: 'stretch',
+ spacing: 'Small',
+ verticalContentAlignment: 'Center',
+ items: [
+ {
+ text,
+ type: 'TextBlock',
+ wrap: true,
+ },
+ ],
+});
+
+const getUsername = ({ index, reviewer, displayCharts }) => {
+ const { login, avatarUrl } = reviewer.author;
+
+ const medal = displayCharts ? MEDALS[index] : null;
+ const suffix = medal ? ` ${medal}` : '';
+
+ return wrapUsername({
+ avatarUrl,
+ login: `${login}${suffix}`,
+ });
+};
+
+const getStats = ({ reviewer, disableLinks }) => {
+ const { stats, urls } = reviewer;
+ const timeToReviewStr = durationToString(stats.timeToReview);
+ const timeToReview = disableLinks
+ ? timeToReviewStr
+ : `[${timeToReviewStr}](${urls.timeToReview})`;
+
+ return [
+ wrapStat(timeToReview),
+ wrapStat(stats.totalReviews),
+ wrapStat(stats.totalComments),
+ ];
+};
+
+module.exports = ({
+ index,
+ reviewer,
+ disableLinks,
+ displayCharts,
+}) => ({
+ type: 'ColumnSet',
+ padding: 'Small',
+ spacing: 'None',
+ separator: true,
+ columns: [
+ getUsername({ index, reviewer, displayCharts }),
+ ...getStats({ reviewer, disableLinks }),
+ ],
+});
+
+
/***/ }),
/***/ 356:
@@ -6306,6 +6363,44 @@ module.exports = function enhanceError(error, config, code, request, response) {
};
+/***/ }),
+
+/***/ 375:
+/***/ (function(module, __unusedexports, __webpack_require__) {
+
+const { buildSources } = __webpack_require__(353);
+
+const getPRText = (pullRequest) => {
+ const { url, number } = pullRequest || {};
+ if (!url || !number) return '';
+ return ` ([#${number}](${url}))`;
+};
+
+const buildGithubLink = ({ description, path }) => `[${description}](https://github.com/${path})`;
+
+module.exports = ({
+ t,
+ org,
+ repos,
+ pullRequest,
+ periodLength,
+}) => {
+ const sources = buildSources({ buildGithubLink, org, repos });
+ return {
+ type: 'Container',
+ padding: 'Small',
+ items: [
+ {
+ type: 'TextBlock',
+ weight: 'Lighter',
+ wrap: true,
+ text: `${t('table.subtitle', { sources, count: periodLength })}${getPRText(pullRequest)}`,
+ },
+ ],
+ };
+};
+
+
/***/ }),
/***/ 379:
@@ -8395,26 +8490,6 @@ function createAgent(callback, opts) {
module.exports = createAgent;
//# sourceMappingURL=index.js.map
-/***/ }),
-
-/***/ 445:
-/***/ (function(module, __unusedexports, __webpack_require__) {
-
-const { STATS } = __webpack_require__(648);
-
-const calculatePercentage = (value, total) => {
- if (!total) return 0;
- return Math.min(1, Math.max(0, value / total));
-};
-
-const getContributions = (reviewer, totals) => STATS.reduce((prev, statsName) => {
- const percentage = calculatePercentage(reviewer.stats[statsName], totals[statsName]);
- return { ...prev, [statsName]: percentage };
-}, {});
-
-module.exports = getContributions;
-
-
/***/ }),
/***/ 448:
@@ -10404,14 +10479,39 @@ exports.RequestError = RequestError;
/***/ }),
-/***/ 469:
-/***/ (function(__unusedmodule, exports, __webpack_require__) {
-
-"use strict";
+/***/ 466:
+/***/ (function(module) {
-var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
- if (k2 === undefined) k2 = k;
- Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
+module.exports = (body) => ({
+ type: 'message',
+ attachments: [
+ {
+ contentType: 'application/vnd.microsoft.card.adaptive',
+ contentUrl: null,
+ content: {
+ body,
+ $schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
+ type: 'AdaptiveCard',
+ version: '1.0',
+ msteams: {
+ width: 'Full',
+ },
+ },
+ },
+ ],
+});
+
+
+/***/ }),
+
+/***/ 469:
+/***/ (function(__unusedmodule, exports, __webpack_require__) {
+
+"use strict";
+
+var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
+ if (k2 === undefined) k2 = k;
+ Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
@@ -11078,9 +11178,11 @@ module.exports = setup;
/***/ (function(module) {
const getSlackCharsLimit = () => 39000;
+const getTeamsBytesLimit = () => 27000;
module.exports = {
getSlackCharsLimit,
+ getTeamsBytesLimit,
};
@@ -11137,6 +11239,44 @@ function addHook(state, kind, name, hook) {
}
+/***/ }),
+
+/***/ 513:
+/***/ (function(module, __unusedexports, __webpack_require__) {
+
+const { buildSources } = __webpack_require__(353);
+
+const getPRText = (pullRequest) => {
+ const { url, number } = pullRequest || {};
+ if (!url || !number) return '';
+ return ` (<${url}|#${number}>)`;
+};
+
+const buildGithubLink = ({ description, path }) => ``;
+
+module.exports = ({
+ t,
+ org,
+ repos,
+ pullRequest,
+ periodLength,
+}) => {
+ const sources = buildSources({ buildGithubLink, org, repos });
+ return [
+ {
+ type: 'section',
+ text: {
+ type: 'mrkdwn',
+ text: `${t('table.subtitle', { sources, count: periodLength })}${getPRText(pullRequest)}`,
+ },
+ },
+ {
+ type: 'divider',
+ },
+ ];
+};
+
+
/***/ }),
/***/ 521:
@@ -13135,45 +13275,45 @@ module.exports = function settle(resolve, reject, response) {
/***/ }),
-/***/ 569:
+/***/ 570:
/***/ (function(module, __unusedexports, __webpack_require__) {
-const { getSlackCharsLimit } = __webpack_require__(508);
-const { median } = __webpack_require__(353);
-
-const CHARS_LIMIT = getSlackCharsLimit();
-
-const getSize = (obj) => JSON.stringify(obj).length;
-
-const getBlockLengths = (blocks) => blocks
- .filter(({ type }) => type === 'section') // Ignoring "divider" blocks
- .map((block) => getSize(block));
+const { t } = __webpack_require__(781);
+const { postToWebhook } = __webpack_require__(162);
+const buildPayload = __webpack_require__(108);
-const getSizePerBlock = (blocks) => Math.round(median(getBlockLengths(blocks)));
+module.exports = async ({
+ org,
+ repos,
+ core,
+ webhook,
+ reviewers,
+ periodLength,
+}) => {
+ if (!webhook) {
+ core.debug(t('integrations.webhook.logs.notConfigured'));
+ return;
+ }
-module.exports = (message) => {
- const blockSize = Math.max(1, getSizePerBlock(message.blocks));
+ const payload = buildPayload({
+ org,
+ repos,
+ reviewers,
+ periodLength,
+ });
- const getBlocksToSplit = (blocks) => {
- const currentSize = getSize({ blocks });
- const diff = currentSize - CHARS_LIMIT;
- if (diff < 0 || blocks.length === 1) return 0;
+ const params = { payload, webhook };
- const blocksSpace = Math.ceil(diff / blockSize);
- const blocksCount = Math.max(1, Math.min(blocks.length - 1, blocksSpace));
- const firsts = blocks.slice(0, blocksCount);
- return getBlocksToSplit(firsts) || blocksCount;
- };
+ core.debug(t('integrations.webhook.logs.posting', {
+ params: JSON.stringify(params, null, 2),
+ }));
- const getChunks = (prev, msg) => {
- const blocksToSplit = getBlocksToSplit(msg.blocks);
- if (!blocksToSplit) return [...prev, msg];
- const blocks = msg.blocks.slice(0, blocksToSplit);
- const others = msg.blocks.slice(blocksToSplit);
- return getChunks([...prev, { blocks }], { blocks: others });
- };
+ await postToWebhook({ payload, webhook }).catch((error) => {
+ core.error(t('integrations.webhook.errors.requestFailed', { error }));
+ throw error;
+ });
- return getChunks([], message);
+ core.debug(t('integrations.webhook.logs.success'));
};
@@ -13388,6 +13528,72 @@ module.exports = (logins) => [...(logins || [])]
.some((login) => externalSponsors.has(getHash(login)));
+/***/ }),
+
+/***/ 583:
+/***/ (function(module) {
+
+class BaseSplitter {
+ constructor({ message, limit = null }) {
+ this.limit = limit || this.constructor.defaultLimit();
+ this.message = message;
+ }
+
+ static defaultLimit() {
+ return Infinity;
+ }
+
+ get blockSize() {
+ if (!this.blockSizeMemo) {
+ this.blockSizeMemo = Math.max(1, this.constructor.calculateSizePerBlock(this.message));
+ }
+ return this.blockSizeMemo;
+ }
+
+ get chunks() {
+ if (!this.chunksMemo) this.chunksMemo = this.split([], this.message);
+ return this.chunksMemo;
+ }
+
+ split(prev, message) {
+ const blocksToSplit = this.calculateBlocksToSplit(message);
+ if (!blocksToSplit) return [...prev, message];
+ const [first, last] = this.constructor.splitBlocks(message, blocksToSplit);
+ return this.split([...prev, first], last);
+ }
+
+ calculateBlocksToSplit(message) {
+ const blocksCount = this.constructor.getBlocksCount(message);
+ const currentSize = this.constructor.calculateSize(message);
+ const diff = currentSize - this.limit;
+ if (diff < 0 || blocksCount === 1) return 0;
+
+ const blocksSpace = Math.ceil(diff / this.blockSize);
+ const blocksToSplit = Math.max(1, Math.min(blocksCount - 1, blocksSpace));
+ const [firsts] = this.constructor.splitBlocks(message, blocksToSplit);
+ return this.calculateBlocksToSplit(firsts) || blocksToSplit;
+ }
+
+ static splitBlocks() {
+ throw new Error('Not implemented');
+ }
+
+ static calculateSize() {
+ throw new Error('Not implemented');
+ }
+
+ static getBlocksCount() {
+ throw new Error('Not implemented');
+ }
+
+ static calculateSizePerBlock() {
+ throw new Error('Not implemented');
+ }
+}
+
+module.exports = BaseSplitter;
+
+
/***/ }),
/***/ 587:
@@ -13717,6 +13923,80 @@ exports.ensure_timestamp = function(time) {
};
+/***/ }),
+
+/***/ 612:
+/***/ (function(module, __unusedexports, __webpack_require__) {
+
+const { t } = __webpack_require__(781);
+const { postToWebhook } = __webpack_require__(162);
+const { TeamsSplitter } = __webpack_require__(40);
+const buildMessage = __webpack_require__(750);
+const buildPayload = __webpack_require__(466);
+
+const DELAY = 500;
+
+module.exports = async ({
+ org,
+ repos,
+ core,
+ teams,
+ isSponsor,
+ reviewers,
+ periodLength,
+ disableLinks,
+ displayCharts,
+ pullRequest = null,
+}) => {
+ const { webhook } = teams || {};
+
+ if (!webhook) {
+ core.debug(t('integrations.teams.logs.notConfigured'));
+ return;
+ }
+
+ if (!isSponsor) {
+ core.error(t('integrations.teams.errors.notSponsor'));
+ return;
+ }
+
+ const send = (body) => {
+ const params = {
+ webhook,
+ payload: buildPayload(body),
+ };
+ core.debug(t('integrations.teams.logs.posting', {
+ params: JSON.stringify(params, null, 2),
+ }));
+ return postToWebhook(params);
+ };
+
+ const fullMessage = buildMessage({
+ org,
+ repos,
+ reviewers,
+ pullRequest,
+ periodLength,
+ disableLinks,
+ displayCharts,
+ });
+
+ const { chunks } = new TeamsSplitter({ message: fullMessage });
+ await chunks.reduce(async (promise, message) => {
+ await promise;
+ await send(message).catch((error) => {
+ core.error(t('integrations.teams.errors.requestFailed', { error }));
+ throw error;
+ });
+ // Delaying between requests to prevent rate limiting
+ // https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using?tabs=cURL#rate-limiting-for-connectors
+ await new Promise((resolve) => setTimeout(resolve, DELAY));
+ }, Promise.resolve());
+
+ core.debug(t('integrations.teams.logs.success'));
+};
+
+
/***/ }),
/***/ 614:
@@ -13896,43 +14176,19 @@ exports.default = parseProxyResponse;
/***/ 660:
/***/ (function(module, __unusedexports, __webpack_require__) {
-const { t } = __webpack_require__(781);
-const { postToWebhook } = __webpack_require__(162);
-const buildPayload = __webpack_require__(108);
-
-module.exports = async ({
- org,
- repos,
- core,
- webhook,
- reviewers,
- periodLength,
-}) => {
- if (!webhook) {
- core.debug(t('integrations.webhook.logs.notConfigured'));
- return;
- }
-
- const payload = buildPayload({
- org,
- repos,
- reviewers,
- periodLength,
- });
-
- const params = { payload, webhook };
+const { STATS } = __webpack_require__(648);
- core.debug(t('integrations.webhook.logs.posting', {
- params: JSON.stringify(params, null, 2),
- }));
+const calculatePercentage = (value, total) => {
+ if (!total) return 0;
+ return Math.min(1, Math.max(0, value / total));
+};
- await postToWebhook({ payload, webhook }).catch((error) => {
- core.error(t('integrations.webhook.errors.requestFailed', { error }));
- throw error;
- });
+const getContributions = (reviewer, totals) => STATS.reduce((prev, statsName) => {
+ const percentage = calculatePercentage(reviewer.stats[statsName], totals[statsName]);
+ return { ...prev, [statsName]: percentage };
+}, {});
- core.debug(t('integrations.webhook.logs.success'));
-};
+module.exports = getContributions;
/***/ }),
@@ -13955,6 +14211,7 @@ const {
checkSponsorship,
alreadyPublished,
postSlackMessage,
+ postTeamsMessage,
postWebhook,
} = __webpack_require__(942);
@@ -14008,13 +14265,10 @@ const run = async (params) => {
});
core.debug(`Commit content built successfully: ${content}`);
- await postWebhook({ ...params, core, reviewers });
- await postSlackMessage({
- ...params,
- core,
- reviewers,
- pullRequest,
- });
+ const whParams = { ...params, core, reviewers };
+ await postWebhook(whParams);
+ await postSlackMessage({ ...whParams, pullRequest });
+ await postTeamsMessage({ ...whParams, pullRequest });
if (!pullRequestId) return;
await postComment({
@@ -14840,6 +15094,9 @@ const getParams = () => {
webhook: core.getInput('slack-webhook'),
channel: core.getInput('slack-channel'),
},
+ teams: {
+ webhook: core.getInput('teams-webhook'),
+ },
};
};
@@ -14865,6 +15122,48 @@ run();
module.exports = {"title":"Pull reviewers stats","icon":"https://s3.amazonaws.com/manuelmhtr.assets/flowwer/logo/logo-1024px.png","subtitle":{"one":"Stats of the last day for {{sources}}","other":"Stats of the last {{count}} days for {{sources}}"},"sources":{"separator":", ","fullList":"{{firsts}} and {{last}}","andOthers":"{{firsts}} and {{count}} others"},"columns":{"avatar":"","username":"User","timeToReview":"Time to review","totalReviews":"Total reviews","totalComments":"Total comments"}};
+/***/ }),
+
+/***/ 681:
+/***/ (function(module, __unusedexports, __webpack_require__) {
+
+const { getSlackCharsLimit } = __webpack_require__(508);
+const { median } = __webpack_require__(353);
+const BaseSplitter = __webpack_require__(583);
+
+class SlackSplitter extends BaseSplitter {
+ static defaultLimit() {
+ return getSlackCharsLimit();
+ }
+
+ static splitBlocks(message, count) {
+ const { blocks } = message;
+ const firsts = blocks.slice(0, count);
+ const lasts = blocks.slice(count);
+ return [{ blocks: firsts }, { blocks: lasts }];
+ }
+
+ static calculateSize(message) {
+ return JSON.stringify(message).length;
+ }
+
+ static getBlocksCount(message) {
+ return message.blocks.length;
+ }
+
+ static calculateSizePerBlock(message) {
+ const blockLengths = message
+ .blocks
+ .filter(({ type }) => type === 'section')
+ .map((block) => this.calculateSize(block));
+
+ return Math.ceil(median(blockLengths));
+ }
+}
+
+module.exports = SlackSplitter;
+
+
/***/ }),
/***/ 688:
@@ -15010,6 +15309,85 @@ module.exports = (value) => parser(value, {
});
+/***/ }),
+
+/***/ 721:
+/***/ (function(module, __unusedexports, __webpack_require__) {
+
+const { durationToString } = __webpack_require__(353);
+
+const MEDALS = [
+ ':first_place_medal:',
+ ':second_place_medal:',
+ ':third_place_medal:',
+]; /* š„š„š„ */
+
+const getUsername = ({ index, reviewer, displayCharts }) => {
+ const { login, avatarUrl } = reviewer.author;
+
+ const medal = displayCharts ? MEDALS[index] : null;
+ const suffix = medal ? ` ${medal}` : '';
+
+ return {
+ type: 'context',
+ elements: [
+ {
+ type: 'image',
+ image_url: avatarUrl,
+ alt_text: login,
+ },
+ {
+ emoji: true,
+ type: 'plain_text',
+ text: `${login}${suffix}`,
+ },
+ ],
+ };
+};
+
+const getStats = ({ t, reviewer, disableLinks }) => {
+ const { stats, urls } = reviewer;
+ const timeToReviewStr = durationToString(stats.timeToReview);
+ const timeToReview = disableLinks
+ ? timeToReviewStr
+ : `<${urls.timeToReview}|${timeToReviewStr}>`;
+
+ return {
+ type: 'section',
+ fields: [
+ {
+ type: 'mrkdwn',
+ text: `*${t('table.columns.totalReviews')}:* ${stats.totalReviews}`,
+ },
+ {
+ type: 'mrkdwn',
+ text: `*${t('table.columns.totalComments')}:* ${stats.totalComments}`,
+ },
+ {
+ type: 'mrkdwn',
+ text: `*${t('table.columns.timeToReview')}:* ${timeToReview}`,
+ },
+ ],
+ };
+};
+
+const getDivider = () => ({
+ type: 'divider',
+});
+
+module.exports = ({
+ t,
+ index,
+ reviewer,
+ disableLinks,
+ displayCharts,
+}) => [
+ getUsername({ index, reviewer, displayCharts }),
+ getStats({ t, reviewer, disableLinks }),
+ getDivider(),
+];
+
+
/***/ }),
/***/ 727:
@@ -15034,7 +15412,7 @@ module.exports = function bind(fn, thisArg) {
/***/ 731:
/***/ (function(module) {
-module.exports = {"name":"pull-request-stats","version":"2.5.1","description":"Github action to print relevant stats about Pull Request reviewers","main":"dist/index.js","scripts":{"build":"ncc build src/index.js","test":"eslint src && yarn run build && jest"},"keywords":[],"author":"Manuel de la Torre","license":"MIT","jest":{"testEnvironment":"node","testMatch":["**/?(*.)+(spec|test).[jt]s?(x)"]},"dependencies":{"@actions/core":"^1.5.0","@actions/github":"^5.0.0","@sentry/react-native":"^3.4.2","axios":"^0.26.1","dotenv":"^16.0.1","graphql":"^16.5.0","graphql-anywhere":"^4.2.7","humanize-duration":"^3.27.0","i18n-js":"^3.9.2","jsurl":"^0.1.5","lodash":"^4.17.21","lodash.get":"^4.4.2","lottie-react-native":"^5.1.3","markdown-table":"^2.0.0","mixpanel":"^0.13.0"},"devDependencies":{"@zeit/ncc":"^0.22.3","eslint":"^7.32.0","eslint-config-airbnb-base":"^14.2.1","eslint-plugin-import":"^2.24.1","eslint-plugin-jest":"^24.4.0","jest":"^27.0.6"},"funding":"https://github.com/sponsors/manuelmhtr"};
+module.exports = {"name":"pull-request-stats","version":"2.6.0","description":"Github action to print relevant stats about Pull Request reviewers","main":"dist/index.js","scripts":{"build":"eslint src && ncc build src/index.js","test":"jest"},"keywords":[],"author":"Manuel de la Torre","license":"MIT","jest":{"testEnvironment":"node","testMatch":["**/?(*.)+(spec|test).[jt]s?(x)"]},"dependencies":{"@actions/core":"^1.5.0","@actions/github":"^5.0.0","@sentry/react-native":"^3.4.2","axios":"^0.26.1","dotenv":"^16.0.1","graphql":"^16.5.0","graphql-anywhere":"^4.2.7","humanize-duration":"^3.27.0","i18n-js":"^3.9.2","jsurl":"^0.1.5","lodash":"^4.17.21","lodash.get":"^4.4.2","lottie-react-native":"^5.1.3","markdown-table":"^2.0.0","mixpanel":"^0.13.0"},"devDependencies":{"@zeit/ncc":"^0.22.3","eslint":"^7.32.0","eslint-config-airbnb-base":"^14.2.1","eslint-plugin-import":"^2.24.1","eslint-plugin-jest":"^24.4.0","jest":"^27.0.6"},"funding":"https://github.com/sponsors/manuelmhtr"};
/***/ }),
@@ -15049,6 +15427,47 @@ module.exports = function isCancel(value) {
};
+/***/ }),
+
+/***/ 741:
+/***/ (function(module, __unusedexports, __webpack_require__) {
+
+const { t } = __webpack_require__(781);
+const buildSubtitle = __webpack_require__(513);
+const buildReviewer = __webpack_require__(721);
+
+module.exports = ({
+ org,
+ repos,
+ reviewers,
+ pullRequest,
+ periodLength,
+ disableLinks,
+ displayCharts,
+}) => ({
+ blocks: [
+ ...buildSubtitle({
+ t,
+ org,
+ repos,
+ pullRequest,
+ periodLength,
+ }),
+
+ ...reviewers.reduce((prev, reviewer, index) => [
+ ...prev,
+ ...buildReviewer({
+ t,
+ index,
+ reviewer,
+ disableLinks,
+ displayCharts,
+ })],
+ []),
+ ],
+});
+
+
/***/ }),
/***/ 742:
@@ -15140,6 +15559,44 @@ exports.OidcClient = OidcClient;
module.exports = require("fs");
+/***/ }),
+
+/***/ 750:
+/***/ (function(module, __unusedexports, __webpack_require__) {
+
+const { t } = __webpack_require__(781);
+const buildHeaders = __webpack_require__(814);
+const buildSubtitle = __webpack_require__(375);
+const buildReviewer = __webpack_require__(354);
+
+module.exports = ({
+ org,
+ repos,
+ reviewers,
+ pullRequest,
+ periodLength,
+ disableLinks,
+ displayCharts,
+}) => ([
+ buildSubtitle({
+ t,
+ org,
+ repos,
+ pullRequest,
+ periodLength,
+ }),
+
+ buildHeaders({ t }),
+
+ ...reviewers.map((reviewer, index) => buildReviewer({
+ index,
+ reviewer,
+ disableLinks,
+ displayCharts,
+ })),
+]);
+
+
/***/ }),
/***/ 753:
@@ -16005,6 +16462,41 @@ exports.createTokenAuth = createTokenAuth;
//# sourceMappingURL=index.js.map
+/***/ }),
+
+/***/ 814:
+/***/ (function(module) {
+
+const wrapHeader = (text) => ({
+ type: 'Column',
+ padding: 'None',
+ width: 'stretch',
+ verticalContentAlignment: 'Center',
+ items: [
+ {
+ text,
+ type: 'TextBlock',
+ wrap: true,
+ weight: 'Bolder',
+ },
+ ],
+});
+
+module.exports = ({ t }) => ({
+ type: 'ColumnSet',
+ padding: 'Small',
+ horizontalAlignment: 'Left',
+ style: 'emphasis',
+ spacing: 'Small',
+ columns: [
+ wrapHeader(t('table.columns.username')),
+ wrapHeader(t('table.columns.timeToReview')),
+ wrapHeader(t('table.columns.totalReviews')),
+ wrapHeader(t('table.columns.totalComments')),
+ ],
+});
+
+
/***/ }),
/***/ 825:
@@ -18267,50 +18759,12 @@ function get(object, path, defaultValue) {
module.exports = get;
-/***/ }),
-
-/***/ 859:
-/***/ (function(module, __unusedexports, __webpack_require__) {
-
-const { buildSources } = __webpack_require__(353);
-
-const getPRText = (pullRequest) => {
- const { url, number } = pullRequest || {};
- if (!url || !number) return '';
- return ` (<${url}|#${number}>)`;
-};
-
-const buildGithubLink = ({ description, path }) => ``;
-
-module.exports = ({
- t,
- org,
- repos,
- pullRequest,
- periodLength,
-}) => {
- const sources = buildSources({ buildGithubLink, org, repos });
- return [
- {
- type: 'section',
- text: {
- type: 'mrkdwn',
- text: `${t('table.subtitle', { sources, count: periodLength })}${getPRText(pullRequest)}`,
- },
- },
- {
- type: 'divider',
- },
- ];
-};
-
-
/***/ }),
/***/ 861:
/***/ (function(module) {
-module.exports = {"logs":{"success":"Action successfully executed","news":"\nāØ New on v2.5:\nā¢ Slack integration\nā¢ Webhooks integration"},"errors":{"main":"Execution failed with error: {{message}}"}};
+module.exports = {"logs":{"success":"Action successfully executed","news":"\nāØ New on v2.6:\nā¢ Microsoft Teams integration\nā¢ Slack integration\nā¢ Webhooks integration"},"errors":{"main":"Execution failed with error: {{message}}"}};
/***/ }),
@@ -18413,8 +18867,8 @@ module.exports = require("tty");
const { t } = __webpack_require__(781);
const { postToSlack } = __webpack_require__(162);
-const buildSlackMessage = __webpack_require__(337);
-const splitInChunks = __webpack_require__(569);
+const { SlackSplitter } = __webpack_require__(40);
+const buildMessage = __webpack_require__(741);
module.exports = async ({
org,
@@ -18454,7 +18908,7 @@ module.exports = async ({
return postToSlack(params);
};
- const fullMessage = buildSlackMessage({
+ const fullMessage = buildMessage({
org,
repos,
reviewers,
@@ -18464,7 +18918,7 @@ module.exports = async ({
displayCharts,
});
- const chunks = splitInChunks(fullMessage);
+ const { chunks } = new SlackSplitter({ message: fullMessage });
await chunks.reduce(async (promise, message) => {
await promise;
return send(message).catch((error) => {
@@ -18691,7 +19145,7 @@ module.exports = function () {
/***/ (function(module, __unusedexports, __webpack_require__) {
const buildReviewTimeLink = __webpack_require__(922);
-const getContributions = __webpack_require__(445);
+const getContributions = __webpack_require__(660);
const calculateTotals = __webpack_require__(202);
const sortByStats = __webpack_require__(914);
@@ -18737,12 +19191,14 @@ module.exports = ({
limit,
tracker,
slack,
+ teams,
webhook,
}) => {
const owner = getRepoOwner(currentRepo);
const reposCount = (repos || []).length;
const orgsCount = org ? 1 : 0;
const usingSlack = !!(slack || {}).webhook;
+ const usingTeams = !!(teams || {}).webhook;
const usingWebhook = !!webhook;
tracker.track('run', {
@@ -18759,6 +19215,7 @@ module.exports = ({
disableLinks,
limit,
usingSlack,
+ usingTeams,
usingWebhook,
});
};
@@ -20300,9 +20757,10 @@ const checkSponsorship = __webpack_require__(402);
const getPulls = __webpack_require__(591);
const getReviewers = __webpack_require__(164);
const postComment = __webpack_require__(173);
-const postSlackMessage = __webpack_require__(878);
-const postWebhook = __webpack_require__(660);
const setUpReviewers = __webpack_require__(901);
+const postSlackMessage = __webpack_require__(878);
+const postTeamsMessage = __webpack_require__(612);
+const postWebhook = __webpack_require__(570);
module.exports = {
alreadyPublished,
@@ -20312,9 +20770,10 @@ module.exports = {
getPulls,
getReviewers,
postComment,
+ setUpReviewers,
postSlackMessage,
+ postTeamsMessage,
postWebhook,
- setUpReviewers,
};
diff --git a/docs/examples/teams copy.json b/docs/examples/teams copy.json
new file mode 100644
index 0000000..41527b6
--- /dev/null
+++ b/docs/examples/teams copy.json
@@ -0,0 +1,29 @@
+{
+ "type": "message",
+ "attachments": [
+ {
+ "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
+ "type": "AdaptiveCard",
+ "version": "1.0",
+ "msteams": {
+ "width": "Full"
+ },
+ "body": [
+ {
+ "type": "Container",
+ "id": "4a4631f4-8373-6aa4-9c42-0eb9be92bea4",
+ "padding": "Medium",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "f41ff117-10ce-4f16-372e-fb2948c18d4f",
+ "text": "Stats of the last 30 days for [myorganization](yotepresto.com)",
+ "wrap": true,
+ "weight": "Lighter"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/docs/examples/teams.json b/docs/examples/teams.json
new file mode 100644
index 0000000..c7d2fae
--- /dev/null
+++ b/docs/examples/teams.json
@@ -0,0 +1,703 @@
+{
+ "type": "message",
+ "attachments": [
+ {
+ "contentType": "application/vnd.microsoft.card.adaptive",
+ "contentUrl": null,
+ "content": {
+ "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
+ "type": "AdaptiveCard",
+ "version": "1.0",
+ "msteams": {
+ "width": "Full"
+ },
+ "body": [
+ {
+ "type": "Container",
+ "id": "4a4631f4-8373-6aa4-9c42-0eb9be92bea4",
+ "padding": "Medium",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "f41ff117-10ce-4f16-372e-fb2948c18d4f",
+ "text": "Stats of the last 30 days for [myorganization](yotepresto.com)",
+ "wrap": true,
+ "weight": "Lighter"
+ }
+ ]
+ },
+ {
+ "type": "ColumnSet",
+ "columns": [
+ {
+ "type": "Column",
+ "id": "113cdbc5-8433-44c1-f3a1-6fe66d17546e",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "b55aa3eb-f66f-eef1-245c-7aab642da9eb",
+ "text": "User",
+ "wrap": true,
+ "weight": "Bolder"
+ }
+ ],
+ "verticalContentAlignment": "Center"
+ },
+ {
+ "type": "Column",
+ "id": "721acc52-3506-2191-dd77-b083484e5b5a",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "a54acfc5-b4f8-838a-532a-f34e5e718a2a",
+ "text": "Time to review",
+ "wrap": true,
+ "weight": "Bolder",
+ "spacing": "None"
+ }
+ ],
+ "spacing": "Small",
+ "verticalContentAlignment": "Center"
+ },
+ {
+ "type": "Column",
+ "id": "6137df23-0b2d-4fec-c485-bd54e9998a7c",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "2eb6714c-1961-cbaf-34a4-d995e38d94bd",
+ "text": "Total reviews",
+ "weight": "Bolder",
+ "spacing": "None",
+ "wrap": true
+ }
+ ]
+ },
+ {
+ "type": "Column",
+ "id": "21555a99-e013-3960-e339-dabec7054e23",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "f98783d8-9cc8-87de-cef4-84f5bd24ecd8",
+ "text": "Total Comments",
+ "wrap": true,
+ "weight": "Bolder",
+ "spacing": "None"
+ }
+ ]
+ }
+ ],
+ "padding": "Small",
+ "horizontalAlignment": "Left",
+ "style": "emphasis",
+ "spacing": "None"
+ },
+ {
+ "type": "Container",
+ "id": "5f89be35-7dbc-235d-33af-bbd764a2b3fd",
+ "padding": "None",
+ "items": [
+ {
+ "type": "ColumnSet",
+ "id": "5115c953-b0b2-085d-6fbe-5d81ef7cd00a",
+ "columns": [
+ {
+ "type": "Column",
+ "id": "51db5e00-fe1e-ebed-1616-6386f7073c18",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "ColumnSet",
+ "columns": [
+ {
+ "type": "Column",
+ "id": "96a8f067-a2f6-e174-f1d1-3da66d0a2fc4",
+ "padding": "None",
+ "width": "auto",
+ "items": [
+ {
+ "type": "Image",
+ "id": "7d45cce0-2eba-65c7-57f6-aef6aed8ffd6",
+ "url": "https://avatars.githubusercontent.com/u/8755542?v=4",
+ "altText": "manuelmhtr",
+ "size": "Small",
+ "horizontalAlignment": "Left",
+ "width": "32px",
+ "spacing": "ExtraLarge",
+ "style": "Person",
+ "height": "32px"
+ }
+ ]
+ },
+ {
+ "type": "Column",
+ "id": "fb74a656-e79a-4b16-bc09-d97036ac70a9",
+ "padding": "Small",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "c8e5ba18-cf6c-c1fb-266b-e68b5559864b",
+ "text": "jartmez",
+ "wrap": true,
+ "horizontalAlignment": "Left",
+ "spacing": "Small"
+ }
+ ],
+ "verticalContentAlignment": "Center",
+ "spacing": "Small"
+ }
+ ],
+ "padding": "None"
+ }
+ ],
+ "spacing": "Small",
+ "separator": true
+ },
+ {
+ "type": "Column",
+ "id": "1532842e-9571-35ac-feb8-37b7614ad65a",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "e7362ead-4f46-416e-e16e-4bfe38900ae0",
+ "text": "[22m](link.com)",
+ "wrap": true
+ }
+ ],
+ "spacing": "Small",
+ "verticalContentAlignment": "Center"
+ },
+ {
+ "type": "Column",
+ "id": "077ece93-16c7-bd1e-286f-b8cfd4fed044",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "ef977db3-dee4-0e13-d633-9ec58ea55dc2",
+ "text": "37",
+ "wrap": true
+ }
+ ],
+ "spacing": "Small",
+ "verticalContentAlignment": "Center"
+ },
+ {
+ "type": "Column",
+ "id": "5fce2ab1-9ff0-ba9c-3963-e724f406f68b",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "a71484b4-ed4b-cbdc-a5d1-d13fd997645e",
+ "text": "13",
+ "wrap": true
+ }
+ ],
+ "spacing": "Small",
+ "verticalContentAlignment": "Center"
+ }
+ ],
+ "padding": "Small",
+ "spacing": "None",
+ "style": "default"
+ }
+ ],
+ "spacing": "None"
+ },
+ {
+ "type": "Container",
+ "id": "cfbcc72d-4d9a-33a3-7934-6ad70541ce3c",
+ "padding": "None",
+ "items": [
+ {
+ "type": "ColumnSet",
+ "id": "608e71e4-c7a4-a922-8a76-33f9f061a918",
+ "columns": [
+ {
+ "type": "Column",
+ "id": "3e35b85f-7b6f-a966-c971-92236266082f",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "ColumnSet",
+ "columns": [
+ {
+ "type": "Column",
+ "id": "9643f41f-ca19-720a-371e-28724ff86fb6",
+ "padding": "None",
+ "width": "auto",
+ "items": [
+ {
+ "type": "Image",
+ "id": "ae348904-e9ad-a64d-cbcd-8c2e71e597f0",
+ "url": "https://avatars.githubusercontent.com/u/2009676?v=4",
+ "altText": "manuelmhtr",
+ "size": "Small",
+ "horizontalAlignment": "Left",
+ "width": "32px",
+ "spacing": "ExtraLarge",
+ "style": "Person",
+ "height": "32px"
+ }
+ ]
+ },
+ {
+ "type": "Column",
+ "id": "94ff1fae-2c83-557f-129b-5343f1298a45",
+ "padding": "Small",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "bb1c228e-a0a6-5f38-308e-e8a87e48ff12",
+ "text": "javierbyte",
+ "wrap": true,
+ "horizontalAlignment": "Left",
+ "spacing": "Small"
+ }
+ ],
+ "verticalContentAlignment": "Center",
+ "spacing": "Small"
+ }
+ ],
+ "padding": "None"
+ }
+ ],
+ "spacing": "Small",
+ "separator": true
+ },
+ {
+ "type": "Column",
+ "id": "70648543-acbe-a14d-5301-faf4ba3e9d49",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "26f0d6b4-2be1-2fa4-7759-b9f53e53f2ac",
+ "text": "[30m](link.com)",
+ "wrap": true
+ }
+ ],
+ "spacing": "Small",
+ "verticalContentAlignment": "Center"
+ },
+ {
+ "type": "Column",
+ "id": "c86b6afb-359c-2d17-8f42-aedc9aa0b042",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "fdd6a8a7-33b0-cfd8-bd03-060562bcf087",
+ "text": "12",
+ "wrap": true
+ }
+ ],
+ "spacing": "Small",
+ "verticalContentAlignment": "Center"
+ },
+ {
+ "type": "Column",
+ "id": "4e744aaf-d2a8-82fb-7ba0-bf723931e694",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "8313c930-1dd7-c6ff-54be-bf7dbab9287e",
+ "text": "0",
+ "wrap": true
+ }
+ ],
+ "spacing": "Small",
+ "verticalContentAlignment": "Center"
+ }
+ ],
+ "padding": "Small",
+ "spacing": "None",
+ "style": "default"
+ }
+ ],
+ "spacing": "None"
+ },
+ {
+ "type": "Container",
+ "id": "5022d3c1-e639-ad9f-674a-5d6301b77b00",
+ "padding": "None",
+ "items": [
+ {
+ "type": "ColumnSet",
+ "id": "799b5797-0d65-4c43-f858-f44ef798291a",
+ "columns": [
+ {
+ "type": "Column",
+ "id": "7a34a918-cbf3-2b55-c367-00f9ce19cc89",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "ColumnSet",
+ "columns": [
+ {
+ "type": "Column",
+ "id": "8504e303-b96b-c95e-7711-5ee92d3d0c64",
+ "padding": "None",
+ "width": "auto",
+ "items": [
+ {
+ "type": "Image",
+ "id": "42c1a511-43f8-3e9e-ec82-9d681f76f900",
+ "url": "https://avatars.githubusercontent.com/u/8495952?v=4",
+ "altText": "manuelmhtr",
+ "size": "Small",
+ "horizontalAlignment": "Left",
+ "width": "32px",
+ "spacing": "ExtraLarge",
+ "style": "Person",
+ "height": "32px"
+ }
+ ]
+ },
+ {
+ "type": "Column",
+ "id": "a20d311d-31d3-82ec-0590-8efc570156fa",
+ "padding": "Small",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "599dac04-f76b-292e-3d60-edb266cd1bda",
+ "text": "Phaze1D",
+ "wrap": true,
+ "horizontalAlignment": "Left",
+ "spacing": "Small"
+ }
+ ],
+ "verticalContentAlignment": "Center",
+ "spacing": "Small"
+ }
+ ],
+ "padding": "None"
+ }
+ ],
+ "spacing": "Small",
+ "separator": true
+ },
+ {
+ "type": "Column",
+ "id": "6dfcacbe-29aa-1200-d61c-695cce75b10d",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "18395a85-3298-8182-9be7-a5f643e9c946",
+ "text": "[34m](link.com)",
+ "wrap": true
+ }
+ ],
+ "spacing": "Small",
+ "verticalContentAlignment": "Center"
+ },
+ {
+ "type": "Column",
+ "id": "41ec654d-2952-a71b-7fa8-ad07dad92bf9",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "358e8e56-3a58-2cbe-df31-95db82e9ac51",
+ "text": "4",
+ "wrap": true
+ }
+ ],
+ "spacing": "Small",
+ "verticalContentAlignment": "Center"
+ },
+ {
+ "type": "Column",
+ "id": "adde4fc4-3612-c9af-9f4d-8ec9d9594493",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "23e450c7-87bf-c631-4f8c-401121cde7b9",
+ "text": "1",
+ "wrap": true
+ }
+ ],
+ "spacing": "Small",
+ "verticalContentAlignment": "Center"
+ }
+ ],
+ "padding": "Small",
+ "spacing": "None",
+ "style": "default"
+ }
+ ],
+ "spacing": "None"
+ },
+ {
+ "type": "Container",
+ "id": "eab3841c-402c-fd21-687d-bd90c8ca541c",
+ "padding": "None",
+ "items": [
+ {
+ "type": "ColumnSet",
+ "id": "6d5f1dd5-aeae-1a89-cd86-ad7f879edab0",
+ "columns": [
+ {
+ "type": "Column",
+ "id": "d8be2b4f-8e50-1670-d901-2e87ddd2f8f6",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "ColumnSet",
+ "columns": [
+ {
+ "type": "Column",
+ "id": "746512f5-7bc5-1850-ed0c-517f9ea30bce",
+ "padding": "None",
+ "width": "auto",
+ "items": [
+ {
+ "type": "Image",
+ "id": "ac8d8b76-1ee3-571f-e522-aa5e03774b96",
+ "url": "https://avatars.githubusercontent.com/u/1031639?v=4",
+ "altText": "manuelmhtr",
+ "size": "Small",
+ "horizontalAlignment": "Left",
+ "width": "32px",
+ "spacing": "ExtraLarge",
+ "style": "Person",
+ "height": "32px"
+ }
+ ]
+ },
+ {
+ "type": "Column",
+ "id": "1ffc4353-720c-d14d-2c77-4afc9a63b397",
+ "padding": "Small",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "d2284cb9-859a-0a4f-6e92-261105cce4af",
+ "text": "manuelmhtr",
+ "wrap": true,
+ "horizontalAlignment": "Left",
+ "spacing": "Small"
+ }
+ ],
+ "verticalContentAlignment": "Center",
+ "spacing": "Small"
+ }
+ ],
+ "padding": "None"
+ }
+ ],
+ "spacing": "Small",
+ "separator": true
+ },
+ {
+ "type": "Column",
+ "id": "c6134a8f-8c71-8d51-7809-8b401141f3be",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "98c88f5f-0352-e37a-dad3-c4819fe828a1",
+ "text": "[48m](link.com)",
+ "wrap": true
+ }
+ ],
+ "spacing": "Small",
+ "verticalContentAlignment": "Center"
+ },
+ {
+ "type": "Column",
+ "id": "56b3d94f-def6-206d-3ec4-11c86626bbb7",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "66d7c7f4-f7ed-5858-a751-cb7498998f7b",
+ "text": "35",
+ "wrap": true
+ }
+ ],
+ "spacing": "Small",
+ "verticalContentAlignment": "Center"
+ },
+ {
+ "type": "Column",
+ "id": "44aa64cf-f35b-a75c-ef95-dc5cf71fe603",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "2469c719-00ee-4bbe-457b-c39344c97462",
+ "text": "96",
+ "wrap": true
+ }
+ ],
+ "spacing": "Small",
+ "verticalContentAlignment": "Center"
+ }
+ ],
+ "padding": "Small",
+ "spacing": "None",
+ "style": "default"
+ }
+ ],
+ "spacing": "None"
+ },
+ {
+ "type": "Container",
+ "id": "84c88128-0057-5a88-7de5-345aaa78b177",
+ "padding": "None",
+ "items": [
+ {
+ "type": "ColumnSet",
+ "id": "d11a0337-ea08-e6a4-42d6-8799d2877577",
+ "columns": [
+ {
+ "type": "Column",
+ "id": "f85bc19d-d4a6-d29b-4f74-1158c4ac62c2",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "ColumnSet",
+ "columns": [
+ {
+ "type": "Column",
+ "id": "a087693a-e851-9c10-c198-5059a1f8ca57",
+ "padding": "None",
+ "width": "auto",
+ "items": [
+ {
+ "type": "Image",
+ "id": "ac29b435-5e8a-1f0f-8dd1-7eea9f5a8948",
+ "url": "https://avatars.githubusercontent.com/u/33379285?v=4",
+ "altText": "manuelmhtr",
+ "size": "Small",
+ "horizontalAlignment": "Left",
+ "width": "32px",
+ "spacing": "ExtraLarge",
+ "style": "Person",
+ "height": "32px"
+ }
+ ]
+ },
+ {
+ "type": "Column",
+ "id": "93a0d50e-03b0-165f-c92e-635854a5c4d6",
+ "padding": "Small",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "eef8f4a2-7597-679c-f317-09091a77bfc6",
+ "text": "ernestognw",
+ "wrap": true,
+ "horizontalAlignment": "Left",
+ "spacing": "Small"
+ }
+ ],
+ "verticalContentAlignment": "Center",
+ "spacing": "Small"
+ }
+ ],
+ "padding": "None"
+ }
+ ],
+ "spacing": "Small",
+ "separator": true
+ },
+ {
+ "type": "Column",
+ "id": "220752f1-0f10-a9e7-92ce-99f0cb938b26",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "fa605740-956b-da57-547a-1cf435cc0953",
+ "text": "[1h 27m](link.com)",
+ "wrap": true
+ }
+ ],
+ "spacing": "Small",
+ "verticalContentAlignment": "Center"
+ },
+ {
+ "type": "Column",
+ "id": "39c62e4e-3a78-fcf4-ce3f-e33bd0dd9d2b",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "94d720e5-ab91-312d-aeaf-32223456ed3d",
+ "text": "25",
+ "wrap": true
+ }
+ ],
+ "spacing": "Small",
+ "verticalContentAlignment": "Center"
+ },
+ {
+ "type": "Column",
+ "id": "3d37e3c6-5df7-970f-a5ef-c27525dc4172",
+ "padding": "None",
+ "width": "stretch",
+ "items": [
+ {
+ "type": "TextBlock",
+ "id": "f304c4e2-1f6a-c486-f836-b79a40938292",
+ "text": "63",
+ "wrap": true
+ }
+ ],
+ "spacing": "Small",
+ "verticalContentAlignment": "Center"
+ }
+ ],
+ "padding": "Small",
+ "spacing": "None",
+ "style": "default"
+ }
+ ],
+ "spacing": "None"
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/docs/slack.md b/docs/slack.md
new file mode 100644
index 0000000..ba7cf16
--- /dev/null
+++ b/docs/slack.md
@@ -0,0 +1,34 @@
+# Posting to Slack
+
+> š This integration is available to sponsors.
+
+This action can post the results to a channel in Slack. For example:
+
+![](/assets/slack.png)
+
+To configure the Slack integration:
+
+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.
+2. Set the `slack-webhook` (from the previous step) and `slack-channel` (don't forget to include the `#` character) parameters in this action.
+3. Ready to go!
+
+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:
+
+```yml
+name: Pull Request Stats
+
+on:
+ schedule:
+ - cron: '0 9 * * 1'
+
+jobs:
+ stats:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Run pull request stats
+ uses: flowwer-dev/pull-request-stats@master
+ with:
+ slack-channel: '#mystatschannel'
+ slack-webhook: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
+ # slack-webhook: ${{ secrets.SLACK_WEBHOOK }} You may want to store this value as a secret.
+```
diff --git a/docs/teams.md b/docs/teams.md
new file mode 100644
index 0000000..a2f7b14
--- /dev/null
+++ b/docs/teams.md
@@ -0,0 +1,33 @@
+# Posting to Microsoft Teams
+
+> š This integration is available to sponsors.
+
+This action can post the results to a channel in Teams. For example:
+
+![](/assets/teams.png)
+
+To configure the Teams integration:
+
+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.
+2. Set the `teams-webhook` (from the previous step) parameter in this action.
+3. Ready to go!
+
+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:
+
+```yml
+name: Pull Request Stats
+
+on:
+ schedule:
+ - cron: '0 9 * * 1'
+
+jobs:
+ stats:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Run pull request stats
+ uses: flowwer-dev/pull-request-stats@master
+ with:
+ teams-webhook: 'https://abcXXX.webhook.office.com/webhookb2/...'
+ # teams-webhook: ${{ secrets.TEAMS_WEBHOOK }} You may want to store this value as a secret.
+```
diff --git a/docs/webhook.md b/docs/webhook.md
index 2aa4d79..49820c2 100644
--- a/docs/webhook.md
+++ b/docs/webhook.md
@@ -1,6 +1,8 @@
-# Posting stats to a webhook
+# Posting stats to a Webhook
-This action can also send the results to a webhook of your preference. This way you can send them to [Zapier](https://zapier.com/), [IFTTT](https://ifttt.com/), [Automate.io](https://automate.io/) and more, to take actions based on the results.
+> š„ This integration does **not** require a sponsorship. Enjoy!
+
+This action can also send the results to a webhook of your preference. This way, you can send them to [Zapier](https://zapier.com/), [IFTTT](https://ifttt.com/), [Automate.io](https://automate.io/) and more, to take actions based on the results.
Just send a URL in the `webhook` parameter. For example:
@@ -23,7 +25,7 @@ jobs:
# webhook: ${{ secrets.WEBHOOK_URL }} You may want to store this value as a secret.
```
-This action will calculate the pull request reviewers stats for the repos `piedpiper/repo1` and `piedpiper/repo2`, each Friday at 14pm, and send the results to the webhook using a `POST` request.
+This action will calculate the pull request reviewers' stats for the repos `piedpiper/repo1` and `piedpiper/repo2`, each Friday at 14pm and send the results to the webhook using a `POST` request.
## Webhook content
@@ -104,6 +106,6 @@ The webhook payload will include:
## What's next?
-I'm building other integrations for this actions, [I'd love to hear](https://github.com/flowwer-dev/pull-request-stats/discussions/new) which integration you want and how are you planning to use webhooks.
+I'm building other integrations for this action, [I'd love to hear](https://github.com/flowwer-dev/pull-request-stats/discussions/new) which integration you want and how you are planning to use webhooks.
Support this project by becoming a [sponsor](https://github.com/sponsors/manuelmhtr) š
diff --git a/package.json b/package.json
index 9087ba7..9caf954 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,11 @@
{
"name": "pull-request-stats",
- "version": "2.5.1",
+ "version": "2.6.0",
"description": "Github action to print relevant stats about Pull Request reviewers",
"main": "dist/index.js",
"scripts": {
- "build": "ncc build src/index.js",
- "test": "eslint src && yarn run build && jest"
+ "build": "eslint src && ncc build src/index.js",
+ "test": "jest"
},
"keywords": [],
"author": "Manuel de la Torre",
diff --git a/src/config/index.js b/src/config/index.js
index 4475e09..f220e78 100644
--- a/src/config/index.js
+++ b/src/config/index.js
@@ -1,5 +1,7 @@
const getSlackCharsLimit = () => 39000;
+const getTeamsBytesLimit = () => 27000;
module.exports = {
getSlackCharsLimit,
+ getTeamsBytesLimit,
};
diff --git a/src/execute.js b/src/execute.js
index b45f9bd..385567c 100644
--- a/src/execute.js
+++ b/src/execute.js
@@ -13,6 +13,7 @@ const {
checkSponsorship,
alreadyPublished,
postSlackMessage,
+ postTeamsMessage,
postWebhook,
} = require('./interactors');
@@ -66,13 +67,10 @@ const run = async (params) => {
});
core.debug(`Commit content built successfully: ${content}`);
- await postWebhook({ ...params, core, reviewers });
- await postSlackMessage({
- ...params,
- core,
- reviewers,
- pullRequest,
- });
+ const whParams = { ...params, core, reviewers };
+ await postWebhook(whParams);
+ await postSlackMessage({ ...whParams, pullRequest });
+ await postTeamsMessage({ ...whParams, pullRequest });
if (!pullRequestId) return;
await postComment({
diff --git a/src/i18n/locales/en-US/execution.json b/src/i18n/locales/en-US/execution.json
index e555835..f4f0d67 100644
--- a/src/i18n/locales/en-US/execution.json
+++ b/src/i18n/locales/en-US/execution.json
@@ -1,7 +1,7 @@
{
"logs": {
"success": "Action successfully executed",
- "news": "\nāØ New on v2.5:\nā¢ Slack integration\nā¢ Webhooks integration"
+ "news": "\nāØ New on v2.6:\nā¢ Microsoft Teams integration\nā¢ Slack integration\nā¢ Webhooks integration"
},
"errors": {
"main": "Execution failed with error: {{message}}"
diff --git a/src/i18n/locales/en-US/integrations.json b/src/i18n/locales/en-US/integrations.json
index 17726c3..22a63fd 100644
--- a/src/i18n/locales/en-US/integrations.json
+++ b/src/i18n/locales/en-US/integrations.json
@@ -6,10 +6,21 @@
"success": "Successfully posted to slack"
},
"errors": {
- "notSponsor": "Slack integration is a premium feature, available to sponsors.\n(If you are already an sponsor, please make sure it configured as public).",
+ "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).",
"requestFailed": "Error posting Slack message: {{error}}"
}
},
+ "teams": {
+ "logs": {
+ "notConfigured": "Microsoft Teams integration is disabled. No webhook configured.",
+ "posting": "Post a MS Teams message with params: {{params}}",
+ "success": "Successfully posted to MS Teams"
+ },
+ "errors": {
+ "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).",
+ "requestFailed": "Error posting MS Teams message: {{error}}"
+ }
+ },
"webhook": {
"logs": {
"notConfigured": "Webhook integration is disabled.",
diff --git a/src/index.js b/src/index.js
index 80ecfa5..bc42eda 100644
--- a/src/index.js
+++ b/src/index.js
@@ -43,6 +43,9 @@ const getParams = () => {
webhook: core.getInput('slack-webhook'),
channel: core.getInput('slack-channel'),
},
+ teams: {
+ webhook: core.getInput('teams-webhook'),
+ },
};
};
diff --git a/src/interactors/index.js b/src/interactors/index.js
index 6fe78c6..5a8f3ba 100644
--- a/src/interactors/index.js
+++ b/src/interactors/index.js
@@ -5,9 +5,10 @@ const checkSponsorship = require('./checkSponsorship');
const getPulls = require('./getPulls');
const getReviewers = require('./getReviewers');
const postComment = require('./postComment');
+const setUpReviewers = require('./setUpReviewers');
const postSlackMessage = require('./postSlackMessage');
+const postTeamsMessage = require('./postTeamsMessage');
const postWebhook = require('./postWebhook');
-const setUpReviewers = require('./setUpReviewers');
module.exports = {
alreadyPublished,
@@ -17,7 +18,8 @@ module.exports = {
getPulls,
getReviewers,
postComment,
+ setUpReviewers,
postSlackMessage,
+ postTeamsMessage,
postWebhook,
- setUpReviewers,
};
diff --git a/src/interactors/postSlackMessage/__tests__/index.test.js b/src/interactors/postSlackMessage/__tests__/index.test.js
index c2ec308..159af9c 100644
--- a/src/interactors/postSlackMessage/__tests__/index.test.js
+++ b/src/interactors/postSlackMessage/__tests__/index.test.js
@@ -1,14 +1,16 @@
const Fetchers = require('../../../fetchers');
const { t } = require('../../../i18n');
-const buildSlackMessage = require('../buildSlackMessage');
-const splitInChunks = require('../splitInChunks');
+const buildMessage = require('../buildMessage');
const postSlackMessage = require('../index');
-const MESSAGE = { blocks: ['MESSAGE'] };
+const MESSAGE = {
+ blocks: [
+ { type: 'section', text: 'MESSAGE' },
+ ],
+};
jest.mock('../../../fetchers', () => ({ postToSlack: jest.fn(() => Promise.resolve()) }));
-jest.mock('../buildSlackMessage', () => jest.fn(() => MESSAGE));
-jest.mock('../splitInChunks', () => jest.fn((message) => [message]));
+jest.mock('../buildMessage', () => jest.fn());
describe('Interactors | .postSlackMessage', () => {
const debug = jest.fn();
@@ -33,17 +35,20 @@ describe('Interactors | .postSlackMessage', () => {
},
};
+ const mockBuildMessage = (msg) => buildMessage.mockReturnValue(msg);
+
beforeEach(() => {
debug.mockClear();
error.mockClear();
- buildSlackMessage.mockClear();
+ buildMessage.mockClear();
Fetchers.postToSlack.mockClear();
+ mockBuildMessage(MESSAGE);
});
describe('when integration is not configured', () => {
const expectDisabledIntegration = () => {
expect(debug).toHaveBeenCalled();
- expect(buildSlackMessage).not.toHaveBeenCalled();
+ expect(buildMessage).not.toHaveBeenCalled();
expect(Fetchers.postToSlack).not.toHaveBeenCalled();
};
@@ -64,7 +69,7 @@ describe('Interactors | .postSlackMessage', () => {
it('logs a error', async () => {
await postSlackMessage({ ...defaultOptions, isSponsor: false });
expect(error).toHaveBeenCalled();
- expect(buildSlackMessage).not.toHaveBeenCalled();
+ expect(buildMessage).not.toHaveBeenCalled();
expect(Fetchers.postToSlack).not.toHaveBeenCalled();
});
});
@@ -73,7 +78,7 @@ describe('Interactors | .postSlackMessage', () => {
it('posts successfully to Slack', async () => {
await postSlackMessage({ ...defaultOptions });
expect(error).not.toHaveBeenCalled();
- expect(buildSlackMessage).toBeCalledWith({
+ expect(buildMessage).toBeCalledWith({
reviewers: defaultOptions.reviewers,
pullRequest: defaultOptions.pullRequest,
periodLength: defaultOptions.periodLength,
@@ -91,11 +96,27 @@ describe('Interactors | .postSlackMessage', () => {
});
it('posts multiple times with divided in chunks', async () => {
- splitInChunks.mockImplementationOnce((message) => [message, message, message]);
+ const charsLimit = 40_000;
+ const block1 = { type: 'section', text: '1'.repeat(charsLimit) };
+ const block2 = { type: 'section', text: '2'.repeat(charsLimit) };
+ const block3 = { type: 'section', text: '3'.repeat(charsLimit) };
+ mockBuildMessage({
+ blocks: [block1, block2, block3],
+ });
+
await postSlackMessage({ ...defaultOptions });
expect(error).not.toHaveBeenCalled();
- expect(buildSlackMessage).toBeCalledTimes(1);
+ expect(buildMessage).toBeCalledTimes(1);
expect(Fetchers.postToSlack).toBeCalledTimes(3);
+ expect(Fetchers.postToSlack).toHaveBeenNthCalledWith(1, expect.objectContaining({
+ message: { blocks: [block1] },
+ }));
+ expect(Fetchers.postToSlack).toHaveBeenNthCalledWith(2, expect.objectContaining({
+ message: { blocks: [block2] },
+ }));
+ expect(Fetchers.postToSlack).toHaveBeenNthCalledWith(3, expect.objectContaining({
+ message: { blocks: [block3] },
+ }));
});
});
});
diff --git a/src/interactors/postSlackMessage/__tests__/splitInChunks.test.js b/src/interactors/postSlackMessage/__tests__/splitInChunks.test.js
deleted file mode 100644
index 050407b..0000000
--- a/src/interactors/postSlackMessage/__tests__/splitInChunks.test.js
+++ /dev/null
@@ -1,48 +0,0 @@
-const splitInChunks = require('../splitInChunks');
-
-const buildBlock = (str, length) => `${str}`.padEnd(length, '.');
-
-jest.mock('../../../config', () => ({
- getSlackCharsLimit: () => 100,
-}));
-
-describe('Interactors | .postSlackMessage | .splitInChunks', () => {
- it('wraps block in array when length is ok', () => {
- const block1 = buildBlock('BLOCK 1', 10);
- const message = {
- blocks: [block1],
- };
- const expectedChunks = [message];
- const result = splitInChunks(message);
- expect(result).toEqual(expectedChunks);
- });
-
- it('divides block in chunks when above length, keeping order', () => {
- const block1 = buildBlock('BLOCK 1', 15);
- const block2 = buildBlock('BLOCK 2', 15);
- const block3 = buildBlock('BLOCK 3', 120);
- const block4 = buildBlock('BLOCK 4', 60);
- const block5 = buildBlock('BLOCK 5', 50);
- const block6 = buildBlock('BLOCK 6', 10);
- const block7 = buildBlock('BLOCK 7', 10);
- const message = {
- blocks: [
- block1,
- block2,
- block3,
- block4,
- block5,
- block6,
- block7,
- ],
- };
- const expectedChunks = [
- { blocks: [block1, block2] },
- { blocks: [block3] },
- { blocks: [block4] },
- { blocks: [block5, block6, block7] },
- ];
- const result = splitInChunks(message);
- expect(result).toEqual(expectedChunks);
- });
-});
diff --git a/src/interactors/postSlackMessage/buildSlackMessage/__tests__/buildReviewer.test.js b/src/interactors/postSlackMessage/buildMessage/__tests__/buildReviewer.test.js
similarity index 100%
rename from src/interactors/postSlackMessage/buildSlackMessage/__tests__/buildReviewer.test.js
rename to src/interactors/postSlackMessage/buildMessage/__tests__/buildReviewer.test.js
diff --git a/src/interactors/postSlackMessage/buildSlackMessage/__tests__/buildSubtitle.test.js b/src/interactors/postSlackMessage/buildMessage/__tests__/buildSubtitle.test.js
similarity index 100%
rename from src/interactors/postSlackMessage/buildSlackMessage/__tests__/buildSubtitle.test.js
rename to src/interactors/postSlackMessage/buildMessage/__tests__/buildSubtitle.test.js
diff --git a/src/interactors/postSlackMessage/buildSlackMessage/__tests__/index.test.js b/src/interactors/postSlackMessage/buildMessage/__tests__/index.test.js
similarity index 83%
rename from src/interactors/postSlackMessage/buildSlackMessage/__tests__/index.test.js
rename to src/interactors/postSlackMessage/buildMessage/__tests__/index.test.js
index 35c87fb..114626e 100644
--- a/src/interactors/postSlackMessage/buildSlackMessage/__tests__/index.test.js
+++ b/src/interactors/postSlackMessage/buildMessage/__tests__/index.test.js
@@ -1,4 +1,4 @@
-const buildSlackMessage = require('../index');
+const buildMessage = require('../index');
const buildSubtitle = require('../buildSubtitle');
const buildReviewer = require('../buildReviewer');
@@ -16,14 +16,14 @@ const defaultOptions = {
displayCharts: 'DISPLAY CHARTS',
};
-describe('Interactors | postSlackMessage | .buildSlackMessage', () => {
+describe('Interactors | postSlackMessage | .buildMessage', () => {
beforeEach(() => {
buildSubtitle.mockClear();
buildReviewer.mockClear();
});
it('returns the expected structure', () => {
- const response = buildSlackMessage({ ...defaultOptions });
+ const response = buildMessage({ ...defaultOptions });
expect(response).toEqual({
blocks: [
SUBTITLE,
@@ -33,7 +33,7 @@ describe('Interactors | postSlackMessage | .buildSlackMessage', () => {
});
it('calls builders with the correct parameters', () => {
- buildSlackMessage({ ...defaultOptions });
+ buildMessage({ ...defaultOptions });
expect(buildSubtitle).toHaveBeenCalledWith({
t: expect.anything(),
pullRequest: defaultOptions.pullRequest,
@@ -50,7 +50,7 @@ describe('Interactors | postSlackMessage | .buildSlackMessage', () => {
it('builds a reviewers per each passed', () => {
const reviewers = ['REVIEWER 1', 'REVIEWER 2', 'REVIEWER 3'];
- buildSlackMessage({ ...defaultOptions, reviewers });
+ buildMessage({ ...defaultOptions, reviewers });
expect(buildReviewer).toHaveBeenCalledTimes(reviewers.length);
});
});
diff --git a/src/interactors/postSlackMessage/buildSlackMessage/buildReviewer.js b/src/interactors/postSlackMessage/buildMessage/buildReviewer.js
similarity index 100%
rename from src/interactors/postSlackMessage/buildSlackMessage/buildReviewer.js
rename to src/interactors/postSlackMessage/buildMessage/buildReviewer.js
diff --git a/src/interactors/postSlackMessage/buildSlackMessage/buildSubtitle.js b/src/interactors/postSlackMessage/buildMessage/buildSubtitle.js
similarity index 100%
rename from src/interactors/postSlackMessage/buildSlackMessage/buildSubtitle.js
rename to src/interactors/postSlackMessage/buildMessage/buildSubtitle.js
diff --git a/src/interactors/postSlackMessage/buildSlackMessage/index.js b/src/interactors/postSlackMessage/buildMessage/index.js
similarity index 100%
rename from src/interactors/postSlackMessage/buildSlackMessage/index.js
rename to src/interactors/postSlackMessage/buildMessage/index.js
diff --git a/src/interactors/postSlackMessage/index.js b/src/interactors/postSlackMessage/index.js
index 5a88965..55a21ff 100644
--- a/src/interactors/postSlackMessage/index.js
+++ b/src/interactors/postSlackMessage/index.js
@@ -1,7 +1,7 @@
const { t } = require('../../i18n');
const { postToSlack } = require('../../fetchers');
-const buildSlackMessage = require('./buildSlackMessage');
-const splitInChunks = require('./splitInChunks');
+const { SlackSplitter } = require('../../services/splitter');
+const buildMessage = require('./buildMessage');
module.exports = async ({
org,
@@ -41,7 +41,7 @@ module.exports = async ({
return postToSlack(params);
};
- const fullMessage = buildSlackMessage({
+ const fullMessage = buildMessage({
org,
repos,
reviewers,
@@ -51,7 +51,7 @@ module.exports = async ({
displayCharts,
});
- const chunks = splitInChunks(fullMessage);
+ const { chunks } = new SlackSplitter({ message: fullMessage });
await chunks.reduce(async (promise, message) => {
await promise;
return send(message).catch((error) => {
diff --git a/src/interactors/postSlackMessage/splitInChunks.js b/src/interactors/postSlackMessage/splitInChunks.js
deleted file mode 100644
index cddfa2f..0000000
--- a/src/interactors/postSlackMessage/splitInChunks.js
+++ /dev/null
@@ -1,37 +0,0 @@
-const { getSlackCharsLimit } = require('../../config');
-const { median } = require('../../utils');
-
-const CHARS_LIMIT = getSlackCharsLimit();
-
-const getSize = (obj) => JSON.stringify(obj).length;
-
-const getBlockLengths = (blocks) => blocks
- .filter(({ type }) => type === 'section') // Ignoring "divider" blocks
- .map((block) => getSize(block));
-
-const getSizePerBlock = (blocks) => Math.round(median(getBlockLengths(blocks)));
-
-module.exports = (message) => {
- const blockSize = Math.max(1, getSizePerBlock(message.blocks));
-
- const getBlocksToSplit = (blocks) => {
- const currentSize = getSize({ blocks });
- const diff = currentSize - CHARS_LIMIT;
- if (diff < 0 || blocks.length === 1) return 0;
-
- const blocksSpace = Math.ceil(diff / blockSize);
- const blocksCount = Math.max(1, Math.min(blocks.length - 1, blocksSpace));
- const firsts = blocks.slice(0, blocksCount);
- return getBlocksToSplit(firsts) || blocksCount;
- };
-
- const getChunks = (prev, msg) => {
- const blocksToSplit = getBlocksToSplit(msg.blocks);
- if (!blocksToSplit) return [...prev, msg];
- const blocks = msg.blocks.slice(0, blocksToSplit);
- const others = msg.blocks.slice(blocksToSplit);
- return getChunks([...prev, { blocks }], { blocks: others });
- };
-
- return getChunks([], message);
-};
diff --git a/src/interactors/postTeamsMessage/__tests__/buildPayload.test.js b/src/interactors/postTeamsMessage/__tests__/buildPayload.test.js
new file mode 100644
index 0000000..dd96d81
--- /dev/null
+++ b/src/interactors/postTeamsMessage/__tests__/buildPayload.test.js
@@ -0,0 +1,13 @@
+const buildPayload = require('../buildPayload');
+
+describe('Interactors | .postTeamsMessage | .buildPayload', () => {
+ const body = 'BODY';
+
+ it('wraps the body into a required structure', () => {
+ const result = buildPayload(body);
+ expect(result.type).toEqual('message');
+
+ const wrappedBody = result?.attachments?.[0]?.content?.body;
+ expect(wrappedBody).toEqual(body);
+ });
+});
diff --git a/src/interactors/postTeamsMessage/__tests__/index.test.js b/src/interactors/postTeamsMessage/__tests__/index.test.js
new file mode 100644
index 0000000..7276e2e
--- /dev/null
+++ b/src/interactors/postTeamsMessage/__tests__/index.test.js
@@ -0,0 +1,119 @@
+const Fetchers = require('../../../fetchers');
+const buildMessage = require('../buildMessage');
+const buildPayload = require('../buildPayload');
+const postTeamsMessage = require('../index');
+
+const MESSAGE = {
+ blocks: [
+ { type: 'section', text: 'MESSAGE' },
+ ],
+};
+
+jest.mock('../../../fetchers', () => ({ postToWebhook: jest.fn(() => Promise.resolve()) }));
+jest.mock('../buildMessage', () => jest.fn());
+jest.mock('../buildPayload', () => jest.fn());
+
+describe('Interactors | .postTeamsMessage', () => {
+ const debug = jest.fn();
+ const error = jest.fn();
+
+ const core = {
+ debug,
+ error,
+ };
+
+ const defaultOptions = {
+ core,
+ isSponsor: true,
+ reviewers: 'REVIEWERS',
+ pullRequest: 'PULl REQUEST',
+ periodLength: 'PERIOD LENGTH',
+ disableLinks: 'DISPLAY LINKS',
+ displayCharts: 'DISPLAY CHARTS',
+ teams: {
+ webhook: 'https://microsoft.com/teams/webhook',
+ },
+ };
+
+ const mockBuildMessage = (msg) => buildMessage.mockReturnValue(msg);
+
+ const mockBuildPayload = () => buildPayload.mockImplementation((body) => ({ body }));
+
+ beforeEach(() => {
+ debug.mockClear();
+ error.mockClear();
+ buildMessage.mockClear();
+ buildPayload.mockClear();
+ Fetchers.postToWebhook.mockClear();
+ mockBuildMessage(MESSAGE);
+ mockBuildPayload();
+ });
+
+ describe('when integration is not configured', () => {
+ const expectDisabledIntegration = () => {
+ expect(debug).toHaveBeenCalled();
+ expect(buildMessage).not.toHaveBeenCalled();
+ expect(Fetchers.postToWebhook).not.toHaveBeenCalled();
+ };
+
+ it('logs a message when webhook is not passed', async () => {
+ const teams = { ...defaultOptions.teams, webhook: null };
+ await postTeamsMessage({ ...defaultOptions, teams });
+ expectDisabledIntegration();
+ });
+ });
+
+ describe('when user is not a sponsor', () => {
+ it('logs a error', async () => {
+ await postTeamsMessage({ ...defaultOptions, isSponsor: false });
+ expect(error).toHaveBeenCalled();
+ expect(buildMessage).not.toHaveBeenCalled();
+ expect(Fetchers.postToWebhook).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when integration is enabled', () => {
+ it('posts successfully to Teams', async () => {
+ await postTeamsMessage({ ...defaultOptions });
+ expect(error).not.toHaveBeenCalled();
+ expect(buildMessage).toBeCalledWith({
+ reviewers: defaultOptions.reviewers,
+ pullRequest: defaultOptions.pullRequest,
+ periodLength: defaultOptions.periodLength,
+ disableLinks: defaultOptions.disableLinks,
+ displayCharts: defaultOptions.displayCharts,
+ });
+ expect(buildPayload).toBeCalledWith(MESSAGE);
+ expect(Fetchers.postToWebhook).toBeCalledTimes(1);
+ expect(Fetchers.postToWebhook).toBeCalledWith({
+ webhook: defaultOptions.teams.webhook,
+ payload: {
+ body: MESSAGE,
+ },
+ });
+ });
+
+ it('posts multiple times with divided in chunks', async () => {
+ const charsLimit = 40_000;
+ const block1 = { type: 'ColumnSet', text: '1'.repeat(charsLimit) };
+ const block2 = { type: 'ColumnSet', text: '2'.repeat(charsLimit) };
+ const block3 = { type: 'ColumnSet', text: '3'.repeat(charsLimit) };
+ mockBuildMessage([block1, block2, block3]);
+
+ await postTeamsMessage({ ...defaultOptions });
+ expect(error).not.toHaveBeenCalled();
+ expect(buildMessage).toBeCalledTimes(1);
+ expect(buildPayload).toBeCalledTimes(3);
+ expect(Fetchers.postToWebhook).toBeCalledTimes(3);
+ expect(Fetchers.postToWebhook).toHaveBeenNthCalledWith(1, expect.objectContaining({
+ payload: { body: [block1] },
+ }));
+ expect(Fetchers.postToWebhook).toHaveBeenNthCalledWith(2, expect.objectContaining({
+ payload: { body: [block2] },
+ }));
+ expect(Fetchers.postToWebhook).toHaveBeenNthCalledWith(3, expect.objectContaining({
+ payload: { body: [block3] },
+ }));
+ });
+ });
+});
diff --git a/src/interactors/postTeamsMessage/buildMessage/__tests__/buildHeaders.test.js b/src/interactors/postTeamsMessage/buildMessage/__tests__/buildHeaders.test.js
new file mode 100644
index 0000000..75bca42
--- /dev/null
+++ b/src/interactors/postTeamsMessage/buildMessage/__tests__/buildHeaders.test.js
@@ -0,0 +1,32 @@
+const { t } = require('../../../../i18n');
+const buildHeaders = require('../buildHeaders');
+
+const EXPECTED_HEADERS = [
+ t('table.columns.username'),
+ t('table.columns.timeToReview'),
+ t('table.columns.totalReviews'),
+ t('table.columns.totalComments'),
+];
+
+describe('Interactors | .postTeamsMessage | .buildHeaders', () => {
+ const result = buildHeaders({ t });
+ const texts = (result?.columns || []).map((column) => column?.items?.[0]?.text);
+
+ it('includes headers structure', () => {
+ expect(result).toEqual(
+ expect.objectContaining({
+ type: 'ColumnSet',
+ padding: 'Small',
+ horizontalAlignment: 'Left',
+ style: 'emphasis',
+ spacing: 'Small',
+ }),
+ );
+ });
+
+ EXPECTED_HEADERS.forEach((expectedHeader) => {
+ it(`includes the header "${expectedHeader}"`, () => {
+ expect(texts).toContain(expectedHeader);
+ });
+ });
+});
diff --git a/src/interactors/postTeamsMessage/buildMessage/__tests__/buildReviewer.test.js b/src/interactors/postTeamsMessage/buildMessage/__tests__/buildReviewer.test.js
new file mode 100644
index 0000000..56b1bfe
--- /dev/null
+++ b/src/interactors/postTeamsMessage/buildMessage/__tests__/buildReviewer.test.js
@@ -0,0 +1,68 @@
+const buildReviewer = require('../buildReviewer');
+const reviewers = require('../../../__tests__/mocks/populatedReviewers.json');
+
+const [reviewer] = reviewers;
+const defaultParams = {
+ reviewer,
+ index: 0,
+ disableLinks: true,
+ displayCharts: false,
+};
+
+const extractData = (response) => {
+ const [usernameCol, ...statsCols] = response?.columns;
+ const [imageCol, nameCol] = usernameCol?.items?.[0].columns;
+ const stats = statsCols.map((col) => col?.items?.[0].text);
+
+ return {
+ avatarUrl: imageCol?.items?.[0]?.url,
+ login: nameCol?.items?.[0]?.text,
+ stats: {
+ timeToReview: stats[0],
+ totalReviews: stats[1],
+ totalComments: stats[2],
+ },
+ };
+};
+
+describe('Interactors | postTeamsMessage | .buildReviewer', () => {
+ const expectedContent = {
+ avatarUrl: 'https://avatars.githubusercontent.com/u/1234',
+ login: 'user1',
+ stats: {
+ timeToReview: '34m',
+ totalReviews: 4,
+ totalComments: 1,
+ },
+ };
+
+ describe('simplest case', () => {
+ it('builds a reviewers with basic config', () => {
+ const response = buildReviewer({ ...defaultParams });
+ expect(extractData(response)).toEqual(expectedContent);
+ });
+ });
+
+ describe('requiring charts', () => {
+ it('adds a medal to username section', () => {
+ const response = buildReviewer({ ...defaultParams, displayCharts: true });
+ expect(extractData(response)).toEqual({
+ ...expectedContent,
+ login: 'user1 š„',
+ });
+ });
+ });
+
+ describe('requiring links', () => {
+ it('adds a medal to username section', () => {
+ const response = buildReviewer({ ...defaultParams, disableLinks: false });
+ expect(extractData(response)).toEqual({
+ ...expectedContent,
+ stats: {
+ ...expectedContent.stats,
+ timeToReview: '[34m](https://app.flowwer.dev/charts/review-time/1)',
+ },
+ });
+ });
+ });
+});
diff --git a/src/interactors/postTeamsMessage/buildMessage/__tests__/buildSubtitle.test.js b/src/interactors/postTeamsMessage/buildMessage/__tests__/buildSubtitle.test.js
new file mode 100644
index 0000000..20bed35
--- /dev/null
+++ b/src/interactors/postTeamsMessage/buildMessage/__tests__/buildSubtitle.test.js
@@ -0,0 +1,67 @@
+const { t } = require('../../../../i18n');
+const { getRepoName } = require('../../../../utils');
+const buildSubtitle = require('../buildSubtitle');
+
+const ORG = 'org';
+const REPO1 = 'org/repo1';
+const REPO2 = 'org/repo2';
+
+const periodLength = 10;
+const pullRequest = {
+ number: 13,
+ url: 'https://github.com/manuelmhtr/pulls/13',
+};
+
+const linkOrg = (org) => `[${org}](https://github.com/${org})`;
+
+const linkRepo = (repo) => `[${getRepoName(repo)}](https://github.com/${repo})`;
+
+const wrapText = (text) => ({
+ type: 'Container',
+ padding: 'Small',
+ items: [
+ {
+ text,
+ type: 'TextBlock',
+ weight: 'Lighter',
+ wrap: true,
+ },
+ ],
+});
+
+describe('Interactors | postTeamsMessage | .buildSubtitle', () => {
+ const baseParams = {
+ t,
+ periodLength,
+ org: ORG,
+ };
+
+ describe('when sending a pull request', () => {
+ it('returns a subtitle with no pull request data', () => {
+ const response = buildSubtitle({ ...baseParams, pullRequest });
+ const prLink = `([#${pullRequest.number}](${pullRequest.url}))`;
+ const sources = linkOrg(ORG);
+ const text = `${t('table.subtitle', { sources, count: periodLength })} ${prLink}`;
+ expect(response).toEqual(wrapText(text));
+ });
+ });
+
+ describe('when not sending a pull request', () => {
+ it('returns a subtitle with no pull request data', () => {
+ const response = buildSubtitle({ ...baseParams, pullRequest: null });
+ const sources = linkOrg(ORG);
+ const text = `${t('table.subtitle', { sources, count: periodLength })}`;
+ expect(response).toEqual(wrapText(text));
+ });
+ });
+
+ describe('when sending multiple repos', () => {
+ it('returns a subtitle with no pull request data', () => {
+ const repos = [REPO1, REPO2];
+ const response = buildSubtitle({ ...baseParams, org: null, repos });
+ const sources = `${linkRepo(REPO1)} and ${linkRepo(REPO2)}`;
+ const text = `${t('table.subtitle', { sources, count: periodLength })}`;
+ expect(response).toEqual(wrapText(text));
+ });
+ });
+});
diff --git a/src/interactors/postTeamsMessage/buildMessage/__tests__/index.test.js b/src/interactors/postTeamsMessage/buildMessage/__tests__/index.test.js
new file mode 100644
index 0000000..7419dd2
--- /dev/null
+++ b/src/interactors/postTeamsMessage/buildMessage/__tests__/index.test.js
@@ -0,0 +1,61 @@
+const buildMessage = require('../index');
+const buildHeaders = require('../buildHeaders');
+const buildSubtitle = require('../buildSubtitle');
+const buildReviewer = require('../buildReviewer');
+
+const HEADERS = 'HEADERS';
+const SUBTITLE = 'SUBTITLE';
+const REVIEWER = 'REVIEWER';
+
+jest.mock('../buildHeaders', () => jest.fn(() => HEADERS));
+jest.mock('../buildSubtitle', () => jest.fn(() => SUBTITLE));
+jest.mock('../buildReviewer', () => jest.fn(() => REVIEWER));
+
+const defaultOptions = {
+ reviewers: ['REVIEWER 1'],
+ pullRequest: 'PULL REQUEST',
+ periodLength: 'PERIOD LENGTH',
+ disableLinks: 'DISABLE LINKS',
+ displayCharts: 'DISPLAY CHARTS',
+};
+
+describe('Interactors | postTeamsMessage | .buildMessage', () => {
+ beforeEach(() => {
+ buildHeaders.mockClear();
+ buildSubtitle.mockClear();
+ buildReviewer.mockClear();
+ });
+
+ it('returns the expected structure', () => {
+ const response = buildMessage({ ...defaultOptions });
+ expect(response).toEqual([
+ SUBTITLE,
+ HEADERS,
+ REVIEWER,
+ ]);
+ });
+
+ it('calls builders with the correct parameters', () => {
+ buildMessage({ ...defaultOptions });
+ expect(buildSubtitle).toHaveBeenCalledWith({
+ t: expect.anything(),
+ pullRequest: defaultOptions.pullRequest,
+ periodLength: defaultOptions.periodLength,
+ });
+ expect(buildHeaders).toHaveBeenCalledWith({
+ t: expect.anything(),
+ });
+ expect(buildReviewer).toHaveBeenCalledWith({
+ index: 0,
+ reviewer: defaultOptions.reviewers[0],
+ disableLinks: defaultOptions.disableLinks,
+ displayCharts: defaultOptions.displayCharts,
+ });
+ });
+
+ it('builds a reviewers per each passed', () => {
+ const reviewers = ['REVIEWER 1', 'REVIEWER 2', 'REVIEWER 3'];
+ buildMessage({ ...defaultOptions, reviewers });
+ expect(buildReviewer).toHaveBeenCalledTimes(reviewers.length);
+ });
+});
diff --git a/src/interactors/postTeamsMessage/buildMessage/buildHeaders.js b/src/interactors/postTeamsMessage/buildMessage/buildHeaders.js
new file mode 100644
index 0000000..0902b1c
--- /dev/null
+++ b/src/interactors/postTeamsMessage/buildMessage/buildHeaders.js
@@ -0,0 +1,28 @@
+const wrapHeader = (text) => ({
+ type: 'Column',
+ padding: 'None',
+ width: 'stretch',
+ verticalContentAlignment: 'Center',
+ items: [
+ {
+ text,
+ type: 'TextBlock',
+ wrap: true,
+ weight: 'Bolder',
+ },
+ ],
+});
+
+module.exports = ({ t }) => ({
+ type: 'ColumnSet',
+ padding: 'Small',
+ horizontalAlignment: 'Left',
+ style: 'emphasis',
+ spacing: 'Small',
+ columns: [
+ wrapHeader(t('table.columns.username')),
+ wrapHeader(t('table.columns.timeToReview')),
+ wrapHeader(t('table.columns.totalReviews')),
+ wrapHeader(t('table.columns.totalComments')),
+ ],
+});
diff --git a/src/interactors/postTeamsMessage/buildMessage/buildReviewer.js b/src/interactors/postTeamsMessage/buildMessage/buildReviewer.js
new file mode 100644
index 0000000..7b5381a
--- /dev/null
+++ b/src/interactors/postTeamsMessage/buildMessage/buildReviewer.js
@@ -0,0 +1,116 @@
+const { durationToString } = require('../../../utils');
+
+const MEDALS = [
+ 'š„',
+ 'š„',
+ 'š„',
+];
+
+const wrapUsername = ({
+ avatarUrl,
+ login,
+}) => ({
+ type: 'Column',
+ padding: 'None',
+ width: 'stretch',
+ spacing: 'Small',
+ separator: true,
+ items: [
+ {
+ type: 'ColumnSet',
+ padding: 'None',
+ columns: [
+ {
+ type: 'Column',
+ padding: 'None',
+ width: 'auto',
+ items: [
+ {
+ type: 'Image',
+ url: avatarUrl,
+ altText: login,
+ size: 'Small',
+ style: 'Person',
+ spacing: 'None',
+ horizontalAlignment: 'Left',
+ width: '32px',
+ height: '32px',
+ },
+ ],
+ },
+ {
+ type: 'Column',
+ padding: 'None',
+ width: 'stretch',
+ verticalContentAlignment: 'Center',
+ items: [
+ {
+ type: 'TextBlock',
+ text: login,
+ wrap: true,
+ horizontalAlignment: 'Left',
+ spacing: 'Small',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+});
+
+const wrapStat = (text) => ({
+ type: 'Column',
+ padding: 'None',
+ width: 'stretch',
+ spacing: 'Small',
+ verticalContentAlignment: 'Center',
+ items: [
+ {
+ text,
+ type: 'TextBlock',
+ wrap: true,
+ },
+ ],
+});
+
+const getUsername = ({ index, reviewer, displayCharts }) => {
+ const { login, avatarUrl } = reviewer.author;
+
+ const medal = displayCharts ? MEDALS[index] : null;
+ const suffix = medal ? ` ${medal}` : '';
+
+ return wrapUsername({
+ avatarUrl,
+ login: `${login}${suffix}`,
+ });
+};
+
+const getStats = ({ reviewer, disableLinks }) => {
+ const { stats, urls } = reviewer;
+ const timeToReviewStr = durationToString(stats.timeToReview);
+ const timeToReview = disableLinks
+ ? timeToReviewStr
+ : `[${timeToReviewStr}](${urls.timeToReview})`;
+
+ return [
+ wrapStat(timeToReview),
+ wrapStat(stats.totalReviews),
+ wrapStat(stats.totalComments),
+ ];
+};
+
+module.exports = ({
+ index,
+ reviewer,
+ disableLinks,
+ displayCharts,
+}) => ({
+ type: 'ColumnSet',
+ padding: 'Small',
+ spacing: 'None',
+ separator: true,
+ columns: [
+ getUsername({ index, reviewer, displayCharts }),
+ ...getStats({ reviewer, disableLinks }),
+ ],
+});
diff --git a/src/interactors/postTeamsMessage/buildMessage/buildSubtitle.js b/src/interactors/postTeamsMessage/buildMessage/buildSubtitle.js
new file mode 100644
index 0000000..e379264
--- /dev/null
+++ b/src/interactors/postTeamsMessage/buildMessage/buildSubtitle.js
@@ -0,0 +1,31 @@
+const { buildSources } = require('../../../utils');
+
+const getPRText = (pullRequest) => {
+ const { url, number } = pullRequest || {};
+ if (!url || !number) return '';
+ return ` ([#${number}](${url}))`;
+};
+
+const buildGithubLink = ({ description, path }) => `[${description}](https://github.com/${path})`;
+
+module.exports = ({
+ t,
+ org,
+ repos,
+ pullRequest,
+ periodLength,
+}) => {
+ const sources = buildSources({ buildGithubLink, org, repos });
+ return {
+ type: 'Container',
+ padding: 'Small',
+ items: [
+ {
+ type: 'TextBlock',
+ weight: 'Lighter',
+ wrap: true,
+ text: `${t('table.subtitle', { sources, count: periodLength })}${getPRText(pullRequest)}`,
+ },
+ ],
+ };
+};
diff --git a/src/interactors/postTeamsMessage/buildMessage/index.js b/src/interactors/postTeamsMessage/buildMessage/index.js
new file mode 100644
index 0000000..c22ca57
--- /dev/null
+++ b/src/interactors/postTeamsMessage/buildMessage/index.js
@@ -0,0 +1,31 @@
+const { t } = require('../../../i18n');
+const buildHeaders = require('./buildHeaders');
+const buildSubtitle = require('./buildSubtitle');
+const buildReviewer = require('./buildReviewer');
+
+module.exports = ({
+ org,
+ repos,
+ reviewers,
+ pullRequest,
+ periodLength,
+ disableLinks,
+ displayCharts,
+}) => ([
+ buildSubtitle({
+ t,
+ org,
+ repos,
+ pullRequest,
+ periodLength,
+ }),
+
+ buildHeaders({ t }),
+
+ ...reviewers.map((reviewer, index) => buildReviewer({
+ index,
+ reviewer,
+ disableLinks,
+ displayCharts,
+ })),
+]);
diff --git a/src/interactors/postTeamsMessage/buildPayload.js b/src/interactors/postTeamsMessage/buildPayload.js
new file mode 100644
index 0000000..7d6aec5
--- /dev/null
+++ b/src/interactors/postTeamsMessage/buildPayload.js
@@ -0,0 +1,18 @@
+module.exports = (body) => ({
+ type: 'message',
+ attachments: [
+ {
+ contentType: 'application/vnd.microsoft.card.adaptive',
+ contentUrl: null,
+ content: {
+ body,
+ $schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
+ type: 'AdaptiveCard',
+ version: '1.0',
+ msteams: {
+ width: 'Full',
+ },
+ },
+ },
+ ],
+});
diff --git a/src/interactors/postTeamsMessage/index.js b/src/interactors/postTeamsMessage/index.js
new file mode 100644
index 0000000..1dbacc2
--- /dev/null
+++ b/src/interactors/postTeamsMessage/index.js
@@ -0,0 +1,67 @@
+const { t } = require('../../i18n');
+const { postToWebhook } = require('../../fetchers');
+const { TeamsSplitter } = require('../../services/splitter');
+const buildMessage = require('./buildMessage');
+const buildPayload = require('./buildPayload');
+
+const DELAY = 500;
+
+module.exports = async ({
+ org,
+ repos,
+ core,
+ teams,
+ isSponsor,
+ reviewers,
+ periodLength,
+ disableLinks,
+ displayCharts,
+ pullRequest = null,
+}) => {
+ const { webhook } = teams || {};
+
+ if (!webhook) {
+ core.debug(t('integrations.teams.logs.notConfigured'));
+ return;
+ }
+
+ if (!isSponsor) {
+ core.error(t('integrations.teams.errors.notSponsor'));
+ return;
+ }
+
+ const send = (body) => {
+ const params = {
+ webhook,
+ payload: buildPayload(body),
+ };
+ core.debug(t('integrations.teams.logs.posting', {
+ params: JSON.stringify(params, null, 2),
+ }));
+ return postToWebhook(params);
+ };
+
+ const fullMessage = buildMessage({
+ org,
+ repos,
+ reviewers,
+ pullRequest,
+ periodLength,
+ disableLinks,
+ displayCharts,
+ });
+
+ const { chunks } = new TeamsSplitter({ message: fullMessage });
+ await chunks.reduce(async (promise, message) => {
+ await promise;
+ await send(message).catch((error) => {
+ core.error(t('integrations.teams.errors.requestFailed', { error }));
+ throw error;
+ });
+ // Delaying between requests to prevent rate limiting
+ // https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using?tabs=cURL#rate-limiting-for-connectors
+ await new Promise((resolve) => setTimeout(resolve, DELAY));
+ }, Promise.resolve());
+
+ core.debug(t('integrations.teams.logs.success'));
+};
diff --git a/src/services/splitter/__tests__/base.test.js b/src/services/splitter/__tests__/base.test.js
new file mode 100644
index 0000000..3f45ac5
--- /dev/null
+++ b/src/services/splitter/__tests__/base.test.js
@@ -0,0 +1,183 @@
+const BaseSplitter = require('../base');
+
+describe('Services | Splitter | BaseSplitter', () => {
+ describe('.constructor', () => {
+ it('receives message and limit on constructor', () => {
+ const limit = 100;
+ const message = 'Some message';
+ const splitter = new BaseSplitter({ message, limit });
+ expect(splitter.limit).toEqual(limit);
+ expect(splitter.message).toEqual(message);
+ });
+
+ it('assigns a default limit when not specified', () => {
+ const splitter = new BaseSplitter({ message: '' });
+ expect(splitter.limit).toEqual(BaseSplitter.defaultLimit());
+ });
+ });
+
+ describe('.defaultLimit', () => {
+ it('returns the highest possible number', () => {
+ expect(BaseSplitter.defaultLimit()).toEqual(Infinity);
+ });
+ });
+
+ describe('.splitBlocks', () => {
+ it('throws "not implemented" error', () => {
+ expect(() => BaseSplitter.splitBlocks()).toThrow(Error, 'Not implemented');
+ });
+ });
+
+ describe('.calculateSize', () => {
+ it('throws "not implemented" error', () => {
+ expect(() => BaseSplitter.calculateSize()).toThrow(Error, 'Not implemented');
+ });
+ });
+
+ describe('.getBlocksCount', () => {
+ it('throws "not implemented" error', () => {
+ expect(() => BaseSplitter.getBlocksCount()).toThrow(Error, 'Not implemented');
+ });
+ });
+
+ describe('.calculateSizePerBlock', () => {
+ it('throws "not implemented" error', () => {
+ expect(() => BaseSplitter.calculateSizePerBlock()).toThrow(Error, 'Not implemented');
+ });
+ });
+
+ describe('#blockSize', () => {
+ const originalCalculateSize = BaseSplitter.calculateSizePerBlock;
+ const calculateSize = jest.fn();
+
+ beforeAll(() => {
+ BaseSplitter.calculateSizePerBlock = calculateSize;
+ });
+
+ afterAll(() => {
+ BaseSplitter.calculateSizePerBlock = originalCalculateSize;
+ });
+
+ const mockCalculateSize = (value) => {
+ calculateSize.mockClear();
+ calculateSize.mockReturnValue(value);
+ };
+
+ it('returns the calculated size per block', () => {
+ const size = 10;
+ mockCalculateSize(size);
+
+ const splitter = new BaseSplitter({ message: '' });
+ expect(splitter.blockSize).toEqual(size);
+ });
+
+ it('returns at least 1 when the calculated is less', () => {
+ const size = -5;
+ mockCalculateSize(size);
+
+ const splitter = new BaseSplitter({ message: '' });
+ expect(splitter.blockSize).toEqual(1);
+ });
+
+ it('memoizes the value', () => {
+ const size = 3;
+ mockCalculateSize(size);
+
+ const splitter = new BaseSplitter({ message: '' });
+
+ // Called multiple times to test memoization
+ expect(splitter.blockSize).toEqual(3);
+ expect(splitter.blockSize).toEqual(3);
+ expect(splitter.blockSize).toEqual(3);
+ expect(calculateSize).toBeCalledTimes(1);
+ });
+ });
+
+ describe('#chunks', () => {
+ const buildBlock = (str, length) => `${str}`.padEnd(length, '.');
+
+ const originalSplitBlocks = BaseSplitter.splitBlocks;
+ const originalCalculateSize = BaseSplitter.calculateSize;
+ const originalGetBlocksCount = BaseSplitter.getBlocksCount;
+ const originalCalculateSizePerBlock = BaseSplitter.calculateSizePerBlock;
+
+ const splitBlocks = (msg, count) => {
+ const firsts = msg.slice(0, count);
+ const lasts = msg.slice(count);
+ return [firsts, lasts];
+ };
+
+ const calculateSize = (msg) => msg.join('').length;
+
+ const getBlocksCount = (msg) => msg.length;
+
+ const calculateSizePerBlock = (msg) => Math.round(calculateSize(msg) / getBlocksCount(msg));
+
+ const block1 = buildBlock('BLOCK 1', 15);
+ const block2 = buildBlock('BLOCK 2', 15);
+ const block3 = buildBlock('BLOCK 3', 120);
+ const block4 = buildBlock('BLOCK 4', 60);
+ const block5 = buildBlock('BLOCK 5', 50);
+ const block6 = buildBlock('BLOCK 6', 10);
+ const block7 = buildBlock('BLOCK 7', 10);
+ const message = [
+ block1,
+ block2,
+ block3,
+ block4,
+ block5,
+ block6,
+ block7,
+ ];
+
+ beforeAll(() => {
+ BaseSplitter.splitBlocks = splitBlocks;
+ BaseSplitter.calculateSize = calculateSize;
+ BaseSplitter.getBlocksCount = getBlocksCount;
+ BaseSplitter.calculateSizePerBlock = calculateSizePerBlock;
+ });
+
+ afterAll(() => {
+ BaseSplitter.splitBlocks = originalSplitBlocks;
+ BaseSplitter.calculateSize = originalCalculateSize;
+ BaseSplitter.getBlocksCount = originalGetBlocksCount;
+ BaseSplitter.calculateSizePerBlock = originalCalculateSizePerBlock;
+ });
+
+ it('returns a single chunk when limit is too large', () => {
+ const limit = Infinity;
+ const splitter = new BaseSplitter({ message, limit });
+ const result = splitter.chunks;
+ expect(result).toEqual([
+ [...message],
+ ]);
+ });
+
+ it('returns one chunk per block when limit is too small', () => {
+ const limit = 1;
+ const splitter = new BaseSplitter({ message, limit });
+ const result = splitter.chunks;
+ expect(result).toEqual([
+ [block1],
+ [block2],
+ [block3],
+ [block4],
+ [block5],
+ [block6],
+ [block7],
+ ]);
+ });
+
+ it('distributes message in chunks to avoid passing the limit', () => {
+ const limit = 100;
+ const splitter = new BaseSplitter({ message, limit });
+ const result = splitter.chunks;
+ expect(result).toEqual([
+ [block1, block2],
+ [block3],
+ [block4],
+ [block5, block6, block7],
+ ]);
+ });
+ });
+});
diff --git a/src/services/splitter/__tests__/slack.test.js b/src/services/splitter/__tests__/slack.test.js
new file mode 100644
index 0000000..46d8647
--- /dev/null
+++ b/src/services/splitter/__tests__/slack.test.js
@@ -0,0 +1,98 @@
+const SlackSplitter = require('../slack');
+const { median } = require('../../../utils');
+
+jest.mock('../../../config', () => ({
+ getSlackCharsLimit: () => 100,
+}));
+
+describe('Services | Splitter | SlackSplitter', () => {
+ const block1 = {
+ type: 'section',
+ text: 'Some text 1',
+ };
+ const block2 = { type: 'divider' };
+ const block3 = {
+ type: 'section',
+ text: 'Some text 2',
+ };
+ const block4 = { type: 'divider' };
+ const block5 = {
+ type: 'section',
+ text: 'Some text 3',
+ };
+ const block6 = { type: 'divider' };
+ const block7 = {
+ type: 'section',
+ text: 'Some text 4',
+ };
+ const message = {
+ blocks: [
+ block1,
+ block2,
+ block3,
+ block4,
+ block5,
+ block6,
+ block7,
+ ],
+ };
+
+ describe('.defaultLimit', () => {
+ it('returns limit from config', () => {
+ expect(SlackSplitter.defaultLimit()).toEqual(100);
+ });
+ });
+
+ describe('.splitBlocks', () => {
+ it('splits message in 2 blocks given an index', () => {
+ const [results1, results2] = SlackSplitter.splitBlocks(message, 3);
+ expect(results1).toEqual({
+ blocks: [block1, block2, block3],
+ });
+ expect(results2).toEqual({
+ blocks: [block4, block5, block6, block7],
+ });
+ });
+
+ it('returns full message as the first split when index is last', () => {
+ const [results1, results2] = SlackSplitter.splitBlocks(message, 7);
+ expect(results1).toEqual(message);
+ expect(results2).toEqual({ blocks: [] });
+ });
+
+ it('returns full message as the last split when index is 0', () => {
+ const [results1, results2] = SlackSplitter.splitBlocks(message, 0);
+ expect(results1).toEqual({ blocks: [] });
+ expect(results2).toEqual(message);
+ });
+ });
+
+ describe('.calculateSize', () => {
+ it('returns the length of the message parsed to JSON', () => {
+ const result = SlackSplitter.calculateSize(message);
+ expect(result > 0).toEqual(true);
+ expect(result).toEqual(JSON.stringify(message).length);
+ });
+ });
+
+ describe('.getBlocksCount', () => {
+ it('returns the number of blocks in a message', () => {
+ const result = SlackSplitter.getBlocksCount(message);
+ expect(result > 0).toEqual(true);
+ expect(result).toEqual(message.blocks.length);
+ });
+ });
+
+ describe('.calculateSizePerBlock', () => {
+ it('returns the median size of the blocks with type "section"', () => {
+ const size1 = JSON.stringify(block1).length;
+ const size2 = JSON.stringify(block3).length;
+ const size3 = JSON.stringify(block5).length;
+ const size4 = JSON.stringify(block7).length;
+ const expected = Math.ceil(median([size1, size2, size3, size4]));
+ const result = SlackSplitter.calculateSizePerBlock(message);
+ expect(result > 0).toEqual(true);
+ expect(result).toEqual(expected);
+ });
+ });
+});
diff --git a/src/services/splitter/__tests__/teams.test.js b/src/services/splitter/__tests__/teams.test.js
new file mode 100644
index 0000000..2c3fcdc
--- /dev/null
+++ b/src/services/splitter/__tests__/teams.test.js
@@ -0,0 +1,94 @@
+const TeamsSplitter = require('../teams');
+const { median } = require('../../../utils');
+
+jest.mock('../../../config', () => ({
+ getTeamsBytesLimit: () => 5000,
+}));
+
+const byteLength = (input) => Buffer.byteLength(JSON.stringify(input));
+
+describe('Services | Splitter | TeamsSplitter', () => {
+ const block1 = {
+ type: 'ColumnSet',
+ text: 'Some text 1',
+ };
+ const block2 = { type: 'Container' };
+ const block3 = {
+ type: 'ColumnSet',
+ text: 'Some text 2',
+ };
+ const block4 = { type: 'Container' };
+ const block5 = {
+ type: 'ColumnSet',
+ text: 'Some text 3',
+ };
+ const block6 = { type: 'Container' };
+ const block7 = {
+ type: 'ColumnSet',
+ text: 'Some text 4',
+ };
+ const message = [
+ block1,
+ block2,
+ block3,
+ block4,
+ block5,
+ block6,
+ block7,
+ ];
+
+ describe('.defaultLimit', () => {
+ it('returns limit from config', () => {
+ expect(TeamsSplitter.defaultLimit()).toEqual(5000);
+ });
+ });
+
+ describe('.splitBlocks', () => {
+ it('splits message in 2 blocks given an index', () => {
+ const [results1, results2] = TeamsSplitter.splitBlocks(message, 3);
+ expect(results1).toEqual([block1, block2, block3]);
+ expect(results2).toEqual([block4, block5, block6, block7]);
+ });
+
+ it('returns full message as the first split when index is last', () => {
+ const [results1, results2] = TeamsSplitter.splitBlocks(message, 7);
+ expect(results1).toEqual(message);
+ expect(results2).toEqual([]);
+ });
+
+ it('returns full message as the last split when index is 0', () => {
+ const [results1, results2] = TeamsSplitter.splitBlocks(message, 0);
+ expect(results1).toEqual([]);
+ expect(results2).toEqual(message);
+ });
+ });
+
+ describe('.calculateSize', () => {
+ it('returns the length of the message parsed to JSON', () => {
+ const result = TeamsSplitter.calculateSize(message);
+ expect(result > 0).toEqual(true);
+ expect(result).toEqual(byteLength(message));
+ });
+ });
+
+ describe('.getBlocksCount', () => {
+ it('returns the number of blocks in a message', () => {
+ const result = TeamsSplitter.getBlocksCount(message);
+ expect(result > 0).toEqual(true);
+ expect(result).toEqual(message.length);
+ });
+ });
+
+ describe('.calculateSizePerBlock', () => {
+ it('returns the median size of the blocks with type "ColumnSet"', () => {
+ const size1 = byteLength(block1);
+ const size2 = byteLength(block3);
+ const size3 = byteLength(block5);
+ const size4 = byteLength(block7);
+ const expected = Math.ceil(median([size1, size2, size3, size4]));
+ const result = TeamsSplitter.calculateSizePerBlock(message);
+ expect(result > 0).toEqual(true);
+ expect(result).toEqual(expected);
+ });
+ });
+});
diff --git a/src/services/splitter/base.js b/src/services/splitter/base.js
new file mode 100644
index 0000000..f0246db
--- /dev/null
+++ b/src/services/splitter/base.js
@@ -0,0 +1,59 @@
+class BaseSplitter {
+ constructor({ message, limit = null }) {
+ this.limit = limit || this.constructor.defaultLimit();
+ this.message = message;
+ }
+
+ static defaultLimit() {
+ return Infinity;
+ }
+
+ get blockSize() {
+ if (!this.blockSizeMemo) {
+ this.blockSizeMemo = Math.max(1, this.constructor.calculateSizePerBlock(this.message));
+ }
+ return this.blockSizeMemo;
+ }
+
+ get chunks() {
+ if (!this.chunksMemo) this.chunksMemo = this.split([], this.message);
+ return this.chunksMemo;
+ }
+
+ split(prev, message) {
+ const blocksToSplit = this.calculateBlocksToSplit(message);
+ if (!blocksToSplit) return [...prev, message];
+ const [first, last] = this.constructor.splitBlocks(message, blocksToSplit);
+ return this.split([...prev, first], last);
+ }
+
+ calculateBlocksToSplit(message) {
+ const blocksCount = this.constructor.getBlocksCount(message);
+ const currentSize = this.constructor.calculateSize(message);
+ const diff = currentSize - this.limit;
+ if (diff < 0 || blocksCount === 1) return 0;
+
+ const blocksSpace = Math.ceil(diff / this.blockSize);
+ const blocksToSplit = Math.max(1, Math.min(blocksCount - 1, blocksSpace));
+ const [firsts] = this.constructor.splitBlocks(message, blocksToSplit);
+ return this.calculateBlocksToSplit(firsts) || blocksToSplit;
+ }
+
+ static splitBlocks() {
+ throw new Error('Not implemented');
+ }
+
+ static calculateSize() {
+ throw new Error('Not implemented');
+ }
+
+ static getBlocksCount() {
+ throw new Error('Not implemented');
+ }
+
+ static calculateSizePerBlock() {
+ throw new Error('Not implemented');
+ }
+}
+
+module.exports = BaseSplitter;
diff --git a/src/services/splitter/index.js b/src/services/splitter/index.js
new file mode 100644
index 0000000..10ba3cb
--- /dev/null
+++ b/src/services/splitter/index.js
@@ -0,0 +1,7 @@
+const SlackSplitter = require('./slack');
+const TeamsSplitter = require('./teams');
+
+module.exports = {
+ SlackSplitter,
+ TeamsSplitter,
+};
diff --git a/src/services/splitter/slack.js b/src/services/splitter/slack.js
new file mode 100644
index 0000000..81e3771
--- /dev/null
+++ b/src/services/splitter/slack.js
@@ -0,0 +1,35 @@
+const { getSlackCharsLimit } = require('../../config');
+const { median } = require('../../utils');
+const BaseSplitter = require('./base');
+
+class SlackSplitter extends BaseSplitter {
+ static defaultLimit() {
+ return getSlackCharsLimit();
+ }
+
+ static splitBlocks(message, count) {
+ const { blocks } = message;
+ const firsts = blocks.slice(0, count);
+ const lasts = blocks.slice(count);
+ return [{ blocks: firsts }, { blocks: lasts }];
+ }
+
+ static calculateSize(message) {
+ return JSON.stringify(message).length;
+ }
+
+ static getBlocksCount(message) {
+ return message.blocks.length;
+ }
+
+ static calculateSizePerBlock(message) {
+ const blockLengths = message
+ .blocks
+ .filter(({ type }) => type === 'section')
+ .map((block) => this.calculateSize(block));
+
+ return Math.ceil(median(blockLengths));
+ }
+}
+
+module.exports = SlackSplitter;
diff --git a/src/services/splitter/teams.js b/src/services/splitter/teams.js
new file mode 100644
index 0000000..e57d5c3
--- /dev/null
+++ b/src/services/splitter/teams.js
@@ -0,0 +1,33 @@
+const { getTeamsBytesLimit } = require('../../config');
+const { median } = require('../../utils');
+const BaseSplitter = require('./base');
+
+class TeamsSplitter extends BaseSplitter {
+ static defaultLimit() {
+ return getTeamsBytesLimit();
+ }
+
+ static splitBlocks(body, count) {
+ const firsts = body.slice(0, count);
+ const lasts = body.slice(count);
+ return [firsts, lasts];
+ }
+
+ static calculateSize(body) {
+ return Buffer.byteLength(JSON.stringify(body));
+ }
+
+ static getBlocksCount(body) {
+ return body.length;
+ }
+
+ static calculateSizePerBlock(body) {
+ const blockLengths = body
+ .filter(({ type }) => type === 'ColumnSet')
+ .map((block) => this.calculateSize(block));
+
+ return Math.ceil(median(blockLengths));
+ }
+}
+
+module.exports = TeamsSplitter;
diff --git a/src/services/telemetry/sendStart.js b/src/services/telemetry/sendStart.js
index 01d95d2..80c20a1 100644
--- a/src/services/telemetry/sendStart.js
+++ b/src/services/telemetry/sendStart.js
@@ -11,12 +11,14 @@ module.exports = ({
limit,
tracker,
slack,
+ teams,
webhook,
}) => {
const owner = getRepoOwner(currentRepo);
const reposCount = (repos || []).length;
const orgsCount = org ? 1 : 0;
const usingSlack = !!(slack || {}).webhook;
+ const usingTeams = !!(teams || {}).webhook;
const usingWebhook = !!webhook;
tracker.track('run', {
@@ -33,6 +35,7 @@ module.exports = ({
disableLinks,
limit,
usingSlack,
+ usingTeams,
usingWebhook,
});
};