From 37cbf9214a1140d25c2c1a5ff097666c96721d6a Mon Sep 17 00:00:00 2001 From: Dmitriy Mozgovoy Date: Wed, 8 Nov 2023 16:17:55 +0200 Subject: [PATCH] chore(ci): added labeling and notification for published PRs; (#6059) --- .github/workflows/publish.yml | 5 ++ bin/GithubAPI.js | 119 +++++++++++++++++++++++++++ bin/RepoBot.js | 95 +++++++++++++++++++++ bin/actions/notify_published.js | 22 +++++ bin/api.js | 3 + bin/contributors.js | 11 ++- bin/{githubAPI.js => githubAxios.js} | 0 bin/helpers/colorize.js | 2 +- bin/helpers/parser.js | 12 +++ gulpfile.js | 2 +- package-lock.json | 103 +++++++++++++++++++++++ package.json | 3 +- templates/pr_published.hbs | 1 + 13 files changed, 373 insertions(+), 5 deletions(-) create mode 100644 bin/GithubAPI.js create mode 100644 bin/RepoBot.js create mode 100644 bin/actions/notify_published.js create mode 100644 bin/api.js rename bin/{githubAPI.js => githubAxios.js} (100%) create mode 100644 bin/helpers/parser.js create mode 100644 templates/pr_published.hbs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 37b07dab..394ae26c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -53,3 +53,8 @@ jobs: run: npm publish env: NODE_AUTH_TOKEN: ${{secrets.npm_token}} + ###### NOTIFY & TAG published PRs ###### + - name: Notify and tag published PRs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node ./bin/actions/notify_published.js --tag v${{ steps.package-version.outputs.current-version }} diff --git a/bin/GithubAPI.js b/bin/GithubAPI.js new file mode 100644 index 00000000..e8f5c99a --- /dev/null +++ b/bin/GithubAPI.js @@ -0,0 +1,119 @@ +import util from "util"; +import cp from "child_process"; +import {parseVersion} from "./helpers/parser.js"; +import githubAxios from "./githubAxios.js"; +import memoize from 'memoizee'; + +const exec = util.promisify(cp.exec); + +export default class GithubAPI { + constructor(owner, repo) { + if (!owner) { + throw new Error('repo owner must be specified'); + } + + if (!repo) { + throw new Error('repo must be specified'); + } + + this.repo = repo; + this.owner = owner; + this.axios = githubAxios.create({ + baseURL: `https://api.github.com/repos/${this.owner}/${this.repo}/`, + }) + } + + async createComment(issue, body) { + return (await this.axios.post(`/issues/${issue}/comments`, {body})).data; + } + + async getComments(issue, {desc = false, per_page= 100, page = 1}) { + return (await this.axios.get(`/issues/${issue}/comments`, {params: {direction: desc ? 'desc' : 'asc', per_page, page}})).data; + } + + async getComment(id) { + return (await this.axios.get(`/issues/comments/${id}`)).data; + } + + async updateComment(id, body) { + return (await this.axios.patch(`/issues/comments/${id}`, {body})).data; + } + + async appendLabels(issue, labels) { + return (await this.axios.post(`issues/${issue}/labels`, {labels})).data; + } + + async getUser(user) { + return (await this.axios.get(`users/${user}`)).data; + } + + async isCollaborator(user) { + try { + return (await this.axios.get(`/collaborators/${user}`)).status === 204; + } catch (e) { + + } + } + + async deleteLabel(issue, label) { + return (await this.axios.delete(`/issues/${issue}/labels/${label}`)).data; + } + + async getIssue(issue) { + return (await this.axios.get(`/issues/${issue}`)).data; + } + + async getPR(issue) { + return (await this.axios.get(`/pulls/${issue}`)).data; + } + + async getIssues({state= 'open', labels, sort = 'created', desc = false, per_page = 100, page = 1}) { + return (await this.axios.get(`/issues`, {params: {state, labels, sort, direction: desc ? 'desc' : 'asc', per_page, page}})).data; + } + + async updateIssue(issue, data) { + return (await this.axios.patch(`/issues/${issue}`, data)).data; + } + + async closeIssue(issue) { + return this.updateIssue(issue, { + state: "closed" + }) + } + + async getReleases({per_page = 30, page= 1} = {}) { + return (await this.axios.get(`/releases`, {params: {per_page, page}})).data; + } + + async getRelease(release = 'latest') { + return (await this.axios.get(parseVersion(release) ? `/releases/tags/${release}` : `/releases/${release}`)).data; + } + + async getTags({per_page = 30, page= 1} = {}) { + return (await this.axios.get(`/tags`, {params: {per_page, page}})).data; + } + + async reopenIssue(issue) { + return this.updateIssue(issue, { + state: "open" + }) + } + + static async getTagRef(tag) { + try { + return (await exec(`git show-ref --tags "refs/tags/${tag}"`)).stdout.split(' ')[0]; + } catch (e) { + } + } +} + +const {prototype} = GithubAPI; + +['getUser', 'isCollaborator'].forEach(methodName => { + prototype[methodName] = memoize(prototype[methodName], { promise: true }) +}); + +['get', 'post', 'put', 'delete', 'isAxiosError'].forEach((method) => prototype[method] = function(...args){ + return this.axios[method](...args); +}); + diff --git a/bin/RepoBot.js b/bin/RepoBot.js new file mode 100644 index 00000000..ead0b976 --- /dev/null +++ b/bin/RepoBot.js @@ -0,0 +1,95 @@ +import GithubAPI from "./GithubAPI.js"; +import api from './api.js'; +import Handlebars from "handlebars"; +import fs from "fs/promises"; +import {colorize} from "./helpers/colorize.js"; +import {getReleaseInfo} from "./contributors.js"; + +const normalizeTag = (tag) => tag.replace(/^v/, ''); + +class RepoBot { + constructor(options) { + const { + owner, repo, + templates + } = options || {}; + + this.templates = Object.assign({ + published: '../templates/pr_published.hbs' + }, templates); + + this.github = api || new GithubAPI(owner, repo); + + this.owner = this.github.owner; + this.repo = this.github.repo; + } + + async addComment(targetId, message) { + return this.github.createComment(targetId, message); + } + + async notifyPRPublished(id, tag) { + const pr = await this.github.getPR(id); + + tag = normalizeTag(tag); + + const {merged, labels, user: {login, type}} = pr; + + const isBot = type === 'Bot'; + + if (!merged) { + return false + } + + await this.github.appendLabels(id, ['v' + tag]); + + if (isBot || labels.find(({name}) => name === 'automated pr') || (await this.github.isCollaborator(login))) { + return false; + } + + const author = await this.github.getUser(login); + + author.isBot = isBot; + + const message = await this.constructor.renderTemplate(this.templates.published, { + id, + author, + release: { + tag, + url: `https://github.com/${this.owner}/${this.repo}/releases/tag/v${tag}` + } + }); + + return await this.addComment(id, message); + } + + async notifyPublishedPRs(tag) { + const release = await getReleaseInfo(tag); + + if (!release) { + throw Error(colorize()`Can't get release info for ${tag}`); + } + + const {merges} = release; + + console.log(colorize()`Found ${merges.length} PRs in ${tag}:`); + + let i = 0; + + for (const pr of merges) { + try { + console.log(colorize()`${i++}) Notify PR #${pr.id}`) + const result = await this.notifyPRPublished(pr.id, tag); + console.log(result ? 'OK' : 'Skipped'); + } catch (err) { + console.warn(colorize('green', 'red')` Failed notify PR ${pr.id}: ${err.message}`); + } + } + } + + static async renderTemplate(template, data) { + return Handlebars.compile(String(await fs.readFile(template)))(data); + } +} + +export default RepoBot; diff --git a/bin/actions/notify_published.js b/bin/actions/notify_published.js new file mode 100644 index 00000000..858d1f90 --- /dev/null +++ b/bin/actions/notify_published.js @@ -0,0 +1,22 @@ +import minimist from "minimist"; +import RepoBot from '../RepoBot.js'; + +const argv = minimist(process.argv.slice(2)); +console.log(argv); + +const tag = argv.tag; + +if (!tag) { + throw new Error('tag must be specified'); +} + +const bot = new RepoBot(); + +(async() => { + try { + await bot.notifyPublishedPRs(tag); + } catch (err) { + console.warn('Error:', err.message); + } +})(); + diff --git a/bin/api.js b/bin/api.js new file mode 100644 index 00000000..5d9ab5b5 --- /dev/null +++ b/bin/api.js @@ -0,0 +1,3 @@ +import GithubAPI from "./GithubAPI.js"; + +export default new GithubAPI('axios', 'axios'); diff --git a/bin/contributors.js b/bin/contributors.js index fa88fd3b..03f3217f 100644 --- a/bin/contributors.js +++ b/bin/contributors.js @@ -1,4 +1,4 @@ -import axios from "./githubAPI.js"; +import axios from "./githubAxios.js"; import util from "util"; import cp from "child_process"; import Handlebars from "handlebars"; @@ -7,6 +7,8 @@ import {colorize} from "./helpers/colorize.js"; const exec = util.promisify(cp.exec); +const ONE_MB = 1024 * 1024; + const removeExtraLineBreaks = (str) => str.replace(/(?:\r\n|\r|\n){3,}/gm, '\r\n\r\n'); const cleanTemplate = template => template @@ -108,7 +110,11 @@ const getReleaseInfo = ((releaseCache) => async (tag) => { version ? '--starting-version ' + version + ' --ending-version ' + version : '' } --stdout --commit-limit false --template json`; - const release = JSON.parse((await exec(command)).stdout)[0]; + console.log(command); + + const {stdout} = await exec(command, {maxBuffer: 10 * ONE_MB}); + + const release = JSON.parse(stdout)[0]; if(release) { const authors = {}; @@ -229,6 +235,7 @@ const getTagRef = async (tag) => { export { renderContributorsList, + getReleaseInfo, renderPRsList, getTagRef } diff --git a/bin/githubAPI.js b/bin/githubAxios.js similarity index 100% rename from bin/githubAPI.js rename to bin/githubAxios.js diff --git a/bin/helpers/colorize.js b/bin/helpers/colorize.js index 193a0a49..8fdd087e 100644 --- a/bin/helpers/colorize.js +++ b/bin/helpers/colorize.js @@ -2,7 +2,7 @@ import chalk from 'chalk'; export const colorize = (...colors)=> { if(!colors.length) { - colors = ['green', 'magenta', 'cyan', 'blue', 'yellow', 'red']; + colors = ['green', 'cyan', 'magenta', 'blue', 'yellow', 'red']; } const colorsCount = colors.length; diff --git a/bin/helpers/parser.js b/bin/helpers/parser.js new file mode 100644 index 00000000..84311da3 --- /dev/null +++ b/bin/helpers/parser.js @@ -0,0 +1,12 @@ +export const matchAll = (text, regexp, cb) => { + let match; + while((match = regexp.exec(text))) { + cb(match); + } +} + +export const parseSection = (body, name, cb) => { + matchAll(body, new RegExp(`^(#+)\\s+${name}?(.*?)^\\1\\s+\\w+`, 'gims'), cb); +} + +export const parseVersion = (rawVersion) => /^v?(\d+).(\d+).(\d+)/.exec(rawVersion); diff --git a/gulpfile.js b/gulpfile.js index 682e2cee..2e185819 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -1,6 +1,6 @@ import gulp from 'gulp'; import fs from 'fs-extra'; -import axios from './bin/githubAPI.js'; +import axios from './bin/githubAxios.js'; import minimist from 'minimist' const argv = minimist(process.argv.slice(2)); diff --git a/package-lock.json b/package-lock.json index 1864db94..04f0a4e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "karma-sauce-launcher": "^4.3.6", "karma-sinon": "^1.0.5", "karma-sourcemap-loader": "^0.3.8", + "memoizee": "^0.4.15", "minimist": "^1.2.7", "mocha": "^10.0.0", "multer": "^1.4.4", @@ -9690,6 +9691,16 @@ "node": ">= 0.6" } }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "dev": true, + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -13488,6 +13499,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "dev": true + }, "node_modules/is-reference": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", @@ -14827,6 +14844,15 @@ "node": ">=10" } }, + "node_modules/lru-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", + "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", + "dev": true, + "dependencies": { + "es5-ext": "~0.10.2" + } + }, "node_modules/macos-release": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-3.1.0.tgz", @@ -15084,6 +15110,22 @@ "node": ">= 0.6" } }, + "node_modules/memoizee": { + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.15.tgz", + "integrity": "sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ==", + "dev": true, + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.53", + "es6-weak-map": "^2.0.3", + "event-emitter": "^0.3.5", + "is-promise": "^2.2.2", + "lru-queue": "^0.1.0", + "next-tick": "^1.1.0", + "timers-ext": "^0.1.7" + } + }, "node_modules/memory-fs": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz", @@ -21421,6 +21463,16 @@ "node": ">=0.6.0" } }, + "node_modules/timers-ext": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz", + "integrity": "sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==", + "dev": true, + "dependencies": { + "es5-ext": "~0.10.46", + "next-tick": "1" + } + }, "node_modules/tmp": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", @@ -31624,6 +31676,16 @@ "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", "dev": true }, + "event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, "eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -34615,6 +34677,12 @@ "isobject": "^3.0.1" } }, + "is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "dev": true + }, "is-reference": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", @@ -35705,6 +35773,15 @@ "yallist": "^4.0.0" } }, + "lru-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", + "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", + "dev": true, + "requires": { + "es5-ext": "~0.10.2" + } + }, "macos-release": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-3.1.0.tgz", @@ -35904,6 +35981,22 @@ "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=", "dev": true }, + "memoizee": { + "version": "0.4.15", + "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.15.tgz", + "integrity": "sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ==", + "dev": true, + "requires": { + "d": "^1.0.1", + "es5-ext": "^0.10.53", + "es6-weak-map": "^2.0.3", + "event-emitter": "^0.3.5", + "is-promise": "^2.2.2", + "lru-queue": "^0.1.0", + "next-tick": "^1.1.0", + "timers-ext": "^0.1.7" + } + }, "memory-fs": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz", @@ -40927,6 +41020,16 @@ "setimmediate": "^1.0.4" } }, + "timers-ext": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz", + "integrity": "sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==", + "dev": true, + "requires": { + "es5-ext": "~0.10.46", + "next-tick": "1" + } + }, "tmp": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", diff --git a/package.json b/package.json index c5293dc7..05012202 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "karma-sauce-launcher": "^4.3.6", "karma-sinon": "^1.0.5", "karma-sourcemap-loader": "^0.3.8", + "memoizee": "^0.4.15", "minimist": "^1.2.7", "mocha": "^10.0.0", "multer": "^1.4.4", @@ -214,4 +215,4 @@ "@commitlint/config-conventional" ] } -} \ No newline at end of file +} diff --git a/templates/pr_published.hbs b/templates/pr_published.hbs new file mode 100644 index 00000000..0af9a2c9 --- /dev/null +++ b/templates/pr_published.hbs @@ -0,0 +1 @@ +Hello, @{{ author.login }}! This PR has been published in [{{ release.tag }}]({{ release.url }}) release. Thank you for your contribution ❤️!