From b6a6d194e088088686280805ecd04a7e4a88d87e Mon Sep 17 00:00:00 2001 From: Manuel de la Torre Date: Sat, 22 Oct 2022 15:00:31 -0500 Subject: [PATCH 1/6] Feature: MS Teams integration --- src/services/splitter/__tests__/base.test.js | 18 + src/services/splitter/__tests__/slack.test.js | 48 +++ src/services/splitter/base.js | 45 +++ src/services/splitter/slack.js | 33 ++ teams.json | 317 ++++++++++++++++++ 5 files changed, 461 insertions(+) create mode 100644 src/services/splitter/__tests__/base.test.js create mode 100644 src/services/splitter/__tests__/slack.test.js create mode 100644 src/services/splitter/base.js create mode 100644 src/services/splitter/slack.js create mode 100644 teams.json diff --git a/src/services/splitter/__tests__/base.test.js b/src/services/splitter/__tests__/base.test.js new file mode 100644 index 0000000..a1d8cbf --- /dev/null +++ b/src/services/splitter/__tests__/base.test.js @@ -0,0 +1,18 @@ +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()); + }); + }); +}); diff --git a/src/services/splitter/__tests__/slack.test.js b/src/services/splitter/__tests__/slack.test.js new file mode 100644 index 0000000..7e0c6c8 --- /dev/null +++ b/src/services/splitter/__tests__/slack.test.js @@ -0,0 +1,48 @@ +// 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/services/splitter/base.js b/src/services/splitter/base.js new file mode 100644 index 0000000..9851fc8 --- /dev/null +++ b/src/services/splitter/base.js @@ -0,0 +1,45 @@ +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.getSizePerBlock()); + return this.blockSizeMemo; + } + + get chunks() { + if (!this.chunksMemo) this.chunksMemo = this.split([], this.message); + return this.chunksMemo; + } + + static split(prev, message) { + const blocksToSplit = this.calculateBlocksToSplit(message); + if (!blocksToSplit) return [...prev, message]; + const [first, last] = this.splitBlocks(message, blocksToSplit); + return this.split([...prev, first], last); + } + + static calculateBlocksToSplit(msg) { + const blocksCount = this.getBlocksCount(msg); + const currentSize = this.calculateSize(msg); + 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.splitBlocks(msg, blocksToSplit); + return this.calculateBlocksToSplit(firsts) || blocksToSplit; + } + + static splitBlocks(...) { + throw new Error('Not implemented'); + } +} + +module.exports = BaseSplitter; diff --git a/src/services/splitter/slack.js b/src/services/splitter/slack.js new file mode 100644 index 0000000..89cb561 --- /dev/null +++ b/src/services/splitter/slack.js @@ -0,0 +1,33 @@ +// const { getSlackCharsLimit } = require('../../config'); +// const { median } = require('../../utils'); + +// class SlackSplitter { +// static defaultLimit() { +// return getSlackCharsLimit(); +// } + +// // Slack +// 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 getSizePerBlock(message) { +// const blockLengths = message +// .blocks +// .filter(({ type }) === 'section') +// .map((block) => this.calculateSize(block)); + +// return Math.round(median(blockLengths)); +// } +// } diff --git a/teams.json b/teams.json new file mode 100644 index 0000000..4accee0 --- /dev/null +++ b/teams.json @@ -0,0 +1,317 @@ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.0", + "body": [ + { + "type": "Container", + "id": "4a4631f4-8373-6aa4-9c42-0eb9be92bea4", + "padding": "Small", + "items": [ + { + "type": "TextBlock", + "id": "f41ff117-10ce-4f16-372e-fb2948c18d4f", + "text": "Stats of the last 30 days for [yotepresto.com](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": "Total Reviews", + "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": "Time to review", + "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": "Small" + }, + { + "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/1031639?v=4", + "altText": "manuelmhtr", + "size": "Small", + "style": "Person", + "spacing": "None", + "horizontalAlignment": "Left", + "width": "32px", + "height": "32px" + } + ] + }, + { + "type": "Column", + "id": "fb74a656-e79a-4b16-bc09-d97036ac70a9", + "padding": "None", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "id": "c8e5ba18-cf6c-c1fb-266b-e68b5559864b", + "text": "manuelmhtr", + "wrap": true, + "horizontalAlignment": "Left", + "spacing": "Small" + } + ], + "verticalContentAlignment": "Center" + } + ], + "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": "41", + "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": "26m", + "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": "30", + "wrap": true + } + ], + "spacing": "Small", + "verticalContentAlignment": "Center" + } + ], + "padding": "Small", + "spacing": "None", + "separator": true + }, + { + "type": "ColumnSet", + "id": "11b3ecc6-e834-c99a-a5b9-c84ca3507c4a", + "columns": [ + { + "type": "Column", + "id": "20f0a3a0-5d46-7493-4636-a6527693cf23", + "padding": "None", + "width": "stretch", + "items": [ + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "id": "00ec6ca4-b5b5-9434-8725-e321d47eacc4", + "padding": "None", + "width": "auto", + "items": [ + { + "type": "Image", + "id": "c2316c8e-eaae-1694-1952-616c98b42a9c", + "url": "https://avatars.githubusercontent.com/u/1031639?v=4", + "altText": "manuelmhtr", + "size": "Small", + "style": "Person", + "spacing": "None", + "horizontalAlignment": "Left", + "width": "32px", + "height": "32px" + } + ] + }, + { + "type": "Column", + "id": "b1d7f9d7-b700-fe81-176a-5c58af24ebdb", + "padding": "None", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "id": "3fa75c88-1ac3-fff9-2abf-89452726348a", + "text": "manuelmhtr", + "wrap": true, + "horizontalAlignment": "Left", + "spacing": "Small" + } + ], + "verticalContentAlignment": "Center" + } + ], + "padding": "None" + } + ], + "spacing": "Small", + "separator": true + }, + { + "type": "Column", + "id": "1df09895-48ee-334b-234d-c90a2a8cd2fb", + "padding": "None", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "id": "88d2edad-e4ef-bf68-b0f6-e3d2bf92e547", + "text": "41", + "wrap": true + } + ], + "spacing": "Small", + "verticalContentAlignment": "Center" + }, + { + "type": "Column", + "id": "85ff8d1a-5d51-08f3-847a-155ee08995d7", + "padding": "None", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "id": "1d8ac7fb-1f7a-c3fe-691d-948bc89aceb3", + "text": "26m", + "wrap": true + } + ], + "spacing": "Small", + "verticalContentAlignment": "Center" + }, + { + "type": "Column", + "id": "331d9331-f649-787a-da70-1dcd36da1ee8", + "padding": "None", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "id": "9c390215-e6a3-1b12-8ecc-056cbe4b1551", + "text": "30", + "wrap": true + } + ], + "spacing": "Small", + "verticalContentAlignment": "Center" + } + ], + "padding": "Small", + "spacing": "None", + "separator": true + } + ], + "padding": "None" +} \ No newline at end of file From 0e1aa11812a45a881744b9837ea5b720b0d52704 Mon Sep 17 00:00:00 2001 From: Manuel de la Torre Date: Sun, 23 Oct 2022 13:02:48 -0500 Subject: [PATCH 2/6] Feature: Using Splitter service for Slack. --- package.json | 4 +- .../postSlackMessage/__tests__/index.test.js | 31 +++- .../__tests__/splitInChunks.test.js | 48 ----- src/interactors/postSlackMessage/index.js | 4 +- .../postSlackMessage/splitInChunks.js | 37 ---- src/services/splitter/__tests__/base.test.js | 167 +++++++++++++++++- src/services/splitter/__tests__/slack.test.js | 145 ++++++++++----- src/services/splitter/base.js | 30 +++- src/services/splitter/index.js | 5 + src/services/splitter/slack.js | 56 +++--- 10 files changed, 349 insertions(+), 178 deletions(-) delete mode 100644 src/interactors/postSlackMessage/__tests__/splitInChunks.test.js delete mode 100644 src/interactors/postSlackMessage/splitInChunks.js create mode 100644 src/services/splitter/index.js diff --git a/package.json b/package.json index 9087ba7..7b19e30 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "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/interactors/postSlackMessage/__tests__/index.test.js b/src/interactors/postSlackMessage/__tests__/index.test.js index c2ec308..6900816 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 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('../buildSlackMessage', () => jest.fn()); describe('Interactors | .postSlackMessage', () => { const debug = jest.fn(); @@ -33,11 +35,14 @@ describe('Interactors | .postSlackMessage', () => { }, }; + const mockBuildMessage = (msg) => buildSlackMessage.mockReturnValue(msg); + beforeEach(() => { debug.mockClear(); error.mockClear(); buildSlackMessage.mockClear(); Fetchers.postToSlack.mockClear(); + mockBuildMessage(MESSAGE); }); describe('when integration is not configured', () => { @@ -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(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/index.js b/src/interactors/postSlackMessage/index.js index 5a88965..f6a8111 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 { SlackSplitter } = require('../../services/splitter'); const buildSlackMessage = require('./buildSlackMessage'); -const splitInChunks = require('./splitInChunks'); module.exports = async ({ org, @@ -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/services/splitter/__tests__/base.test.js b/src/services/splitter/__tests__/base.test.js index a1d8cbf..3f45ac5 100644 --- a/src/services/splitter/__tests__/base.test.js +++ b/src/services/splitter/__tests__/base.test.js @@ -1,7 +1,7 @@ const BaseSplitter = require('../base'); describe('Services | Splitter | BaseSplitter', () => { - describe('constructor', () => { + describe('.constructor', () => { it('receives message and limit on constructor', () => { const limit = 100; const message = 'Some message'; @@ -15,4 +15,169 @@ describe('Services | Splitter | BaseSplitter', () => { 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 index 7e0c6c8..2c290bd 100644 --- a/src/services/splitter/__tests__/slack.test.js +++ b/src/services/splitter/__tests__/slack.test.js @@ -1,48 +1,97 @@ -// 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); -// }); -// }); +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).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/base.js b/src/services/splitter/base.js index 9851fc8..f0246db 100644 --- a/src/services/splitter/base.js +++ b/src/services/splitter/base.js @@ -9,7 +9,9 @@ class BaseSplitter { } get blockSize() { - if (!this.blockSizeMemo) this.blockSizeMemo = Math.max(1, this.getSizePerBlock()); + if (!this.blockSizeMemo) { + this.blockSizeMemo = Math.max(1, this.constructor.calculateSizePerBlock(this.message)); + } return this.blockSizeMemo; } @@ -18,26 +20,38 @@ class BaseSplitter { return this.chunksMemo; } - static split(prev, message) { + split(prev, message) { const blocksToSplit = this.calculateBlocksToSplit(message); if (!blocksToSplit) return [...prev, message]; - const [first, last] = this.splitBlocks(message, blocksToSplit); + const [first, last] = this.constructor.splitBlocks(message, blocksToSplit); return this.split([...prev, first], last); } - static calculateBlocksToSplit(msg) { - const blocksCount = this.getBlocksCount(msg); - const currentSize = this.calculateSize(msg); + 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.splitBlocks(msg, blocksToSplit); + const [firsts] = this.constructor.splitBlocks(message, blocksToSplit); return this.calculateBlocksToSplit(firsts) || blocksToSplit; } - static splitBlocks(...) { + 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'); } } diff --git a/src/services/splitter/index.js b/src/services/splitter/index.js new file mode 100644 index 0000000..c5ed3a4 --- /dev/null +++ b/src/services/splitter/index.js @@ -0,0 +1,5 @@ +const SlackSplitter = require('./slack'); + +module.exports = { + SlackSplitter, +}; diff --git a/src/services/splitter/slack.js b/src/services/splitter/slack.js index 89cb561..81e3771 100644 --- a/src/services/splitter/slack.js +++ b/src/services/splitter/slack.js @@ -1,33 +1,35 @@ -// const { getSlackCharsLimit } = require('../../config'); -// const { median } = require('../../utils'); +const { getSlackCharsLimit } = require('../../config'); +const { median } = require('../../utils'); +const BaseSplitter = require('./base'); -// class SlackSplitter { -// static defaultLimit() { -// return getSlackCharsLimit(); -// } +class SlackSplitter extends BaseSplitter { + static defaultLimit() { + return getSlackCharsLimit(); + } -// // Slack -// static splitBlocks(message, count) { -// const { blocks } = message; -// const firsts = blocks.slice(0, count); -// const lasts = blocks.slice(count); -// return [{ blocks: firsts }, { blocks: lasts }]; -// } + 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 calculateSize(message) { + return JSON.stringify(message).length; + } -// static getBlocksCount(message) { -// return message.blocks.length; -// } + static getBlocksCount(message) { + return message.blocks.length; + } -// static getSizePerBlock(message) { -// const blockLengths = message -// .blocks -// .filter(({ type }) === 'section') -// .map((block) => this.calculateSize(block)); + static calculateSizePerBlock(message) { + const blockLengths = message + .blocks + .filter(({ type }) => type === 'section') + .map((block) => this.calculateSize(block)); -// return Math.round(median(blockLengths)); -// } -// } + return Math.ceil(median(blockLengths)); + } +} + +module.exports = SlackSplitter; From 85a63ecb6bf136985b3be12fad50e77c85b2118a Mon Sep 17 00:00:00 2001 From: Manuel de la Torre Date: Sun, 23 Oct 2022 14:46:21 -0500 Subject: [PATCH 3/6] Feature: MS Teams, message builder and Splitter service. --- action.yml | 9 +- src/config/index.js | 2 + src/execute.js | 12 +- src/i18n/locales/en-US/integrations.json | 13 +- src/index.js | 3 + src/interactors/index.js | 6 +- .../postSlackMessage/__tests__/index.test.js | 16 +-- .../__tests__/buildReviewer.test.js | 0 .../__tests__/buildSubtitle.test.js | 0 .../__tests__/index.test.js | 10 +- .../buildReviewer.js | 0 .../buildSubtitle.js | 0 .../index.js | 0 src/interactors/postSlackMessage/index.js | 4 +- .../__tests__/buildPayload.test.js | 13 ++ .../__tests__/buildHeaders.test.js | 32 +++++ .../__tests__/buildSubtitle.test.js | 67 ++++++++++ .../buildMessage/buildHeaders.js | 28 +++++ .../buildMessage/buildReviewer.js | 116 ++++++++++++++++++ .../buildMessage/buildSubtitle.js | 31 +++++ .../postTeamsMessage/buildMessage/index.js | 31 +++++ .../postTeamsMessage/buildPayload.js | 18 +++ src/interactors/postTeamsMessage/index.js | 67 ++++++++++ src/services/splitter/__tests__/slack.test.js | 1 + src/services/splitter/__tests__/teams.test.js | 94 ++++++++++++++ src/services/splitter/index.js | 2 + src/services/splitter/teams.js | 33 +++++ src/services/telemetry/sendStart.js | 3 + 28 files changed, 583 insertions(+), 28 deletions(-) rename src/interactors/postSlackMessage/{buildSlackMessage => buildMessage}/__tests__/buildReviewer.test.js (100%) rename src/interactors/postSlackMessage/{buildSlackMessage => buildMessage}/__tests__/buildSubtitle.test.js (100%) rename src/interactors/postSlackMessage/{buildSlackMessage => buildMessage}/__tests__/index.test.js (83%) rename src/interactors/postSlackMessage/{buildSlackMessage => buildMessage}/buildReviewer.js (100%) rename src/interactors/postSlackMessage/{buildSlackMessage => buildMessage}/buildSubtitle.js (100%) rename src/interactors/postSlackMessage/{buildSlackMessage => buildMessage}/index.js (100%) create mode 100644 src/interactors/postTeamsMessage/__tests__/buildPayload.test.js create mode 100644 src/interactors/postTeamsMessage/buildMessage/__tests__/buildHeaders.test.js create mode 100644 src/interactors/postTeamsMessage/buildMessage/__tests__/buildSubtitle.test.js create mode 100644 src/interactors/postTeamsMessage/buildMessage/buildHeaders.js create mode 100644 src/interactors/postTeamsMessage/buildMessage/buildReviewer.js create mode 100644 src/interactors/postTeamsMessage/buildMessage/buildSubtitle.js create mode 100644 src/interactors/postTeamsMessage/buildMessage/index.js create mode 100644 src/interactors/postTeamsMessage/buildPayload.js create mode 100644 src/interactors/postTeamsMessage/index.js create mode 100644 src/services/splitter/__tests__/teams.test.js create mode 100644 src/services/splitter/teams.js 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/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/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 6900816..159af9c 100644 --- a/src/interactors/postSlackMessage/__tests__/index.test.js +++ b/src/interactors/postSlackMessage/__tests__/index.test.js @@ -1,6 +1,6 @@ const Fetchers = require('../../../fetchers'); const { t } = require('../../../i18n'); -const buildSlackMessage = require('../buildSlackMessage'); +const buildMessage = require('../buildMessage'); const postSlackMessage = require('../index'); const MESSAGE = { @@ -10,7 +10,7 @@ const MESSAGE = { }; jest.mock('../../../fetchers', () => ({ postToSlack: jest.fn(() => Promise.resolve()) })); -jest.mock('../buildSlackMessage', () => jest.fn()); +jest.mock('../buildMessage', () => jest.fn()); describe('Interactors | .postSlackMessage', () => { const debug = jest.fn(); @@ -35,12 +35,12 @@ describe('Interactors | .postSlackMessage', () => { }, }; - const mockBuildMessage = (msg) => buildSlackMessage.mockReturnValue(msg); + const mockBuildMessage = (msg) => buildMessage.mockReturnValue(msg); beforeEach(() => { debug.mockClear(); error.mockClear(); - buildSlackMessage.mockClear(); + buildMessage.mockClear(); Fetchers.postToSlack.mockClear(); mockBuildMessage(MESSAGE); }); @@ -48,7 +48,7 @@ describe('Interactors | .postSlackMessage', () => { describe('when integration is not configured', () => { const expectDisabledIntegration = () => { expect(debug).toHaveBeenCalled(); - expect(buildSlackMessage).not.toHaveBeenCalled(); + expect(buildMessage).not.toHaveBeenCalled(); expect(Fetchers.postToSlack).not.toHaveBeenCalled(); }; @@ -69,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(); }); }); @@ -78,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, @@ -106,7 +106,7 @@ describe('Interactors | .postSlackMessage', () => { 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] }, 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 f6a8111..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 { SlackSplitter } = require('../../services/splitter'); -const buildSlackMessage = require('./buildSlackMessage'); +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, 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/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__/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/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__/slack.test.js b/src/services/splitter/__tests__/slack.test.js index 2c290bd..46d8647 100644 --- a/src/services/splitter/__tests__/slack.test.js +++ b/src/services/splitter/__tests__/slack.test.js @@ -70,6 +70,7 @@ describe('Services | Splitter | SlackSplitter', () => { 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); }); }); 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/index.js b/src/services/splitter/index.js index c5ed3a4..10ba3cb 100644 --- a/src/services/splitter/index.js +++ b/src/services/splitter/index.js @@ -1,5 +1,7 @@ const SlackSplitter = require('./slack'); +const TeamsSplitter = require('./teams'); module.exports = { SlackSplitter, + TeamsSplitter, }; 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, }); }; From 11ce9d1e470fea88302559442b716706d4aea781 Mon Sep 17 00:00:00 2001 From: Manuel de la Torre Date: Mon, 24 Oct 2022 20:04:32 -0500 Subject: [PATCH 4/6] Finish tests for Teams Integration. --- .../postTeamsMessage/__tests__/index.test.js | 119 ++++++++++++++++++ .../__tests__/buildReviewer.test.js | 68 ++++++++++ .../buildMessage/__tests__/index.test.js | 61 +++++++++ 3 files changed, 248 insertions(+) create mode 100644 src/interactors/postTeamsMessage/__tests__/index.test.js create mode 100644 src/interactors/postTeamsMessage/buildMessage/__tests__/buildReviewer.test.js create mode 100644 src/interactors/postTeamsMessage/buildMessage/__tests__/index.test.js 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__/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__/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); + }); +}); From c8fa77d3753d5b57f3cd169376b936449051731a Mon Sep 17 00:00:00 2001 From: Manuel de la Torre Date: Mon, 24 Oct 2022 20:46:47 -0500 Subject: [PATCH 5/6] Update readme --- README.md | 49 +- assets/slack-logo.jpg | Bin 0 -> 5662 bytes assets/teams-logo.jpg | Bin 0 -> 4255 bytes assets/teams.png | Bin 0 -> 57768 bytes assets/webhook-logo.jpg | Bin 0 -> 6057 bytes docs/examples/teams copy.json | 29 ++ docs/examples/teams.json | 703 ++++++++++++++++++++++++++ docs/slack.md | 34 ++ docs/teams.md | 33 ++ docs/webhook.md | 10 +- src/i18n/locales/en-US/execution.json | 2 +- teams.json | 317 ------------ 12 files changed, 822 insertions(+), 355 deletions(-) create mode 100644 assets/slack-logo.jpg create mode 100644 assets/teams-logo.jpg create mode 100644 assets/teams.png create mode 100644 assets/webhook-logo.jpg create mode 100644 docs/examples/teams copy.json create mode 100644 docs/examples/teams.json create mode 100644 docs/slack.md create mode 100644 docs/teams.md delete mode 100644 teams.json 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/assets/slack-logo.jpg b/assets/slack-logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e5910a1d6459c387e0843fc7b5e99e18ce943b7e GIT binary patch literal 5662 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 literal 0 HcmV?d00001 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..badb2b3 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/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/teams.json b/teams.json deleted file mode 100644 index 4accee0..0000000 --- a/teams.json +++ /dev/null @@ -1,317 +0,0 @@ -{ - "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "type": "AdaptiveCard", - "version": "1.0", - "body": [ - { - "type": "Container", - "id": "4a4631f4-8373-6aa4-9c42-0eb9be92bea4", - "padding": "Small", - "items": [ - { - "type": "TextBlock", - "id": "f41ff117-10ce-4f16-372e-fb2948c18d4f", - "text": "Stats of the last 30 days for [yotepresto.com](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": "Total Reviews", - "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": "Time to review", - "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": "Small" - }, - { - "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/1031639?v=4", - "altText": "manuelmhtr", - "size": "Small", - "style": "Person", - "spacing": "None", - "horizontalAlignment": "Left", - "width": "32px", - "height": "32px" - } - ] - }, - { - "type": "Column", - "id": "fb74a656-e79a-4b16-bc09-d97036ac70a9", - "padding": "None", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "id": "c8e5ba18-cf6c-c1fb-266b-e68b5559864b", - "text": "manuelmhtr", - "wrap": true, - "horizontalAlignment": "Left", - "spacing": "Small" - } - ], - "verticalContentAlignment": "Center" - } - ], - "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": "41", - "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": "26m", - "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": "30", - "wrap": true - } - ], - "spacing": "Small", - "verticalContentAlignment": "Center" - } - ], - "padding": "Small", - "spacing": "None", - "separator": true - }, - { - "type": "ColumnSet", - "id": "11b3ecc6-e834-c99a-a5b9-c84ca3507c4a", - "columns": [ - { - "type": "Column", - "id": "20f0a3a0-5d46-7493-4636-a6527693cf23", - "padding": "None", - "width": "stretch", - "items": [ - { - "type": "ColumnSet", - "columns": [ - { - "type": "Column", - "id": "00ec6ca4-b5b5-9434-8725-e321d47eacc4", - "padding": "None", - "width": "auto", - "items": [ - { - "type": "Image", - "id": "c2316c8e-eaae-1694-1952-616c98b42a9c", - "url": "https://avatars.githubusercontent.com/u/1031639?v=4", - "altText": "manuelmhtr", - "size": "Small", - "style": "Person", - "spacing": "None", - "horizontalAlignment": "Left", - "width": "32px", - "height": "32px" - } - ] - }, - { - "type": "Column", - "id": "b1d7f9d7-b700-fe81-176a-5c58af24ebdb", - "padding": "None", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "id": "3fa75c88-1ac3-fff9-2abf-89452726348a", - "text": "manuelmhtr", - "wrap": true, - "horizontalAlignment": "Left", - "spacing": "Small" - } - ], - "verticalContentAlignment": "Center" - } - ], - "padding": "None" - } - ], - "spacing": "Small", - "separator": true - }, - { - "type": "Column", - "id": "1df09895-48ee-334b-234d-c90a2a8cd2fb", - "padding": "None", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "id": "88d2edad-e4ef-bf68-b0f6-e3d2bf92e547", - "text": "41", - "wrap": true - } - ], - "spacing": "Small", - "verticalContentAlignment": "Center" - }, - { - "type": "Column", - "id": "85ff8d1a-5d51-08f3-847a-155ee08995d7", - "padding": "None", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "id": "1d8ac7fb-1f7a-c3fe-691d-948bc89aceb3", - "text": "26m", - "wrap": true - } - ], - "spacing": "Small", - "verticalContentAlignment": "Center" - }, - { - "type": "Column", - "id": "331d9331-f649-787a-da70-1dcd36da1ee8", - "padding": "None", - "width": "stretch", - "items": [ - { - "type": "TextBlock", - "id": "9c390215-e6a3-1b12-8ecc-056cbe4b1551", - "text": "30", - "wrap": true - } - ], - "spacing": "Small", - "verticalContentAlignment": "Center" - } - ], - "padding": "Small", - "spacing": "None", - "separator": true - } - ], - "padding": "None" -} \ No newline at end of file From c14d2517b95b28f66395cb84f0f46d4781b0e81f Mon Sep 17 00:00:00 2001 From: Manuel de la Torre Date: Mon, 24 Oct 2022 20:56:43 -0500 Subject: [PATCH 6/6] Update version --- CHANGELOG.md | 4 + dist/index.js | 995 +++++++++++++++++++++++++++++++++++------------- docs/webhook.md | 2 +- package.json | 2 +- 4 files changed, 733 insertions(+), 270 deletions(-) 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/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/webhook.md b/docs/webhook.md index badb2b3..49820c2 100644 --- a/docs/webhook.md +++ b/docs/webhook.md @@ -1,6 +1,6 @@ # Posting stats to a Webhook -> šŸ”„ This integration does not require a sponsorship. Enjoy! +> šŸ”„ 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. diff --git a/package.json b/package.json index 7b19e30..9caf954 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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": {