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, }); };