├── .dockerignore ├── tests ├── unit │ ├── repositories │ │ └── testGitLabRepository.js │ ├── services │ │ ├── __snapshots__ │ │ │ ├── testLoggerService.test.js.snap │ │ │ ├── testTagService.test.js.snap │ │ │ └── testChangelogService.test.js.snap │ │ ├── testLoggerService.test.js │ │ ├── testChangelogService.test.js │ │ └── testTagService.test.js │ ├── publishers │ │ ├── testPublisherFactory.test.js │ │ ├── __snapshots__ │ │ │ └── testGitLabPublisher.test.js.snap │ │ └── testGitLabPublisher.test.js │ ├── decorators │ │ ├── __snapshots__ │ │ │ ├── testSlackDecorator.test.js.snap │ │ │ ├── testGitLabDecorator.test.js.snap │ │ │ └── testBaseDecorator.test.js.snap │ │ ├── testDecoratorFactory.test.js │ │ ├── testBaseDecorator.test.js │ │ ├── testSlackDecorator.test.js │ │ └── testGitLabDecorator.test.js │ └── adapter │ │ └── testGitlabAdapter.test.js ├── utils.js ├── functional │ ├── __snapshots__ │ │ └── testApp.test.js.snap │ └── testApp.test.js └── fixtures │ ├── tag.js │ ├── project.js │ ├── issue.js │ └── mergeRequest.js ├── app ├── utils.js ├── constants.js ├── publishers │ ├── gitlab.js │ └── index.js ├── decorators │ ├── index.js │ ├── base.js │ ├── slack.js │ └── gitlab.js ├── configureContainer.js ├── services │ ├── logger.js │ ├── changelog.js │ └── tag.js ├── repositories │ └── gitlab.js ├── index.js ├── app.js └── adapters │ └── gitlab.js ├── docker-compose.yml ├── Dockerfile ├── .circleci └── config.yml ├── LICENSE ├── .gitignore ├── .eslintrc.json ├── package.json ├── .sample.gitlab-ci.yml └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | .circleci 2 | .coverage 3 | .git -------------------------------------------------------------------------------- /tests/unit/repositories/testGitLabRepository.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/utils.js: -------------------------------------------------------------------------------- 1 | exports.capitalizeFirstLetter = (string) => { 2 | return string.charAt(0).toUpperCase() + string.slice(1); 3 | }; 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | gitlab-release-note-generator: 4 | build: . 5 | image: gitlab-release-note-generator 6 | environment: 7 | - GITLAB_PERSONAL_TOKEN=sampleGitlabToken 8 | - GITLAB_PROJECT_ID=12345678 9 | -------------------------------------------------------------------------------- /tests/utils.js: -------------------------------------------------------------------------------- 1 | const crypto = require("crypto"); 2 | 3 | exports.sha1 = (data) => { 4 | return crypto.createHash("sha1").update(data, "utf8").digest("hex"); 5 | }; 6 | 7 | exports.commitSha = (data) => { 8 | const sha = exports.sha1(data); 9 | return [sha, sha.substring(sha.length - 8)]; 10 | }; 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16.14.2 2 | 3 | MAINTAINER Jack Zhang 4 | 5 | RUN echo "Running node version: " `node -v` 6 | RUN echo "Running npm version: " `npm -v` 7 | 8 | #Code base 9 | COPY ./ /src 10 | 11 | # Define working directory 12 | WORKDIR /src 13 | 14 | RUN npm ci --production 15 | 16 | CMD ["npm", "start"] 17 | -------------------------------------------------------------------------------- /tests/unit/services/__snapshots__/testLoggerService.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Logger service should log debug 1`] = `"2018-09-07T21:16:17+10:00 [DEBUG] test"`; 4 | 5 | exports[`Logger service should log error 1`] = `"2018-09-07T21:16:17+10:00 [ERROR] test"`; 6 | 7 | exports[`Logger service should log info 1`] = `"2018-09-07T21:16:17+10:00 [INFO] test"`; 8 | 9 | exports[`Logger service should log warn 1`] = `"2018-09-07T21:16:17+10:00 [WARN] test"`; 10 | -------------------------------------------------------------------------------- /app/constants.js: -------------------------------------------------------------------------------- 1 | exports.SERVICE_PROVIDER_GITLAB = "gitlab"; 2 | exports.SERVICE_PROVIDER_SLACK = "slack"; 3 | 4 | exports.LABEL_CONFIG = [ 5 | { name: "breaking change", title: "Notable changes" }, 6 | { name: "enhancement", title: "Enhancements" }, 7 | { name: "feature", title: "New features" }, 8 | { name: "bug", title: "Fixed bugs" } 9 | ]; 10 | 11 | exports.defaultOptions = { 12 | GITLAB_API_ENDPOINT: "https://gitlab.com/api/v4", 13 | NODE_ENV: process.env.NODE_ENV, 14 | TZ: "Australia/Melbourne", 15 | SERVICE_PROVIDER: exports.SERVICE_PROVIDER_GITLAB 16 | }; 17 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | codecov: codecov/codecov@1.0.2 4 | jobs: 5 | test: 6 | docker: 7 | - image: circleci/node:16.13.1 8 | steps: 9 | - checkout 10 | - restore_cache: 11 | key: dependency-cache-{{ checksum "package.json" }} 12 | - run: 13 | name: "Install node dependencies" 14 | command: "npm install" 15 | - save_cache: 16 | key: dependency-cache-{{ checksum "package.json" }} 17 | paths: 18 | - ./node_modules 19 | - run: 20 | name: test 21 | command: npm run test-with-coverage 22 | - codecov/upload: 23 | file: coverage/*.json 24 | workflows: 25 | version: 2.1 26 | workflow: 27 | jobs: 28 | - test 29 | -------------------------------------------------------------------------------- /app/publishers/gitlab.js: -------------------------------------------------------------------------------- 1 | module.exports = class GitLabPublisher { 2 | constructor({ gitlabAdapter, loggerService }) { 3 | this.gitlabAdapter = gitlabAdapter; 4 | this.logger = loggerService; 5 | } 6 | async publish({ projectId, tag, content }) { 7 | if (tag?.release?.description) { 8 | this.logger.debug(`Updating the release note`); 9 | await this.gitlabAdapter.updateTagReleaseByProjectIdTagNameAndTagId(projectId, tag.name, { 10 | description: content 11 | }); 12 | } else { 13 | this.logger.debug(`Creating a new release note`); 14 | await this.gitlabAdapter.createTagReleaseByProjectIdTagNameAndTagId(projectId, tag.name, { 15 | description: content 16 | }); 17 | } 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /app/decorators/index.js: -------------------------------------------------------------------------------- 1 | const Constants = require("../constants"); 2 | const Utils = require("../utils"); 3 | module.exports = class DecoratorFactory { 4 | constructor({ SERVICE_PROVIDER }) { 5 | this.serviceProvider = SERVICE_PROVIDER; 6 | } 7 | create(param) { 8 | let Decorator; 9 | switch (this.serviceProvider) { 10 | case Constants.SERVICE_PROVIDER_GITLAB: 11 | Decorator = require("./gitlab"); 12 | break; 13 | case Constants.SERVICE_PROVIDER_SLACK: 14 | Decorator = require("./slack"); 15 | break; 16 | default: 17 | throw new Error(`${Utils.capitalizeFirstLetter(this.serviceProvider)} decorator is not implemented`); 18 | } 19 | return new Decorator(param); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /app/decorators/base.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | 3 | module.exports = class BaseDecorator { 4 | constructor({ labelConfigs }) { 5 | this.labelConfigs = labelConfigs; 6 | this.labelBucket = { issues: [], mergeRequests: [] }; 7 | for (const labelConfigItem of this.labelConfigs) { 8 | this.labelBucket[labelConfigItem.name] = []; 9 | } 10 | } 11 | 12 | populateContentBucketByContents(contents) { 13 | for (let content of contents) { 14 | let added = false; 15 | for (const label of content.labels || []) { 16 | if (_.has(this.labelBucket, label)) { 17 | this.labelBucket[label].push(content.message); 18 | added = true; 19 | } 20 | } 21 | if (!added) this.labelBucket[content.defaultLabel].push(content.message); 22 | } 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /app/publishers/index.js: -------------------------------------------------------------------------------- 1 | const Constants = require("../constants"); 2 | const Utils = require("../utils"); 3 | module.exports = class PublisherFactory { 4 | constructor({ SERVICE_PROVIDER, gitlabAdapter, loggerService }) { 5 | this.serviceProvider = SERVICE_PROVIDER; 6 | this.gitlabAdapter = gitlabAdapter; 7 | this.logger = loggerService; 8 | } 9 | create() { 10 | let Publisher, 11 | param = { loggerService: this.logger }; 12 | switch (this.serviceProvider) { 13 | case Constants.SERVICE_PROVIDER_GITLAB: 14 | Publisher = require("./gitlab"); 15 | param.gitlabAdapter = this.gitlabAdapter; 16 | break; 17 | default: 18 | throw new Error(`${Utils.capitalizeFirstLetter(this.serviceProvider)} publisher is not implemented`); 19 | } 20 | return new Publisher(param); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /app/configureContainer.js: -------------------------------------------------------------------------------- 1 | const Awilix = require("awilix"); 2 | 3 | const GitlabAdapter = require("./adapters/gitlab"); 4 | const GitlabRepository = require("./repositories/gitlab"); 5 | 6 | const ChangelogService = require("./services/changelog"); 7 | const LoggerService = require("./services/logger"); 8 | const TagService = require("./services/tag"); 9 | 10 | const GitLabReleaseNoteGenerator = require("./app"); 11 | 12 | module.exports = function configureContainer() { 13 | const Container = Awilix.createContainer(); 14 | Container.register({ 15 | gitlabAdapter: Awilix.asClass(GitlabAdapter), 16 | gitlabRepository: Awilix.asClass(GitlabRepository), 17 | changelogService: Awilix.asClass(ChangelogService), 18 | loggerService: Awilix.asClass(LoggerService), 19 | tagService: Awilix.asClass(TagService), 20 | gitLabReleaseNoteGenerator: Awilix.asClass(GitLabReleaseNoteGenerator).singleton() 21 | }); 22 | return Container; 23 | }; 24 | -------------------------------------------------------------------------------- /tests/unit/publishers/testPublisherFactory.test.js: -------------------------------------------------------------------------------- 1 | const PublisherFactory = require("../../../app/publishers"); 2 | const GitLabPublisher = require("../../../app/publishers/gitlab"); 3 | const Constants = require("../../../app/constants"); 4 | 5 | describe("DecoratorFactory", () => { 6 | describe("#create", () => { 7 | test("should create gitlab Publisher", () => { 8 | const publisher = new PublisherFactory({ 9 | SERVICE_PROVIDER: Constants.SERVICE_PROVIDER_GITLAB, 10 | gitlabAdapter: {}, 11 | loggerService: {} 12 | }).create({}); 13 | expect(publisher).toBeInstanceOf(GitLabPublisher); 14 | }); 15 | test("should throw error when service type is not supported", () => { 16 | let err; 17 | try { 18 | new PublisherFactory({ SERVICE_PROVIDER: "unknown" }).create({}); 19 | } catch (e) { 20 | err = e; 21 | } 22 | expect(err?.message).toBe("Unknown publisher is not implemented"); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /tests/unit/decorators/__snapshots__/testSlackDecorator.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Slack Decorator #decorateIssue should format issue 1`] = `"- Consequatur vero maxime deserunt laboriosam est voluptas dolorem. "`; 4 | 5 | exports[`Slack Decorator #decorateMergeRequest should format merge request 1`] = `"- test1 ()"`; 6 | 7 | exports[`Slack Decorator #generateContent should generate release note content 1`] = ` 8 | "*Release note (2022-05-08)* 9 | *Closed issues* 10 | - Mock Issue 1 *Merged merge requests* 11 | - Mock merge request 2 ()*Features* 12 | - Mock merge request 1 ()*Bug Fixes* 13 | - mock issue 2 " 14 | `; 15 | -------------------------------------------------------------------------------- /app/services/logger.js: -------------------------------------------------------------------------------- 1 | const Chalk = require("chalk"); 2 | const Moment = require("moment-timezone"); 3 | 4 | module.exports = class LoggerService { 5 | constructor({ config }) { 6 | this.config = config; 7 | this.console = console; 8 | if (this.config.NODE_ENV === "test") this.console.log = () => {}; 9 | } 10 | 11 | debug(message) { 12 | // eslint-disable-next-line no-console 13 | this.console.log(Chalk.whiteBright(`${Moment.tz(this.config.TZ).format()} [DEBUG] ${message}`)); 14 | } 15 | info(message) { 16 | // eslint-disable-next-line no-console 17 | this.console.log(Chalk.greenBright(`${Moment.tz(this.config.TZ).format()} [INFO] ${message}`)); 18 | } 19 | warn(message) { 20 | // eslint-disable-next-line no-console 21 | this.console.log(Chalk.yellowBright(`${Moment.tz(this.config.TZ).format()} [WARN] ${message}`)); 22 | } 23 | error(message) { 24 | // eslint-disable-next-line no-console 25 | this.console.log(Chalk.redBright(`${Moment.tz(this.config.TZ).format()} [ERROR] ${message}`)); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jack Zhang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/unit/decorators/__snapshots__/testGitLabDecorator.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Gitlab Decorator #decorateIssue should format issue 1`] = `"- Consequatur vero maxime deserunt laboriosam est voluptas dolorem. [#6](http://example.com/example/example/issues/6)"`; 4 | 5 | exports[`Gitlab Decorator #decorateMergeRequest should format merge request 1`] = `"- test1 [#1](http://gitlab.example.com/my-group/my-project/merge_requests/1) ([admin](https://gitlab.example.com/admin))"`; 6 | 7 | exports[`Gitlab Decorator #generateContent should generate release note content 1`] = ` 8 | "### Release note (2022-05-08) 9 | #### Closed issues 10 | - Mock Issue 1 [#5](http://example.com/example/example/issues/5) 11 | #### Merged merge requests 12 | - Mock merge request 2 [#2](http://gitlab.example.com/my-group/my-project/merge_requests/1) ([admin](https://gitlab.example.com/admin)) 13 | #### Features 14 | - Mock merge request 1 [#1](http://gitlab.example.com/my-group/my-project/merge_requests/1) ([admin](https://gitlab.example.com/admin)) 15 | #### Bug Fixes 16 | - mock issue 2 [#6](http://example.com/example/example/issues/6) 17 | " 18 | `; 19 | -------------------------------------------------------------------------------- /tests/unit/decorators/__snapshots__/testBaseDecorator.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Base decorator #populateContentBucketByContents should populate content buckets with custom label configs 1`] = ` 4 | Object { 5 | "features": Array [ 6 | "mock feature message", 7 | ], 8 | "fixes": Array [ 9 | "mock fixes message", 10 | ], 11 | "issues": Array [], 12 | "mergeRequests": Array [], 13 | } 14 | `; 15 | 16 | exports[`Base decorator #populateContentBucketByContents should populate content buckets with default label configs 1`] = ` 17 | Object { 18 | "issues": Array [ 19 | "mock issue message", 20 | ], 21 | "mergeRequests": Array [ 22 | "mock MR message", 23 | ], 24 | } 25 | `; 26 | 27 | exports[`Base decorator #populateContentBucketByContents should populate content buckets with mixed label configs 1`] = ` 28 | Object { 29 | "features": Array [ 30 | "mock feature message", 31 | ], 32 | "fixes": Array [ 33 | "mock fixes message", 34 | ], 35 | "issues": Array [ 36 | "mock normal issue message", 37 | ], 38 | "mergeRequests": Array [ 39 | "mock normal MR message", 40 | ], 41 | } 42 | `; 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | .idea 64 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "plugin:node/recommended"], 3 | "plugins": ["prettier", "jest"], 4 | "parserOptions": { 5 | "ecmaVersion": 2021 6 | }, 7 | "env": { 8 | "jest/globals": true 9 | }, 10 | "rules": { 11 | "jest/no-disabled-tests": "warn", 12 | "jest/no-focused-tests": "error", 13 | "jest/no-identical-title": "error", 14 | "jest/prefer-to-have-length": "warn", 15 | "jest/valid-expect": "error", 16 | "no-fallthrough": "warn", 17 | "no-process-exit": "off", 18 | "no-console": "warn", 19 | "no-unused-vars": "warn", 20 | "node/no-unpublished-require": "off", 21 | "no-useless-escape": "warn", 22 | "no-empty": "off", 23 | "no-unused-labels": "off", 24 | "no-undef": "error", 25 | "prettier/prettier": [ 26 | "error", 27 | { 28 | "arrowParens": "always", 29 | "bracketSpacing": true, 30 | "printWidth": 120, 31 | "semi": true, 32 | "singleQuote": false, 33 | "tabWidth": 4, 34 | "trailingComma": "none" 35 | } 36 | ] 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gitlab-release-note-generator", 3 | "version": "1.2.5", 4 | "description": "", 5 | "main": "index.js", 6 | "engines": { 7 | "node": "16.14.2" 8 | }, 9 | "scripts": { 10 | "start": "node app/index.js", 11 | "test": "jest tests/", 12 | "test-with-coverage": "jest tests/ --collectCoverage=true", 13 | "lint": "eslint app/**/*" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/jk1z/GitLab-Release-Note-Generator.git" 18 | }, 19 | "author": "Jack Zhang", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/jk1z/GitLab-Release-Note-Generator/issues" 23 | }, 24 | "homepage": "https://github.com/jk1z/GitLab-Release-Note-Generator#readme", 25 | "dependencies": { 26 | "awilix": "7.0.1", 27 | "chalk": "4.1.2", 28 | "got": "11.8.3", 29 | "lodash": "4.17.21", 30 | "moment-timezone": "0.5.34", 31 | "parse-link-header": "2.0.0", 32 | "yargs": "17.4.1" 33 | }, 34 | "devDependencies": { 35 | "eslint": "^8.12.0", 36 | "eslint-plugin-jest": "^26.1.5", 37 | "eslint-plugin-node": "11.1.0", 38 | "eslint-plugin-prettier": "4.0.0", 39 | "faker": "5.5.3", 40 | "jest": "27.5.1", 41 | "jest-when": "3.5.1", 42 | "mockdate": "3.0.5", 43 | "nock": "13.2.4", 44 | "prettier": "2.6.1" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/unit/publishers/__snapshots__/testGitLabPublisher.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`GitLab publisher #publish should create release note if release note is missing 1`] = `"Creating a new release note"`; 4 | 5 | exports[`GitLab publisher #publish should create release note if release note is missing 2`] = ` 6 | Array [ 7 | "123", 8 | "v1.1.0", 9 | Object { 10 | "description": "### Release note (2012-05-27) 11 | #### Closed issues 12 | - Consequatur vero maxime deserunt laboriosam est voluptas dolorem. [#6](http://example.com/example/example/issues/6) 13 | #### Merged merge requests 14 | - test1 [#1](http://gitlab.example.com/my-group/my-project/merge_requests/1) ([admin](https://gitlab.example.com/admin)) 15 | ", 16 | }, 17 | ] 18 | `; 19 | 20 | exports[`GitLab publisher #publish should update release note if release note exists 1`] = `"Updating the release note"`; 21 | 22 | exports[`GitLab publisher #publish should update release note if release note exists 2`] = ` 23 | Array [ 24 | "123", 25 | "v1.1.0", 26 | Object { 27 | "description": "### Release note (2012-05-27) 28 | #### Closed issues 29 | - Consequatur vero maxime deserunt laboriosam est voluptas dolorem. [#6](http://example.com/example/example/issues/6) 30 | #### Merged merge requests 31 | - test1 [#1](http://gitlab.example.com/my-group/my-project/merge_requests/1) ([admin](https://gitlab.example.com/admin)) 32 | ", 33 | }, 34 | ] 35 | `; 36 | -------------------------------------------------------------------------------- /tests/functional/__snapshots__/testApp.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Gitlab release note generator #run Sunny scenario publisher should publish release note 1`] = ` 4 | Object { 5 | "content": "### Release note (2012-05-27) 6 | #### Closed issues 7 | - Consequatur vero maxime deserunt laboriosam est voluptas dolorem. [#6](http://example.com/example/example/issues/6) 8 | #### Merged merge requests 9 | - test1 [#1](http://gitlab.example.com/my-group/my-project/merge_requests/1) ([admin](https://gitlab.example.com/admin)) 10 | ", 11 | "projectId": "12345678", 12 | "tag": Object { 13 | "commit": Object { 14 | "author_email": "john@example.com", 15 | "author_name": "John Smith", 16 | "authored_date": "2012-05-27T04:42:42Z", 17 | "committed_date": "2012-05-27T04:42:42Z", 18 | "committer_email": "jack@example.com", 19 | "committer_name": "Jack Smith", 20 | "created_at": "2012-05-27T04:42:42Z", 21 | "id": "ec0b4f0b5c90ed0fa911a2972ccc452641b31563", 22 | "message": "Initial commit", 23 | "parent_ids": Array [ 24 | "2a4b78934375d7f53875269ffd4f45fd83a84ebe", 25 | ], 26 | "short_id": "41b31563", 27 | "title": "Initial commit", 28 | }, 29 | "message": null, 30 | "name": "v1.1.0", 31 | "release": Object { 32 | "description": "Amazing release. Wow", 33 | "tag_name": "1.1.0", 34 | }, 35 | "target": "ec0b4f0b5c90ed0fa911a2972ccc452641b31563", 36 | }, 37 | } 38 | `; 39 | -------------------------------------------------------------------------------- /tests/unit/services/testLoggerService.test.js: -------------------------------------------------------------------------------- 1 | const MockDate = require("mockdate"); 2 | const LoggerService = require("../../../app/services/logger"); 3 | describe("Logger service", () => { 4 | let loggerService; 5 | beforeEach(() => { 6 | MockDate.set("2018-09-07T11:16:17.520Z"); 7 | jest.resetAllMocks(); 8 | loggerService = new LoggerService({ config: { NODE_ENV: "local", TZ: "Australia/Melbourne" } }); 9 | loggerService.console.log = jest.fn(); 10 | }); 11 | 12 | afterEach(() => { 13 | MockDate.reset(); 14 | }); 15 | 16 | test("should log info", () => { 17 | loggerService.info(`test`); 18 | expect(loggerService.console.log).toBeCalledTimes(1); 19 | expect(loggerService.console.log.mock.calls[0][0]).toMatchSnapshot(); 20 | }); 21 | test("should log warn", () => { 22 | loggerService.warn(`test`); 23 | expect(loggerService.console.log).toBeCalledTimes(1); 24 | expect(loggerService.console.log.mock.calls[0][0]).toMatchSnapshot(); 25 | }); 26 | test("should log debug", () => { 27 | loggerService.debug(`test`); 28 | expect(loggerService.console.log).toBeCalledTimes(1); 29 | expect(loggerService.console.log.mock.calls[0][0]).toMatchSnapshot(); 30 | }); 31 | test("should log error", () => { 32 | loggerService.error(`test`); 33 | expect(loggerService.console.log).toBeCalledTimes(1); 34 | expect(loggerService.console.log.mock.calls[0][0]).toMatchSnapshot(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /.sample.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - deploy 3 | - post_deploy 4 | 5 | tag-after-deployment: 6 | only: 7 | refs: 8 | - master 9 | - develop 10 | variables: 11 | - $CI_COMMIT_TITLE =~ /^[0-9]+.[0-9]+.[0-9]+(-[0-9]+)?$/ 12 | except: 13 | refs: 14 | - tags 15 | stage: deploy 16 | image: debian:stable 17 | before_script: 18 | - apt-get clean -qq 19 | - apt-get update -qq --fix-missing 20 | - apt-get install -qq --fix-missing jq git 21 | - 'which ssh-agent || ( apt-get update -y && apt-get install openssh-client -y )' 22 | - eval $(ssh-agent -s) 23 | - echo "${SSH_PRIVATE_KEY}" | tr -d '\r' | ssh-add - > /dev/null 24 | - mkdir -p ~/.ssh 25 | - chmod 700 ~/.ssh 26 | - ssh-keyscan gitlab.com >> ~/.ssh/known_hosts 27 | - chmod 644 ~/.ssh/known_hosts 28 | - export VERSION=$(cat package.json | jq '.version' | sed "s/\"//g") 29 | - git config --global user.name "${GITLAB_USER_NAME}" 30 | - git config --global user.email "${GITLAB_USER_EMAIL}" 31 | - git remote set-url origin "git@gitlab.com:jackzhang/generator-test.git" 32 | - git remote -v 33 | script: 34 | - git tag -a "v${VERSION}" ${CI_COMMIT_SHA} -m "${VERSION}" 35 | - git push origin --tags 36 | 37 | generate-release-note: 38 | only: 39 | refs: 40 | - tags 41 | variables: 42 | - $CI_COMMIT_TITLE =~ /^[0-9]+.[0-9]+.[0-9]+(-[0-9]+)?$/ 43 | stage: post_deploy 44 | image: docker:stable 45 | services: 46 | - docker:dind 47 | script: 48 | - docker container run -e GITLAB_PERSONAL_TOKEN=${GITLAB_ACCESS_TOKEN} -e GITLAB_PROJECT_ID=${CI_PROJECT_ID} 00freezy00/gitlab-release-note-generator -------------------------------------------------------------------------------- /app/services/changelog.js: -------------------------------------------------------------------------------- 1 | const Moment = require("moment-timezone"); 2 | 3 | module.exports = class ChangelogService { 4 | constructor({ gitlabRepository, loggerService, config }) { 5 | this.gitlabRepository = gitlabRepository; 6 | this.logger = loggerService; 7 | this.config = config; 8 | } 9 | 10 | async getChangelogByStartAndEndDate(startDate, endDate) { 11 | this.logger.info( 12 | `Time range that we are looking at MRs and issues is between ${Moment.tz( 13 | startDate, 14 | this.config.TZ 15 | )} and ${Moment.tz(endDate, this.config.TZ)}` 16 | ); 17 | let mergeRequests = await this.gitlabRepository.findMergeRequestsByProjectIdStateStartDateAndEndDate( 18 | this.config.GITLAB_PROJECT_ID, 19 | "merged", 20 | startDate, 21 | endDate 22 | ); 23 | mergeRequests = mergeRequests.filter((mergeRequest) => 24 | Moment.tz(mergeRequest.merged_at, this.config.TZ).isBetween(startDate, endDate, "second", "[]") 25 | ); 26 | this.logger.info(`Found ${mergeRequests ? mergeRequests.length : 0} merge requests`); 27 | let issues = await this.gitlabRepository.findIssuesByProjectIdStateStartDateAndEndDate( 28 | this.config.GITLAB_PROJECT_ID, 29 | "closed", 30 | startDate, 31 | endDate 32 | ); 33 | issues = issues.filter((issue) => 34 | Moment.tz(issue.closed_at, this.config.TZ).isBetween(startDate, endDate, "second", "[]") 35 | ); 36 | this.logger.info(`Found ${issues ? issues.length : 0} issues`); 37 | return { 38 | mergeRequests, 39 | issues 40 | }; 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /app/decorators/slack.js: -------------------------------------------------------------------------------- 1 | const Moment = require("moment-timezone"); 2 | const _ = require("lodash"); 3 | const BaseDecorator = require("./base"); 4 | 5 | module.exports = class SlackDecorator extends BaseDecorator { 6 | constructor({ changelog, labelConfigs, tz }) { 7 | super({ labelConfigs }); 8 | this.changelog = changelog; 9 | this.tz = tz; 10 | } 11 | generateContent() { 12 | let { issues, mergeRequests } = this.changelog; 13 | issues = issues.map((issue) => { 14 | return { message: this.decorateIssue(issue), labels: [...issue.labels], defaultLabel: "issues" }; 15 | }); 16 | mergeRequests = mergeRequests.map((mergeRequest) => { 17 | return { 18 | message: this.decorateMergeRequest(mergeRequest), 19 | labels: [...mergeRequest.labels], 20 | defaultLabel: "mergeRequests" 21 | }; 22 | }); 23 | this.populateContentBucketByContents([...issues, ...mergeRequests]); 24 | let output = `*Release note (${Moment.tz(this.changelog.releaseDate, this.tz).format("YYYY-MM-DD")})*\n`; 25 | for (const labelConfig of this.labelConfigs) { 26 | if (this.labelBucket[labelConfig.name]) { 27 | output += `*${labelConfig.title}*\n`; 28 | output += this.labelBucket[labelConfig.name].join("\n"); 29 | } 30 | } 31 | return output; 32 | } 33 | 34 | decorateIssue(issue) { 35 | return `- ${issue.title} <${issue.web_url}|#${issue.iid}>`; 36 | } 37 | decorateMergeRequest(mergeRequest) { 38 | return `- ${mergeRequest.title} <${mergeRequest.web_url}|#${mergeRequest.iid}> (<${_.get( 39 | mergeRequest, 40 | "author.web_url" 41 | )}|${_.get(mergeRequest, "author.username")}>)`; 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /app/repositories/gitlab.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | module.exports = class GitlabRepository { 3 | constructor({ gitlabAdapter }) { 4 | this.gitlabAdapter = gitlabAdapter; 5 | } 6 | // MR 7 | async findMergeRequestsByProjectIdStateStartDateAndEndDate(projectId, state, startDate, endDate) { 8 | let { mergeRequests, _link } = await this.gitlabAdapter.searchMergeRequestsByProjectId(projectId, { 9 | state, 10 | updated_before: endDate, 11 | updated_after: startDate 12 | }); 13 | while (_.get(_link, "next")) { 14 | const res = await _link.next(); 15 | mergeRequests.push(...res.mergeRequests); 16 | _link = res._link; 17 | } 18 | return mergeRequests; 19 | } 20 | 21 | // Issues 22 | async findIssuesByProjectIdStateStartDateAndEndDate(projectId, state, startDate, endDate) { 23 | let { issues, _link } = await this.gitlabAdapter.searchIssuesByProjectId(projectId, { 24 | state, 25 | updated_before: endDate, 26 | updated_after: startDate 27 | }); 28 | while (_.get(_link, "next")) { 29 | const res = await _link.next(); 30 | issues.push(...res.issues); 31 | _link = res._link; 32 | } 33 | return issues; 34 | } 35 | 36 | // Commit 37 | async findBranchRefsByProjectIdAndSha(projectId, sha) { 38 | return this.gitlabAdapter.findCommitRefsByProjectIdAndSha(projectId, sha, { type: "branch" }); 39 | } 40 | 41 | // Tags 42 | async findTagsByProjectId(projectId) { 43 | return this.gitlabAdapter.searchTagsByProjectId(projectId); 44 | } 45 | 46 | // Repo 47 | async findRepoByProjectId(projectId) { 48 | return this.gitlabAdapter.getRepoByProjectId(projectId); 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /app/decorators/gitlab.js: -------------------------------------------------------------------------------- 1 | const Moment = require("moment-timezone"); 2 | const _ = require("lodash"); 3 | 4 | const BaseDecorator = require("./base"); 5 | 6 | module.exports = class GitlabDecorator extends BaseDecorator { 7 | constructor({ changelog, labelConfigs, tz }) { 8 | super({ labelConfigs }); 9 | this.changelog = changelog; 10 | this.tz = tz; 11 | } 12 | generateContent() { 13 | let { issues, mergeRequests } = this.changelog; 14 | issues = issues.map((issue) => { 15 | return { message: this.decorateIssue(issue), labels: [...issue.labels], defaultLabel: "issues" }; 16 | }); 17 | mergeRequests = mergeRequests.map((mergeRequest) => { 18 | return { 19 | message: this.decorateMergeRequest(mergeRequest), 20 | labels: [...mergeRequest.labels], 21 | defaultLabel: "mergeRequests" 22 | }; 23 | }); 24 | this.populateContentBucketByContents([...issues, ...mergeRequests]); 25 | let output = `### Release note (${Moment.tz(this.changelog.releaseDate, this.tz).format("YYYY-MM-DD")})\n`; 26 | for (const labelConfig of this.labelConfigs) { 27 | if (this.labelBucket[labelConfig.name]) { 28 | if (!_.isEmpty(this.labelBucket[labelConfig.name]) || labelConfig.default) { 29 | output += `#### ${labelConfig.title}\n`; 30 | if (!_.isEmpty(this.labelBucket[labelConfig.name])) { 31 | output += this.labelBucket[labelConfig.name].join("\n") + "\n"; 32 | } 33 | } 34 | } 35 | } 36 | return output; 37 | } 38 | decorateIssue(issue) { 39 | return `- ${issue.title} [#${issue.iid}](${issue.web_url})`; 40 | } 41 | decorateMergeRequest(mergeRequest) { 42 | return `- ${mergeRequest.title} [#${mergeRequest.iid}](${mergeRequest.web_url}) ([${_.get( 43 | mergeRequest, 44 | "author.username" 45 | )}](${_.get(mergeRequest, "author.web_url")}))`; 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /tests/unit/decorators/testDecoratorFactory.test.js: -------------------------------------------------------------------------------- 1 | const DecoratorFactory = require("../../../app/decorators"); 2 | const GitLabDecorator = require("../../../app/decorators/gitlab"); 3 | const SlackDecorator = require("../../../app/decorators/slack"); 4 | const Constants = require("../../../app/constants"); 5 | 6 | describe("DecoratorFactory", () => { 7 | describe("#create", () => { 8 | test("should create gitlab decorator", () => { 9 | const decorator = new DecoratorFactory({ SERVICE_PROVIDER: Constants.SERVICE_PROVIDER_GITLAB }).create({ 10 | changelog: { 11 | issues: [], 12 | mergeRequests: [] 13 | }, 14 | labelConfigs: [ 15 | { name: "issues", title: "Closed issues", default: true }, 16 | { name: "mergeRequests", title: "Merged merge requests", default: true } 17 | ], 18 | tz: "Australia/Melbourne" 19 | }); 20 | expect(decorator).toBeInstanceOf(GitLabDecorator); 21 | }); 22 | test("should create slack decorator", () => { 23 | const decorator = new DecoratorFactory({ SERVICE_PROVIDER: Constants.SERVICE_PROVIDER_SLACK }).create({ 24 | changelog: { 25 | issues: [], 26 | mergeRequests: [] 27 | }, 28 | labelConfigs: [ 29 | { name: "issues", title: "Closed issues", default: true }, 30 | { name: "mergeRequests", title: "Merged merge requests", default: true } 31 | ], 32 | tz: "Australia/Melbourne" 33 | }); 34 | expect(decorator).toBeInstanceOf(SlackDecorator); 35 | }); 36 | test("should throw error when service type is not supported", () => { 37 | let err; 38 | try { 39 | new DecoratorFactory({ SERVICE_PROVIDER: "unknown" }).create({ 40 | changelog: { 41 | issues: [], 42 | mergeRequests: [] 43 | }, 44 | labelConfigs: [ 45 | { name: "issues", title: "Closed issues", default: true }, 46 | { name: "mergeRequests", title: "Merged merge requests", default: true } 47 | ], 48 | tz: "Australia/Melbourne" 49 | }); 50 | } catch (e) { 51 | err = e; 52 | } 53 | expect(err?.message).toBe("Unknown decorator is not implemented"); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /app/index.js: -------------------------------------------------------------------------------- 1 | const Yargs = require("yargs/yargs"); 2 | const { hideBin } = require("yargs/helpers"); 3 | const { asValue } = require("awilix"); 4 | 5 | const Constants = require("./constants"); 6 | 7 | const ConfigureContainer = require("./configureContainer"); 8 | const container = ConfigureContainer(); 9 | 10 | (async () => { 11 | try { 12 | let env = null; 13 | if (process.env.GITLAB_API_ENDPOINT && process.env.GITLAB_PROJECT_ID) { 14 | // eslint-disable-next-line no-console 15 | console.log(`Detected environment variable. Skipping CLI command.`); 16 | env = process.env; 17 | } else { 18 | const { argv } = Yargs(hideBin(process.argv)) 19 | .usage( 20 | "Usage: $0 -GITLAB_API_ENDPOINT [string] " + 21 | "-GITLAB_PERSONAL_TOKEN [string] -GITLAB_PROJECT_ID [string] " + 22 | "-TARGET_BRANCH [regex string] -TARGET_TAG_REGEX [regex string] " + 23 | "-SERVICE_PROVIDER [string] " + 24 | "-ISSUE_CLOSED_SECONDS [num] " + 25 | "-TZ [string] -NODE_ENV [string]" 26 | ) 27 | .demandOption(["GITLAB_PERSONAL_TOKEN", "GITLAB_PROJECT_ID"]); 28 | env = argv; 29 | } 30 | 31 | if (!env.GITLAB_PROJECT_ID) throw new Error("GitLab project id is required"); 32 | if (!env.GITLAB_PERSONAL_TOKEN) throw new Error("Gitlab personal token is required"); 33 | const config = { ...Constants.defaultOptions }; 34 | if (env.GITLAB_API_ENDPOINT) config.GITLAB_API_ENDPOINT = env.GITLAB_API_ENDPOINT; 35 | if (env.GITLAB_PERSONAL_TOKEN) config.GITLAB_PERSONAL_TOKEN = env.GITLAB_PERSONAL_TOKEN; 36 | if (env.GITLAB_PROJECT_ID) config.GITLAB_PROJECT_ID = env.GITLAB_PROJECT_ID; 37 | if (env.TARGET_BRANCH) config.TARGET_BRANCH = env.TARGET_BRANCH; 38 | if (env.TARGET_TAG_REGEX) config.TARGET_TAG_REGEX = env.TARGET_TAG_REGEX; 39 | if (env.ISSUE_CLOSED_SECONDS) config.ISSUE_CLOSED_SECONDS = env.ISSUE_CLOSED_SECONDS; 40 | if (env.SERVICE_PROVIDER) config.SERVICE_PROVIDER = env.SERVICE_PROVIDER; 41 | if (env.TZ) config.TZ = env.TZ; 42 | if (env.NODE_ENV) config.NODE_ENV = env.NODE_ENV; 43 | container.register({ config: asValue(config) }); 44 | const GitLabReleaseNoteGenerator = container.resolve("gitLabReleaseNoteGenerator"); 45 | await GitLabReleaseNoteGenerator.run(); 46 | } catch (err) { 47 | // eslint-disable-next-line no-console 48 | console.error(`Fatal error: ${JSON.stringify(err, Object.getOwnPropertyNames(err))}`); 49 | } 50 | })(); 51 | -------------------------------------------------------------------------------- /tests/unit/decorators/testBaseDecorator.test.js: -------------------------------------------------------------------------------- 1 | const BaseDecorator = require("../../../app/decorators/base"); 2 | describe("Base decorator", () => { 3 | describe("#populateContentBucketByContents", () => { 4 | test("should populate content buckets with default label configs", () => { 5 | const baseDecorator = new BaseDecorator({ 6 | labelConfigs: [ 7 | { name: "issues", title: "Closed issues", default: true }, 8 | { name: "mergeRequests", title: "Merged merge requests", default: true } 9 | ] 10 | }); 11 | baseDecorator.populateContentBucketByContents([ 12 | { message: "mock issue message", labels: [], defaultLabel: "issues" }, 13 | { message: "mock MR message", labels: [], defaultLabel: "mergeRequests" } 14 | ]); 15 | expect(baseDecorator.labelBucket).toMatchSnapshot(); 16 | }); 17 | test("should populate content buckets with custom label configs", () => { 18 | const baseDecorator = new BaseDecorator({ 19 | labelConfigs: [ 20 | { name: "issues", title: "Closed issues", default: true }, 21 | { name: "mergeRequests", title: "Merged merge requests", default: true }, 22 | { name: "features", title: "Features" }, 23 | { name: "fixes", title: "Bug Fixes" } 24 | ] 25 | }); 26 | baseDecorator.populateContentBucketByContents([ 27 | { message: "mock feature message", labels: ["features"], defaultLabel: "issues" }, 28 | { message: "mock fixes message", labels: ["fixes"], defaultLabel: "mergeRequests" } 29 | ]); 30 | expect(baseDecorator.labelBucket).toMatchSnapshot(); 31 | }); 32 | test("should populate content buckets with mixed label configs", () => { 33 | const baseDecorator = new BaseDecorator({ 34 | labelConfigs: [ 35 | { name: "issues", title: "Closed issues", default: true }, 36 | { name: "mergeRequests", title: "Merged merge requests", default: true }, 37 | { name: "features", title: "Features" }, 38 | { name: "fixes", title: "Bug Fixes" } 39 | ] 40 | }); 41 | baseDecorator.populateContentBucketByContents([ 42 | { message: "mock normal MR message", labels: [], defaultLabel: "mergeRequests" }, 43 | { message: "mock normal issue message", labels: [], defaultLabel: "issues" }, 44 | { message: "mock feature message", labels: ["features"], defaultLabel: "issues" }, 45 | { message: "mock fixes message", labels: ["fixes"], defaultLabel: "mergeRequests" } 46 | ]); 47 | expect(baseDecorator.labelBucket).toMatchSnapshot(); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /app/app.js: -------------------------------------------------------------------------------- 1 | const Moment = require("moment-timezone"); 2 | const Constants = require("./constants"); 3 | 4 | module.exports = class GitLabReleaseNoteGenerator { 5 | constructor({ config, tagService, changelogService, loggerService, gitlabAdapter }) { 6 | this.config = config; 7 | this.tagService = tagService; 8 | this.changelogService = changelogService; 9 | this.logger = loggerService; 10 | this.gitlabAdapter = gitlabAdapter; 11 | } 12 | async run() { 13 | const tags = await this.tagService.getLatestAndSecondLatestTag(); 14 | if (tags.length !== 2) { 15 | throw new Error("Cannot find latest and second latest tag. Tag Result: " + JSON.stringify(tags)); 16 | } 17 | const [latestTag, secondLatestTag] = tags; 18 | if (!latestTag?.commit?.committed_date || !secondLatestTag?.commit?.committed_date) { 19 | throw new Error(`Cannot find latest and second latest tag. Abort the program!`); 20 | } 21 | const startDate = secondLatestTag.commit.committed_date; 22 | let endDate = latestTag.commit.committed_date; 23 | 24 | // allow the end date to be adjusted by a few seconds to catch issues that are automatically closed by 25 | // a MR and are time stamped a few seconds later. 26 | if (this.config.ISSUE_CLOSED_SECONDS > 0) { 27 | this.logger.debug(`EndDate: ${endDate}`); 28 | this.logger.debug(`Adding Seconds: ${this.config.ISSUE_CLOSED_SECONDS}`); 29 | endDate = Moment.tz(endDate, this.config.TZ).add(this.config.ISSUE_CLOSED_SECONDS, "seconds").toISOString(); 30 | this.logger.debug(`New End Date: ${endDate}`); 31 | } 32 | const { mergeRequests, issues } = await this.changelogService.getChangelogByStartAndEndDate(startDate, endDate); 33 | 34 | const labelConfigs = [ 35 | ...Constants.LABEL_CONFIG, 36 | { name: "issues", title: "Closed issues", default: true }, 37 | { name: "mergeRequests", title: "Merged merge requests", default: true } 38 | ]; 39 | const DecoratorFactory = require("./decorators"); 40 | const decorator = new DecoratorFactory({ 41 | SERVICE_PROVIDER: this.config.SERVICE_PROVIDER, 42 | loggerService: this.logger 43 | }).create({ 44 | changelog: { mergeRequests, issues, releaseDate: endDate }, 45 | labelConfigs, 46 | tz: this.config.TZ 47 | }); 48 | const content = decorator.generateContent(); 49 | this.logger.debug(`(${this.config.SERVICE_PROVIDER} format) Changelog: ${content}`); 50 | 51 | const PublisherFactory = require("./publishers"); 52 | const publisher = new PublisherFactory({ 53 | SERVICE_PROVIDER: this.config.SERVICE_PROVIDER, 54 | gitlabAdapter: this.gitlabAdapter, 55 | loggerService: this.logger 56 | }).create(); 57 | await publisher.publish({ 58 | projectId: this.config.GITLAB_PROJECT_ID, 59 | tag: latestTag, 60 | content 61 | }); 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /tests/unit/adapter/testGitlabAdapter.test.js: -------------------------------------------------------------------------------- 1 | const GitlabAdapter = require("../../../app/adapters/gitlab"); 2 | 3 | describe("Gitlab adapter", () => { 4 | describe("#_decorateLinks", () => { 5 | let linkObj; 6 | let gitlabAdapter; 7 | beforeAll(() => { 8 | jest.resetAllMocks(); 9 | gitlabAdapter = new GitlabAdapter({ 10 | config: { 11 | GITLAB_API_ENDPOINT: "https://gitlab.com/api/v4", 12 | GITLAB_PROJECT_ID: "12345678" 13 | } 14 | }); 15 | gitlabAdapter.searchTagsByProjectId = jest.fn(); 16 | gitlabAdapter.searchTagsByProjectId.mockResolvedValue({}); 17 | linkObj = gitlabAdapter._decorateLinks( 18 | '; rel="prev", ; rel="next", ; rel="first"', 19 | gitlabAdapter.searchTagsByProjectId, 20 | ["mockProjectId"], 21 | { mockKey: "mockValue" } 22 | ); 23 | }); 24 | 25 | test("should decorate links if link contains first, last, next and prev", async () => { 26 | expect(typeof linkObj.first).toBe("function"); 27 | expect(typeof linkObj.next).toBe("function"); 28 | expect(typeof linkObj.prev).toBe("function"); 29 | await linkObj.first(); 30 | await linkObj.next(); 31 | await linkObj.prev(); 32 | expect(gitlabAdapter.searchTagsByProjectId.mock.calls[0]).toEqual([ 33 | "mockProjectId", 34 | { mockKey: "mockValue", page: "1", per_page: "20" } 35 | ]); 36 | expect(gitlabAdapter.searchTagsByProjectId.mock.calls[1]).toEqual([ 37 | "mockProjectId", 38 | { mockKey: "mockValue", page: "3", per_page: "20" } 39 | ]); 40 | expect(gitlabAdapter.searchTagsByProjectId.mock.calls[2]).toEqual([ 41 | "mockProjectId", 42 | { mockKey: "mockValue", page: "1", per_page: "20" } 43 | ]); 44 | }); 45 | test("should not decorate links if link is empty", () => { 46 | const linkObj = gitlabAdapter._decorateLinks(undefined, null, ["mockProjectId"], { mockKey: "mockValue" }); 47 | expect(linkObj).toStrictEqual({}); 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /tests/unit/publishers/testGitLabPublisher.test.js: -------------------------------------------------------------------------------- 1 | const GitLabPublisher = require("../../../app/publishers/gitlab"); 2 | const GitLabAdapter = require("../../../app/adapters/gitlab"); 3 | const Logger = require("../../../app/services/logger"); 4 | const TagFixture = require("../../fixtures/tag"); 5 | 6 | jest.mock("../../../app/services/logger"); 7 | jest.mock("../../../app/adapters/gitlab"); 8 | describe("GitLab publisher", () => { 9 | describe("#publish", () => { 10 | let gitLabPublisher; 11 | let gitlabAdapter; 12 | let logger; 13 | beforeEach(() => { 14 | jest.resetAllMocks(); 15 | gitlabAdapter = new GitLabAdapter(); 16 | logger = new Logger(); 17 | gitLabPublisher = new GitLabPublisher({ gitlabAdapter, loggerService: logger }); 18 | }); 19 | test("should update release note if release note exists", async () => { 20 | await gitLabPublisher.publish({ 21 | projectId: "123", 22 | tag: TagFixture.tagDefault(), 23 | content: `### Release note (2012-05-27) 24 | #### Closed issues 25 | - Consequatur vero maxime deserunt laboriosam est voluptas dolorem. [#6](http://example.com/example/example/issues/6) 26 | #### Merged merge requests 27 | - test1 [#1](http://gitlab.example.com/my-group/my-project/merge_requests/1) ([admin](https://gitlab.example.com/admin)) 28 | ` 29 | }); 30 | const loggerInstance = Logger.mock.instances[0]; 31 | expect(loggerInstance.debug).toHaveBeenCalledTimes(1); 32 | expect(loggerInstance.debug.mock.calls[0][0]).toMatchSnapshot(); 33 | const gitLabAdapterInstance = GitLabAdapter.mock.instances[0]; 34 | expect(gitLabAdapterInstance.updateTagReleaseByProjectIdTagNameAndTagId).toHaveBeenCalledTimes(1); 35 | expect(gitLabAdapterInstance.updateTagReleaseByProjectIdTagNameAndTagId.mock.calls[0]).toMatchSnapshot(); 36 | }); 37 | test("should create release note if release note is missing", async () => { 38 | const mockTag = JSON.parse(JSON.stringify(TagFixture.tagDefault())); 39 | delete mockTag.release; 40 | await gitLabPublisher.publish({ 41 | projectId: "123", 42 | tag: mockTag, 43 | content: `### Release note (2012-05-27) 44 | #### Closed issues 45 | - Consequatur vero maxime deserunt laboriosam est voluptas dolorem. [#6](http://example.com/example/example/issues/6) 46 | #### Merged merge requests 47 | - test1 [#1](http://gitlab.example.com/my-group/my-project/merge_requests/1) ([admin](https://gitlab.example.com/admin)) 48 | ` 49 | }); 50 | const loggerInstance = Logger.mock.instances[0]; 51 | expect(loggerInstance.debug).toHaveBeenCalledTimes(1); 52 | expect(loggerInstance.debug.mock.calls[0][0]).toMatchSnapshot(); 53 | const gitLabAdapterInstance = GitLabAdapter.mock.instances[0]; 54 | expect(gitLabAdapterInstance.createTagReleaseByProjectIdTagNameAndTagId).toHaveBeenCalledTimes(1); 55 | expect(gitLabAdapterInstance.createTagReleaseByProjectIdTagNameAndTagId.mock.calls[0]).toMatchSnapshot(); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /app/services/tag.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | module.exports = class TagService { 3 | constructor({ gitlabRepository, loggerService, config }) { 4 | this.gitlabRepository = gitlabRepository; 5 | this.logger = loggerService; 6 | this.config = config; 7 | this.projectId = this.config.GITLAB_PROJECT_ID; 8 | } 9 | async getLatestAndSecondLatestTag() { 10 | let { tags, _link } = await this.gitlabRepository.findTagsByProjectId(this.projectId); 11 | if (_.isEmpty(tags)) return []; 12 | 13 | // Only get the latest and second latest one from the same branch 14 | const latestTag = tags.shift(); 15 | this.logger.info(`Latest tag is ${latestTag.name}`); 16 | 17 | if (!this.isTagsMatchTargetTagRegex([latestTag], this.config.TARGET_TAG_REGEX)) 18 | throw new Error( 19 | `Latest tag doesn't match with the regex. Target tag regex ${this.config.TARGET_TAG_REGEX}` 20 | ); 21 | const latestBranches = await this.gitlabRepository.findBranchRefsByProjectIdAndSha( 22 | this.projectId, 23 | latestTag.commit?.id 24 | ); 25 | if (!this.isBranchesInTargetBranch(latestBranches, this.config.TARGET_BRANCH)) 26 | throw new Error(`Latest tag doesn't contain target branch. Target branch ${this.config.TARGET_BRANCH}`); 27 | 28 | if (tags.length === 0) { 29 | const project = await this.gitlabRepository.findRepoByProjectId(this.projectId); 30 | this.logger.info("No more tag is found. Assuming project creation date is the start date"); 31 | return [latestTag, { commit: { committed_date: project.created_at } }]; 32 | } else { 33 | let secondLatestTag = null; 34 | let page = 0; 35 | while (!secondLatestTag) { 36 | for (const tag of tags) { 37 | if (this.isTagsMatchTargetTagRegex([tag], this.config.TARGET_TAG_REGEX)) { 38 | const branches = await this.gitlabRepository.findBranchRefsByProjectIdAndSha( 39 | this.projectId, 40 | latestTag.commit?.id 41 | ); 42 | if (this.isBranchesInTargetBranch(branches, this.config.TARGET_BRANCH)) { 43 | this.logger.info( 44 | `Found the second latest tag on page ${page + 1}. The second latest tag is ${tag.name}` 45 | ); 46 | secondLatestTag = tag; 47 | break; 48 | } 49 | } 50 | if (secondLatestTag) break; 51 | } 52 | 53 | if (!secondLatestTag) { 54 | if (!_.isFunction(_link?.next)) break; 55 | const res = await _link.next(); 56 | tags = res.tags; 57 | _link = res._link; 58 | page++; 59 | } 60 | } 61 | return [latestTag, secondLatestTag]; 62 | } 63 | } 64 | 65 | isTagsMatchTargetTagRegex(tags, targetTagRegex) { 66 | if (!targetTagRegex) return true; 67 | return _.some(tags, (tag) => String(tag?.name).match(targetTagRegex)); 68 | } 69 | isBranchesInTargetBranch(branches, targetBranchName) { 70 | if (!targetBranchName) return true; 71 | return _.some(branches, (branch) => branch.name === targetBranchName); 72 | } 73 | }; 74 | -------------------------------------------------------------------------------- /tests/unit/services/testChangelogService.test.js: -------------------------------------------------------------------------------- 1 | const ChangelogService = require("../../../app/services/changelog"); 2 | 3 | const GitLabRepository = require("../../../app/repositories/gitlab"); 4 | const Logger = require("../../../app/services/logger"); 5 | 6 | const IssueFixtures = require("../../fixtures/issue"); 7 | const MergeRequestFixtures = require("../../fixtures/mergeRequest"); 8 | 9 | jest.mock("../../../app/services/logger"); 10 | jest.mock("../../../app/repositories/gitlab"); 11 | describe("Changelog service", () => { 12 | describe("#getChangelogByStartAndEndDate", () => { 13 | let changelogService; 14 | let mergeRequests, issues; 15 | let gitlabRepository; 16 | let loggerService; 17 | beforeAll(async () => { 18 | jest.resetAllMocks(); 19 | gitlabRepository = new GitLabRepository({}); 20 | const mockMergeRequest = MergeRequestFixtures.mergeRequestDefault(); 21 | const mockMergeRequestOther = MergeRequestFixtures.mergeRequestDefaultOther(); 22 | mockMergeRequestOther.merged_at = "2018-08-07T11:15:17.520Z"; 23 | gitlabRepository.findMergeRequestsByProjectIdStateStartDateAndEndDate.mockResolvedValue([ 24 | mockMergeRequest, 25 | mockMergeRequestOther 26 | ]); 27 | const mockIssue = IssueFixtures.issueDefault(); 28 | const mockIssueOther = IssueFixtures.issueDefaultOther(); 29 | mockIssue.closed_at = "2018-09-07T12:15:17.520Z"; 30 | mockIssueOther.closed_at = "2018-08-07T11:15:17.520Z"; 31 | gitlabRepository.findIssuesByProjectIdStateStartDateAndEndDate.mockResolvedValue([ 32 | mockIssue, 33 | mockIssueOther 34 | ]); 35 | loggerService = new Logger({}); 36 | changelogService = new ChangelogService({ 37 | gitlabRepository, 38 | loggerService, 39 | config: { TZ: "Australia/Melbourne", GITLAB_PROJECT_ID: "12345" } 40 | }); 41 | const changelog = await changelogService.getChangelogByStartAndEndDate( 42 | "2018-09-07T11:15:17.520Z", 43 | "2018-10-07T11:15:17.520Z" 44 | ); 45 | mergeRequests = changelog.mergeRequests; 46 | issues = changelog.issues; 47 | }); 48 | test("should return issues and merge requests", () => { 49 | expect(mergeRequests).toHaveLength(1); 50 | expect(issues).toHaveLength(1); 51 | expect(mergeRequests).toMatchSnapshot(); 52 | expect(issues).toMatchSnapshot(); 53 | }); 54 | test("should call findMergeRequestsByProjectIdStateStartDateAndEndDate", () => { 55 | expect(gitlabRepository.findMergeRequestsByProjectIdStateStartDateAndEndDate).toHaveBeenCalledTimes(1); 56 | expect( 57 | gitlabRepository.findMergeRequestsByProjectIdStateStartDateAndEndDate.mock.calls[0] 58 | ).toMatchSnapshot(); 59 | }); 60 | test("should call findIssuesByProjectIdStateStartDateAndEndDate", () => { 61 | expect(gitlabRepository.findIssuesByProjectIdStateStartDateAndEndDate).toHaveBeenCalledTimes(1); 62 | expect(gitlabRepository.findIssuesByProjectIdStateStartDateAndEndDate.mock.calls[0]).toMatchSnapshot(); 63 | }); 64 | test("should log info", () => { 65 | expect(loggerService.info).toHaveBeenCalledTimes(3); 66 | expect(loggerService.info.mock.calls).toMatchSnapshot(); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /tests/fixtures/tag.js: -------------------------------------------------------------------------------- 1 | const Utils = require("../utils"); 2 | 3 | module.exports = { 4 | tagDefault: () => { 5 | return JSON.parse( 6 | JSON.stringify({ 7 | commit: { 8 | id: Utils.commitSha("commit1")[0], 9 | short_id: Utils.commitSha("commit1")[1], 10 | title: "Initial commit", 11 | created_at: "2012-05-27T04:42:42Z", 12 | parent_ids: ["2a4b78934375d7f53875269ffd4f45fd83a84ebe"], 13 | message: "Initial commit", 14 | author_name: "John Smith", 15 | author_email: "john@example.com", 16 | authored_date: "2012-05-27T04:42:42Z", 17 | committer_name: "Jack Smith", 18 | committer_email: "jack@example.com", 19 | committed_date: "2012-05-27T04:42:42Z" 20 | }, 21 | release: { 22 | tag_name: "1.1.0", 23 | description: "Amazing release. Wow" 24 | }, 25 | name: "v1.1.0", 26 | target: Utils.commitSha("commit1")[0], 27 | message: null 28 | }) 29 | ); 30 | }, 31 | tagDefaultOther: () => { 32 | return JSON.parse( 33 | JSON.stringify({ 34 | commit: { 35 | id: Utils.commitSha("commit2")[0], 36 | short_id: Utils.commitSha("commit2")[1], 37 | title: "Initial commit", 38 | created_at: "2012-05-26T04:42:42Z", 39 | parent_ids: ["2a4b78934375d7f53875269ffd4f45fd83a84ebf"], 40 | message: "Initial commit", 41 | author_name: "John Smith", 42 | author_email: "john@example.com", 43 | authored_date: "2012-05-26T04:42:42Z", 44 | committer_name: "Jack Smith", 45 | committer_email: "jack@example.com", 46 | committed_date: "2012-05-26T04:42:42Z" 47 | }, 48 | release: { 49 | tag_name: "1.0.0-20", 50 | description: "Amazing release. Wow" 51 | }, 52 | name: "v1.0.0-20", 53 | target: Utils.commitSha("commit2")[0], 54 | message: null 55 | }) 56 | ); 57 | }, 58 | tagDefaultOther2: () => { 59 | return JSON.parse( 60 | JSON.stringify({ 61 | commit: { 62 | id: Utils.commitSha("commit3")[0], 63 | short_id: Utils.commitSha("commit3")[1], 64 | title: "Initial commit", 65 | created_at: "2012-05-25T04:42:42Z", 66 | parent_ids: ["2a4b78934375d7f53875269ffd4f45fd83a84ebd"], 67 | message: "Initial commit", 68 | author_name: "John Smith", 69 | author_email: "john@example.com", 70 | authored_date: "2012-05-25T04:42:42Z", 71 | committer_name: "Jack Smith", 72 | committer_email: "jack@example.com", 73 | committed_date: "2012-05-25T04:42:42Z" 74 | }, 75 | release: { 76 | tag_name: "1.0.0", 77 | description: "Amazing release. Wow" 78 | }, 79 | name: "v1.0.0", 80 | target: Utils.commitSha("commit3")[0], 81 | message: null 82 | }) 83 | ); 84 | } 85 | }; 86 | -------------------------------------------------------------------------------- /app/adapters/gitlab.js: -------------------------------------------------------------------------------- 1 | const Got = require("got"); 2 | const LinkHeaderParse = require("parse-link-header"); 3 | 4 | module.exports = class GitlabAdapter { 5 | constructor({ config }) { 6 | this.GITLAB_PERSONAL_TOKEN = config.GITLAB_PERSONAL_TOKEN; 7 | this.GITLAB_API_ENDPOINT = config.GITLAB_API_ENDPOINT; 8 | this.gotDefaultOptions = { 9 | headers: { "Private-Token": this.GITLAB_PERSONAL_TOKEN }, 10 | responseType: "json" 11 | }; 12 | } 13 | _decorateLinks(link, templateFunction, templateArgs, query) { 14 | const linkObj = {}; 15 | if (link) { 16 | link = LinkHeaderParse(link); 17 | for (const key of Object.keys(link)) { 18 | linkObj[key] = () => 19 | templateFunction.apply(null, [ 20 | ...templateArgs, 21 | { ...query, page: link[key].page, per_page: link[key].per_page } 22 | ]); 23 | } 24 | } 25 | return linkObj; 26 | } 27 | 28 | async getRepoByProjectId(projectId) { 29 | const response = await Got.get(`${this.GITLAB_API_ENDPOINT}/projects/${projectId}`, { 30 | ...this.gotDefaultOptions 31 | }); 32 | return response.body; 33 | } 34 | 35 | async searchMergeRequestsByProjectId(projectId, query) { 36 | const queryString = query ? new URLSearchParams(query).toString() : null; 37 | const response = await Got.get( 38 | `${this.GITLAB_API_ENDPOINT}/projects/${projectId}/merge_requests${queryString ? `?${queryString}` : ""}`, 39 | { ...this.gotDefaultOptions } 40 | ); 41 | const linkObj = this._decorateLinks( 42 | response.headers.link, 43 | this.searchMergeRequestsByProjectId, 44 | [projectId], 45 | query 46 | ); 47 | return { 48 | mergeRequests: response.body || [], 49 | _link: linkObj 50 | }; 51 | } 52 | 53 | async searchIssuesByProjectId(projectId, query) { 54 | const queryString = query ? new URLSearchParams(query).toString() : null; 55 | const response = await Got.get( 56 | `${this.GITLAB_API_ENDPOINT}/projects/${projectId}/issues${queryString ? `?${queryString}` : ""}`, 57 | { 58 | ...this.gotDefaultOptions 59 | } 60 | ); 61 | const linkObj = this._decorateLinks(response.headers.link, this.searchIssuesByProjectId, [projectId], query); 62 | return { 63 | issues: response.body || [], 64 | _link: linkObj 65 | }; 66 | } 67 | 68 | async searchTagsByProjectId(projectId, query) { 69 | const queryString = query ? new URLSearchParams(query).toString() : null; 70 | const response = await Got.get( 71 | `${this.GITLAB_API_ENDPOINT}/projects/${projectId}/repository/tags${queryString ? `?${queryString}` : ""}`, 72 | { 73 | ...this.gotDefaultOptions 74 | } 75 | ); 76 | const linkObj = this._decorateLinks(response.headers.link, this.searchTagsByProjectId, [projectId], query); 77 | return { 78 | tags: response.body, 79 | _link: linkObj 80 | }; 81 | } 82 | 83 | async findCommitRefsByProjectIdAndSha(projectId, sha, query) { 84 | const queryString = query ? new URLSearchParams(query).toString() : null; 85 | const response = await Got.get( 86 | `${this.GITLAB_API_ENDPOINT}/projects/${projectId}/repository/commits/${sha}/refs${ 87 | queryString ? `?${queryString}` : "" 88 | }`, 89 | { ...this.gotDefaultOptions } 90 | ); 91 | return response.body; 92 | } 93 | 94 | async createTagReleaseByProjectIdTagNameAndTagId(projectId, tagName, body) { 95 | const response = await Got.post(`${this.GITLAB_API_ENDPOINT}/projects/${projectId}/releases`, { 96 | ...this.gotDefaultOptions, 97 | json: { ...body, tagName } 98 | }); 99 | return response.body; 100 | } 101 | 102 | async updateTagReleaseByProjectIdTagNameAndTagId(projectId, tagName, body) { 103 | const response = await Got.put(`${this.GITLAB_API_ENDPOINT}/projects/${projectId}/releases/${tagName}`, { 104 | ...this.gotDefaultOptions, 105 | json: body 106 | }); 107 | return response.body; 108 | } 109 | }; 110 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CircleCI](https://circleci.com/gh/jk1z/gitlab-release-note-generator/tree/master.svg?style=svg)](https://circleci.com/gh/jk1z/gitlab-release-note-generator/tree/master) 2 | [![codecov](https://codecov.io/gh/jk1z/gitlab-release-note-generator/branch/master/graph/badge.svg)](https://codecov.io/gh/jk1z/gitlab-release-note-generator) 3 | 4 | 5 | # Gitlab Release Note Generator 6 | 7 | A Gitlab release note generator that generates release note on latest tag 8 | 9 | ## Feature 10 | - Generate release note on the latest tag based on merge requests and issues 11 | - Distinguished title with issues or merge requests that have the following labels: **enhancement**, **breaking change**, **feature** and **bug** 12 | 13 | *(Note. if an issue or merge request that has 2 or more labels, that issue or merge request will be displayed again under the corresponding title)* 14 | 15 | - Can be integrated as a CD service. Tutorial below 16 | 17 | 18 | ## How it works 19 | 1. Find the latest tag 20 | 2. Find the previous tag that is on the same branch as the latest tag. 21 | 3. Locate the date range between the latest and the previous tag. If there is only a tag in the project, then the `from` date will be the project creation date and the `to` date will be that tag's creation date. 22 | 4. Find all **Merged** merge requests and **Closed** issues within that time range 23 | 5. Generate a release note/changelog based on the findings above. 24 | 25 | ## Software requirement 26 | - NodeJS ^10.x.x OR docker 27 | - A gitlab personal access token with `api` permission. [How to Tutorial](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html) 28 | 29 | ## How to run this app 30 | 31 | ### Docker method 32 | 33 | ```shell 34 | docker container run -e GITLAB_PERSONAL_TOKEN=gitlabSampleToken -e GITLAB_PROJECT_ID=12345678 -e TARGET_BRANCH=sampleTargetBranch -e TARGET_TAG_REGEX=sampleRegex 00freezy00/gitlab-release-note-generator 35 | ``` 36 | 37 | ### Nodejs Method 38 | - Fill in the parameters mainly `GITLAB_PERSONAL_TOKEN`, `GITLAB_PROJECT_ID`, `TARGET_BRANCH`(optional. Use it only if you want to find tags in the same specific branch) and `TARGET_TAG_REGEX` (optional. Can use it to distinguish master or develop branch version bump) in `app/env.js` or feed it in `process.env` through npm 39 | - `npm install` 40 | - `npm start` 41 | - After couple seconds, latest tag should have a release note 42 | ![](https://dl3.pushbulletusercontent.com/HIav5xaHjcerMtkHT3myQLnl5C9g1UP3/Screen%20Shot%202019-06-01%20at%204.27.18%20pm.png) 43 | 44 | ### Gitlab CI method 45 | 1. Need to pass in `gitlab personal access token` as a CI variable 46 | 2. c/p the `.sample.gitlab-ci.yml` to your gitlab ci. 47 | 48 | What's included in the sample gitlab CI script 49 | 50 | - `generate-release-note` job. Generates a release note on the tag after detecting tag push with this regex `/^[0-9]+.[0-9]+.[0-9]+(-[0-9]+)?$/` 51 | - `tag-after-deployment` job (optional). Tag the commit that contains a version bump with this regex `/^[0-9]+.[0-9]+.[0-9]+(-[0-9]+)?$/`. **Require ssh key to work.** 52 | 3. Customise the gitlab ci script to your need 53 | 54 | Reference gitlab repo: [generator test](https://gitlab.com/jackzhang/generator-test) 55 | 56 | 57 | ## Options 58 | 59 | These can be specified using environment variables 60 | 61 | * GITLAB_API_ENDPOINT: Your gitlab instaqnce's endpoint 62 | * Default https://gitlab.com/api/v4 63 | * GITLAB_PERSONAL_TOKEN: Grant api read/access permission 64 | * GITLAB_PROJECT_ID: Your project id that is located under settings > general 65 | * TARGET_BRANCH: The branch to look for release tags (ie master) 66 | * TARGET_TAG_REGEX: Regular expression of the release tags to search (ie: ^release-.*$) 67 | * TZ: The timezone for your release notes 68 | * Default "Australia/Melbourne" 69 | * ISSUE_CLOSED_SECONDS: The amount of seconds to search after the last commit, useful for Merge Requests that close their tickets a second after the commit. 70 | * Default 0 71 | 72 | ## Building and Running locally 73 | 74 | ```bash 75 | export GITLAB_PERSONAL_TOKEN=MYGITLABACCESSTOKEN 76 | export GITLAB_PROJECT_ID=99 77 | export GITLAB_API_ENDPOINT=https://my.gitlab.com/api/v4 78 | 79 | // run docker to build my local version 80 | docker build -t local-gitlab-release-note-generator . 81 | 82 | // run my local version 83 | docker container run \ 84 | -e TZ=America/New_York \ 85 | -e GITLAB_API_ENDPOINT=$GITLAB_API_ENDPOINT \ 86 | -e GITLAB_PERSONAL_TOKEN=$GITLAB_PERSONAL_TOKEN \ 87 | -e GITLAB_PROJECT_ID=$GITLAB_PROJECT_ID \ 88 | -e TARGET_BRANCH=master \ 89 | -e TARGET_TAG_REGEX=^release-.*$ \ 90 | local-gitlab-release-note-generator 91 | 92 | ``` 93 | 94 | ## TODO: 95 | ### Feature 96 | - Release notes generation on selected tag 97 | - Customise template for the release note 98 | 99 | ## Credits 100 | Thanks to [github-changelog-generator](https://github.com/github-changelog-generator/github-changelog-generator) for inspiring me to make this app. Sorry, I couldn't wait any longer for that gitlab feature to be merged in. 101 | -------------------------------------------------------------------------------- /tests/fixtures/project.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | projectDefault: () => { 3 | return JSON.parse( 4 | JSON.stringify({ 5 | id: 12345678, 6 | description: null, 7 | default_branch: "master", 8 | visibility: "private", 9 | ssh_url_to_repo: "git@example.com:diaspora/diaspora-project-site.git", 10 | http_url_to_repo: "http://example.com/diaspora/diaspora-project-site.git", 11 | web_url: "http://example.com/diaspora/diaspora-project-site", 12 | readme_url: "http://example.com/diaspora/diaspora-project-site/blob/master/README.md", 13 | tag_list: ["example", "disapora project"], 14 | owner: { 15 | id: 3, 16 | name: "Diaspora", 17 | created_at: "2012-05-26T04:42:42Z" 18 | }, 19 | name: "Diaspora Project Site", 20 | name_with_namespace: "Diaspora / Diaspora Project Site", 21 | path: "diaspora-project-site", 22 | path_with_namespace: "diaspora/diaspora-project-site", 23 | issues_enabled: true, 24 | open_issues_count: 1, 25 | merge_requests_enabled: true, 26 | jobs_enabled: true, 27 | wiki_enabled: true, 28 | snippets_enabled: false, 29 | resolve_outdated_diff_discussions: false, 30 | container_registry_enabled: false, 31 | created_at: "2012-05-26T04:42:42Z", 32 | last_activity_at: "2012-05-27T04:42:42Z", 33 | creator_id: 3, 34 | namespace: { 35 | id: 3, 36 | name: "Diaspora", 37 | path: "diaspora", 38 | kind: "group", 39 | full_path: "diaspora", 40 | avatar_url: "http://localhost:3000/uploads/group/avatar/3/foo.jpg", 41 | web_url: "http://localhost:3000/groups/diaspora" 42 | }, 43 | import_status: "none", 44 | import_error: null, 45 | permissions: { 46 | project_access: { 47 | access_level: 10, 48 | notification_level: 3 49 | }, 50 | group_access: { 51 | access_level: 50, 52 | notification_level: 3 53 | } 54 | }, 55 | archived: false, 56 | avatar_url: "http://example.com/uploads/project/avatar/3/uploads/avatar.png", 57 | license_url: "http://example.com/diaspora/diaspora-client/blob/master/LICENSE", 58 | license: { 59 | key: "lgpl-3.0", 60 | name: "GNU Lesser General Public License v3.0", 61 | nickname: "GNU LGPLv3", 62 | html_url: "http://choosealicense.com/licenses/lgpl-3.0/", 63 | source_url: "http://www.gnu.org/licenses/lgpl-3.0.txt" 64 | }, 65 | shared_runners_enabled: true, 66 | forks_count: 0, 67 | star_count: 0, 68 | runners_token: "b8bc4a7a29eb76ea83cf79e4908c2b", 69 | public_jobs: true, 70 | shared_with_groups: [ 71 | { 72 | group_id: 4, 73 | group_name: "Twitter", 74 | group_full_path: "twitter", 75 | group_access_level: 30 76 | }, 77 | { 78 | group_id: 3, 79 | group_name: "Gitlab Org", 80 | group_full_path: "gitlab-org", 81 | group_access_level: 10 82 | } 83 | ], 84 | repository_storage: "default", 85 | only_allow_merge_if_pipeline_succeeds: false, 86 | only_allow_merge_if_all_discussions_are_resolved: false, 87 | printing_merge_requests_link_enabled: true, 88 | request_access_enabled: false, 89 | merge_method: "merge", 90 | approvals_before_merge: 0, 91 | statistics: { 92 | commit_count: 37, 93 | storage_size: 1038090, 94 | repository_size: 1038090, 95 | wiki_size: 0, 96 | lfs_objects_size: 0, 97 | job_artifacts_size: 0, 98 | packages_size: 0 99 | }, 100 | _links: { 101 | self: "http://example.com/api/v4/projects", 102 | issues: "http://example.com/api/v4/projects/1/issues", 103 | merge_requests: "http://example.com/api/v4/projects/1/merge_requests", 104 | repo_branches: "http://example.com/api/v4/projects/1/repository_branches", 105 | labels: "http://example.com/api/v4/projects/1/labels", 106 | events: "http://example.com/api/v4/projects/1/events", 107 | members: "http://example.com/api/v4/projects/1/members" 108 | } 109 | }) 110 | ); 111 | } 112 | }; 113 | -------------------------------------------------------------------------------- /tests/unit/services/__snapshots__/testTagService.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Tag service #getLatestAndSecondLatestTag Boom scenario Latest tag is not in the targeting branch should throw err 1`] = `"Latest tag doesn't contain target branch. Target branch develop"`; 4 | 5 | exports[`Tag service #getLatestAndSecondLatestTag Boom scenario Latest tag is not matching the regex should throw err 1`] = `"Latest tag doesn't match with the regex. Target tag regex /^[0-9]+.[0-9]+.[0-9]+$/"`; 6 | 7 | exports[`Tag service #getLatestAndSecondLatestTag Optional scenario Only one tag should call findBranchRefsByProjectIdAndSha 1`] = ` 8 | Array [ 9 | Array [ 10 | "123456", 11 | "59395c05c18b9c8904853715d4136921de0b48f1", 12 | ], 13 | ] 14 | `; 15 | 16 | exports[`Tag service #getLatestAndSecondLatestTag Optional scenario Only one tag should call findRepoByProjectId 1`] = ` 17 | Array [ 18 | "123456", 19 | ] 20 | `; 21 | 22 | exports[`Tag service #getLatestAndSecondLatestTag Optional scenario Only one tag should call findTagsByProjectId 1`] = ` 23 | Array [ 24 | "123456", 25 | ] 26 | `; 27 | 28 | exports[`Tag service #getLatestAndSecondLatestTag Optional scenario Only one tag should log info 1`] = ` 29 | [MockFunction] { 30 | "calls": Array [ 31 | Array [ 32 | "Latest tag is v1.0.0", 33 | ], 34 | Array [ 35 | "No more tag is found. Assuming project creation date is the start date", 36 | ], 37 | ], 38 | "results": Array [ 39 | Object { 40 | "type": "return", 41 | "value": undefined, 42 | }, 43 | Object { 44 | "type": "return", 45 | "value": undefined, 46 | }, 47 | ], 48 | } 49 | `; 50 | 51 | exports[`Tag service #getLatestAndSecondLatestTag Optional scenario Only one tag should return latest and second latest tag 1`] = ` 52 | Array [ 53 | Object { 54 | "commit": Object { 55 | "author_email": "john@example.com", 56 | "author_name": "John Smith", 57 | "authored_date": "2012-05-25T04:42:42Z", 58 | "committed_date": "2012-05-25T04:42:42Z", 59 | "committer_email": "jack@example.com", 60 | "committer_name": "Jack Smith", 61 | "created_at": "2012-05-25T04:42:42Z", 62 | "id": "59395c05c18b9c8904853715d4136921de0b48f1", 63 | "message": "Initial commit", 64 | "parent_ids": Array [ 65 | "2a4b78934375d7f53875269ffd4f45fd83a84ebd", 66 | ], 67 | "short_id": "de0b48f1", 68 | "title": "Initial commit", 69 | }, 70 | "message": null, 71 | "name": "v1.0.0", 72 | "release": Object { 73 | "description": "Amazing release. Wow", 74 | "tag_name": "1.0.0", 75 | }, 76 | "target": "59395c05c18b9c8904853715d4136921de0b48f1", 77 | }, 78 | Object { 79 | "commit": Object { 80 | "committed_date": "2012-05-26T04:42:42Z", 81 | }, 82 | }, 83 | ] 84 | `; 85 | 86 | exports[`Tag service #getLatestAndSecondLatestTag Sunny scenario should call findBranchRefsByProjectIdAndSha 1`] = ` 87 | Array [ 88 | Array [ 89 | "123456", 90 | "ec0b4f0b5c90ed0fa911a2972ccc452641b31563", 91 | ], 92 | Array [ 93 | "123456", 94 | "ec0b4f0b5c90ed0fa911a2972ccc452641b31563", 95 | ], 96 | ] 97 | `; 98 | 99 | exports[`Tag service #getLatestAndSecondLatestTag Sunny scenario should call findTagsByProjectId 1`] = ` 100 | Array [ 101 | "123456", 102 | ] 103 | `; 104 | 105 | exports[`Tag service #getLatestAndSecondLatestTag Sunny scenario should log info 1`] = ` 106 | [MockFunction] { 107 | "calls": Array [ 108 | Array [ 109 | "Latest tag is v1.1.0", 110 | ], 111 | Array [ 112 | "Found the second latest tag on page 1. The second latest tag is v1.0.0", 113 | ], 114 | ], 115 | "results": Array [ 116 | Object { 117 | "type": "return", 118 | "value": undefined, 119 | }, 120 | Object { 121 | "type": "return", 122 | "value": undefined, 123 | }, 124 | ], 125 | } 126 | `; 127 | 128 | exports[`Tag service #getLatestAndSecondLatestTag Sunny scenario should return latest and second latest tag 1`] = ` 129 | Array [ 130 | Object { 131 | "commit": Object { 132 | "author_email": "john@example.com", 133 | "author_name": "John Smith", 134 | "authored_date": "2012-05-27T04:42:42Z", 135 | "committed_date": "2012-05-27T04:42:42Z", 136 | "committer_email": "jack@example.com", 137 | "committer_name": "Jack Smith", 138 | "created_at": "2012-05-27T04:42:42Z", 139 | "id": "ec0b4f0b5c90ed0fa911a2972ccc452641b31563", 140 | "message": "Initial commit", 141 | "parent_ids": Array [ 142 | "2a4b78934375d7f53875269ffd4f45fd83a84ebe", 143 | ], 144 | "short_id": "41b31563", 145 | "title": "Initial commit", 146 | }, 147 | "message": null, 148 | "name": "v1.1.0", 149 | "release": Object { 150 | "description": "Amazing release. Wow", 151 | "tag_name": "1.1.0", 152 | }, 153 | "target": "ec0b4f0b5c90ed0fa911a2972ccc452641b31563", 154 | }, 155 | Object { 156 | "commit": Object { 157 | "author_email": "john@example.com", 158 | "author_name": "John Smith", 159 | "authored_date": "2012-05-25T04:42:42Z", 160 | "committed_date": "2012-05-25T04:42:42Z", 161 | "committer_email": "jack@example.com", 162 | "committer_name": "Jack Smith", 163 | "created_at": "2012-05-25T04:42:42Z", 164 | "id": "59395c05c18b9c8904853715d4136921de0b48f1", 165 | "message": "Initial commit", 166 | "parent_ids": Array [ 167 | "2a4b78934375d7f53875269ffd4f45fd83a84ebd", 168 | ], 169 | "short_id": "de0b48f1", 170 | "title": "Initial commit", 171 | }, 172 | "message": null, 173 | "name": "v1.0.0", 174 | "release": Object { 175 | "description": "Amazing release. Wow", 176 | "tag_name": "1.0.0", 177 | }, 178 | "target": "59395c05c18b9c8904853715d4136921de0b48f1", 179 | }, 180 | ] 181 | `; 182 | -------------------------------------------------------------------------------- /tests/fixtures/issue.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | issueDefault: () => { 3 | return JSON.parse( 4 | JSON.stringify({ 5 | state: "closed", 6 | description: "Ratione dolores corrupti mollitia soluta quia.", 7 | author: { 8 | state: "active", 9 | id: 18, 10 | web_url: "https://gitlab.example.com/eileen.lowe", 11 | name: "Alexandra Bashirian", 12 | avatar_url: null, 13 | username: "eileen.lowe" 14 | }, 15 | milestone: { 16 | project_id: 1, 17 | description: "Ducimus nam enim ex consequatur cumque ratione.", 18 | state: "closed", 19 | due_date: null, 20 | iid: 2, 21 | created_at: "2016-01-04T15:31:39.996Z", 22 | title: "v4.0", 23 | id: 17, 24 | updated_at: "2016-01-04T15:31:39.996Z" 25 | }, 26 | project_id: 1, 27 | assignees: [ 28 | { 29 | state: "active", 30 | id: 1, 31 | name: "Administrator", 32 | web_url: "https://gitlab.example.com/root", 33 | avatar_url: null, 34 | username: "root" 35 | } 36 | ], 37 | assignee: { 38 | state: "active", 39 | id: 1, 40 | name: "Administrator", 41 | web_url: "https://gitlab.example.com/root", 42 | avatar_url: null, 43 | username: "root" 44 | }, 45 | updated_at: "2016-01-04T15:31:51.081Z", 46 | closed_at: null, 47 | closed_by: null, 48 | id: 75, 49 | title: "Mock Issue 1", 50 | created_at: "2016-01-04T15:31:51.081Z", 51 | iid: 5, 52 | labels: ["foo", "bar"], 53 | upvotes: 4, 54 | downvotes: 0, 55 | merge_requests_count: 0, 56 | user_notes_count: 1, 57 | due_date: "2016-07-22", 58 | web_url: "http://example.com/example/example/issues/5", 59 | weight: null, 60 | time_stats: { 61 | time_estimate: 0, 62 | total_time_spent: 0, 63 | human_time_estimate: null, 64 | human_total_time_spent: null 65 | }, 66 | has_tasks: true, 67 | task_status: "10 of 15 tasks completed", 68 | confidential: false, 69 | discussion_locked: false, 70 | _links: { 71 | self: "http://example.com/api/v4/projects/1/issues/76", 72 | notes: "`http://example.com/`api/v4/projects/1/issues/76/notes", 73 | award_emoji: "http://example.com/api/v4/projects/1/issues/76/award_emoji", 74 | project: "http://example.com/api/v4/projects/1" 75 | }, 76 | subscribed: false 77 | }) 78 | ); 79 | }, 80 | issueDefaultOther: () => { 81 | return JSON.parse( 82 | JSON.stringify({ 83 | state: "closed", 84 | description: "Ratione dolores corrupti mollitia soluta quia.", 85 | author: { 86 | state: "active", 87 | id: 18, 88 | web_url: "https://gitlab.example.com/eileen.lowe", 89 | name: "Alexandra Bashirian", 90 | avatar_url: null, 91 | username: "eileen.lowe" 92 | }, 93 | milestone: { 94 | project_id: 1, 95 | description: "Ducimus nam enim ex consequatur cumque ratione.", 96 | state: "closed", 97 | due_date: null, 98 | iid: 2, 99 | created_at: "2016-01-04T15:31:39.996Z", 100 | title: "v4.0", 101 | id: 17, 102 | updated_at: "2016-01-04T15:31:39.996Z" 103 | }, 104 | project_id: 1, 105 | assignees: [ 106 | { 107 | state: "active", 108 | id: 1, 109 | name: "Administrator", 110 | web_url: "https://gitlab.example.com/root", 111 | avatar_url: null, 112 | username: "root" 113 | } 114 | ], 115 | assignee: { 116 | state: "active", 117 | id: 1, 118 | name: "Administrator", 119 | web_url: "https://gitlab.example.com/root", 120 | avatar_url: null, 121 | username: "root" 122 | }, 123 | updated_at: "2016-01-04T15:31:51.081Z", 124 | closed_at: null, 125 | closed_by: null, 126 | id: 76, 127 | title: "mock issue 2", 128 | created_at: "2016-01-04T15:31:51.081Z", 129 | iid: 6, 130 | labels: ["fixes"], 131 | upvotes: 4, 132 | downvotes: 0, 133 | merge_requests_count: 0, 134 | user_notes_count: 1, 135 | due_date: "2016-07-22", 136 | web_url: "http://example.com/example/example/issues/6", 137 | weight: null, 138 | time_stats: { 139 | time_estimate: 0, 140 | total_time_spent: 0, 141 | human_time_estimate: null, 142 | human_total_time_spent: null 143 | }, 144 | has_tasks: true, 145 | task_status: "10 of 15 tasks completed", 146 | confidential: false, 147 | discussion_locked: false, 148 | _links: { 149 | self: "http://example.com/api/v4/projects/1/issues/76", 150 | notes: "`http://example.com/`api/v4/projects/1/issues/76/notes", 151 | award_emoji: "http://example.com/api/v4/projects/1/issues/76/award_emoji", 152 | project: "http://example.com/api/v4/projects/1" 153 | }, 154 | subscribed: false 155 | }) 156 | ); 157 | } 158 | }; 159 | -------------------------------------------------------------------------------- /tests/unit/services/__snapshots__/testChangelogService.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Changelog service #getChangelogByStartAndEndDate should call findIssuesByProjectIdStateStartDateAndEndDate 1`] = ` 4 | Array [ 5 | "12345", 6 | "closed", 7 | "2018-09-07T11:15:17.520Z", 8 | "2018-10-07T11:15:17.520Z", 9 | ] 10 | `; 11 | 12 | exports[`Changelog service #getChangelogByStartAndEndDate should call findMergeRequestsByProjectIdStateStartDateAndEndDate 1`] = ` 13 | Array [ 14 | "12345", 15 | "merged", 16 | "2018-09-07T11:15:17.520Z", 17 | "2018-10-07T11:15:17.520Z", 18 | ] 19 | `; 20 | 21 | exports[`Changelog service #getChangelogByStartAndEndDate should log info 1`] = ` 22 | Array [ 23 | Array [ 24 | "Time range that we are looking at MRs and issues is between Fri Sep 07 2018 21:15:17 GMT+1000 and Sun Oct 07 2018 22:15:17 GMT+1100", 25 | ], 26 | Array [ 27 | "Found 1 merge requests", 28 | ], 29 | Array [ 30 | "Found 1 issues", 31 | ], 32 | ] 33 | `; 34 | 35 | exports[`Changelog service #getChangelogByStartAndEndDate should return issues and merge requests 1`] = ` 36 | Array [ 37 | Object { 38 | "allow_collaboration": false, 39 | "allow_maintainer_to_push": false, 40 | "approvals_before_merge": null, 41 | "assignee": Object { 42 | "avatar_url": null, 43 | "id": 1, 44 | "name": "Administrator", 45 | "state": "active", 46 | "username": "admin", 47 | "web_url": "https://gitlab.example.com/admin", 48 | }, 49 | "assignees": Array [ 50 | Object { 51 | "avatar_url": "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", 52 | "id": 12, 53 | "name": "Miss Monserrate Beier", 54 | "state": "active", 55 | "username": "axel.block", 56 | "web_url": "https://gitlab.example.com/axel.block", 57 | }, 58 | ], 59 | "author": Object { 60 | "avatar_url": null, 61 | "id": 1, 62 | "name": "Administrator", 63 | "state": "active", 64 | "username": "admin", 65 | "web_url": "https://gitlab.example.com/admin", 66 | }, 67 | "closed_at": null, 68 | "closed_by": null, 69 | "created_at": "2017-04-29T08:46:00Z", 70 | "description": "fixed login page css paddings", 71 | "discussion_locked": null, 72 | "downvotes": 0, 73 | "force_remove_source_branch": false, 74 | "id": 1, 75 | "iid": 1, 76 | "labels": Array [ 77 | "features", 78 | ], 79 | "merge_commit_sha": null, 80 | "merge_status": "can_be_merged", 81 | "merge_when_pipeline_succeeds": true, 82 | "merged_at": "2018-09-07T11:16:17.520Z", 83 | "merged_by": Object { 84 | "avatar_url": "https://gitlab.example.com/uploads/-/system/user/avatar/87854/avatar.png", 85 | "id": 87854, 86 | "name": "Douwe Maan", 87 | "state": "active", 88 | "username": "DouweM", 89 | "web_url": "https://gitlab.com/DouweM", 90 | }, 91 | "milestone": Object { 92 | "created_at": "2015-02-02T19:49:26.013Z", 93 | "description": "Assumenda aut placeat expedita exercitationem labore sunt enim earum.", 94 | "due_date": "2018-09-22", 95 | "id": 5, 96 | "iid": 1, 97 | "project_id": 3, 98 | "start_date": "2018-08-08", 99 | "state": "closed", 100 | "title": "v2.0", 101 | "updated_at": "2015-02-02T19:49:26.013Z", 102 | "web_url": "https://gitlab.example.com/my-group/my-project/milestones/1", 103 | }, 104 | "project_id": 3, 105 | "sha": "8888888888888888888888888888888888888888", 106 | "should_remove_source_branch": true, 107 | "source_branch": "test1", 108 | "source_project_id": 2, 109 | "squash": false, 110 | "state": "merged", 111 | "target_branch": "master", 112 | "target_project_id": 3, 113 | "time_stats": Object { 114 | "human_time_estimate": null, 115 | "human_total_time_spent": null, 116 | "time_estimate": 0, 117 | "total_time_spent": 0, 118 | }, 119 | "title": "Mock merge request 1", 120 | "updated_at": "2017-04-29T08:46:00Z", 121 | "upvotes": 0, 122 | "user_notes_count": 1, 123 | "web_url": "http://gitlab.example.com/my-group/my-project/merge_requests/1", 124 | "work_in_progress": false, 125 | }, 126 | ] 127 | `; 128 | 129 | exports[`Changelog service #getChangelogByStartAndEndDate should return issues and merge requests 2`] = ` 130 | Array [ 131 | Object { 132 | "_links": Object { 133 | "award_emoji": "http://example.com/api/v4/projects/1/issues/76/award_emoji", 134 | "notes": "\`http://example.com/\`api/v4/projects/1/issues/76/notes", 135 | "project": "http://example.com/api/v4/projects/1", 136 | "self": "http://example.com/api/v4/projects/1/issues/76", 137 | }, 138 | "assignee": Object { 139 | "avatar_url": null, 140 | "id": 1, 141 | "name": "Administrator", 142 | "state": "active", 143 | "username": "root", 144 | "web_url": "https://gitlab.example.com/root", 145 | }, 146 | "assignees": Array [ 147 | Object { 148 | "avatar_url": null, 149 | "id": 1, 150 | "name": "Administrator", 151 | "state": "active", 152 | "username": "root", 153 | "web_url": "https://gitlab.example.com/root", 154 | }, 155 | ], 156 | "author": Object { 157 | "avatar_url": null, 158 | "id": 18, 159 | "name": "Alexandra Bashirian", 160 | "state": "active", 161 | "username": "eileen.lowe", 162 | "web_url": "https://gitlab.example.com/eileen.lowe", 163 | }, 164 | "closed_at": "2018-09-07T12:15:17.520Z", 165 | "closed_by": null, 166 | "confidential": false, 167 | "created_at": "2016-01-04T15:31:51.081Z", 168 | "description": "Ratione dolores corrupti mollitia soluta quia.", 169 | "discussion_locked": false, 170 | "downvotes": 0, 171 | "due_date": "2016-07-22", 172 | "has_tasks": true, 173 | "id": 75, 174 | "iid": 5, 175 | "labels": Array [ 176 | "foo", 177 | "bar", 178 | ], 179 | "merge_requests_count": 0, 180 | "milestone": Object { 181 | "created_at": "2016-01-04T15:31:39.996Z", 182 | "description": "Ducimus nam enim ex consequatur cumque ratione.", 183 | "due_date": null, 184 | "id": 17, 185 | "iid": 2, 186 | "project_id": 1, 187 | "state": "closed", 188 | "title": "v4.0", 189 | "updated_at": "2016-01-04T15:31:39.996Z", 190 | }, 191 | "project_id": 1, 192 | "state": "closed", 193 | "subscribed": false, 194 | "task_status": "10 of 15 tasks completed", 195 | "time_stats": Object { 196 | "human_time_estimate": null, 197 | "human_total_time_spent": null, 198 | "time_estimate": 0, 199 | "total_time_spent": 0, 200 | }, 201 | "title": "Mock Issue 1", 202 | "updated_at": "2016-01-04T15:31:51.081Z", 203 | "upvotes": 4, 204 | "user_notes_count": 1, 205 | "web_url": "http://example.com/example/example/issues/5", 206 | "weight": null, 207 | }, 208 | ] 209 | `; 210 | -------------------------------------------------------------------------------- /tests/fixtures/mergeRequest.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mergeRequestDefault: () => { 3 | return JSON.parse( 4 | JSON.stringify({ 5 | id: 1, 6 | iid: 1, 7 | project_id: 3, 8 | title: "Mock merge request 1", 9 | description: "fixed login page css paddings", 10 | state: "merged", 11 | merged_by: { 12 | id: 87854, 13 | name: "Douwe Maan", 14 | username: "DouweM", 15 | state: "active", 16 | avatar_url: "https://gitlab.example.com/uploads/-/system/user/avatar/87854/avatar.png", 17 | web_url: "https://gitlab.com/DouweM" 18 | }, 19 | merged_at: "2018-09-07T11:16:17.520Z", 20 | closed_by: null, 21 | closed_at: null, 22 | created_at: "2017-04-29T08:46:00Z", 23 | updated_at: "2017-04-29T08:46:00Z", 24 | target_branch: "master", 25 | source_branch: "test1", 26 | upvotes: 0, 27 | downvotes: 0, 28 | author: { 29 | id: 1, 30 | name: "Administrator", 31 | username: "admin", 32 | state: "active", 33 | avatar_url: null, 34 | web_url: "https://gitlab.example.com/admin" 35 | }, 36 | assignee: { 37 | id: 1, 38 | name: "Administrator", 39 | username: "admin", 40 | state: "active", 41 | avatar_url: null, 42 | web_url: "https://gitlab.example.com/admin" 43 | }, 44 | assignees: [ 45 | { 46 | name: "Miss Monserrate Beier", 47 | username: "axel.block", 48 | id: 12, 49 | state: "active", 50 | avatar_url: "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", 51 | web_url: "https://gitlab.example.com/axel.block" 52 | } 53 | ], 54 | source_project_id: 2, 55 | target_project_id: 3, 56 | labels: ["features"], 57 | work_in_progress: false, 58 | milestone: { 59 | id: 5, 60 | iid: 1, 61 | project_id: 3, 62 | title: "v2.0", 63 | description: "Assumenda aut placeat expedita exercitationem labore sunt enim earum.", 64 | state: "closed", 65 | created_at: "2015-02-02T19:49:26.013Z", 66 | updated_at: "2015-02-02T19:49:26.013Z", 67 | due_date: "2018-09-22", 68 | start_date: "2018-08-08", 69 | web_url: "https://gitlab.example.com/my-group/my-project/milestones/1" 70 | }, 71 | merge_when_pipeline_succeeds: true, 72 | merge_status: "can_be_merged", 73 | sha: "8888888888888888888888888888888888888888", 74 | merge_commit_sha: null, 75 | user_notes_count: 1, 76 | discussion_locked: null, 77 | should_remove_source_branch: true, 78 | force_remove_source_branch: false, 79 | allow_collaboration: false, 80 | allow_maintainer_to_push: false, 81 | web_url: "http://gitlab.example.com/my-group/my-project/merge_requests/1", 82 | time_stats: { 83 | time_estimate: 0, 84 | total_time_spent: 0, 85 | human_time_estimate: null, 86 | human_total_time_spent: null 87 | }, 88 | squash: false, 89 | approvals_before_merge: null 90 | }) 91 | ); 92 | }, 93 | mergeRequestDefaultOther: () => { 94 | return JSON.parse( 95 | JSON.stringify({ 96 | id: 1, 97 | iid: 2, 98 | project_id: 3, 99 | title: "Mock merge request 2", 100 | description: "fixed login page css paddings", 101 | state: "merged", 102 | merged_by: { 103 | id: 87854, 104 | name: "Douwe Maan", 105 | username: "DouweM", 106 | state: "active", 107 | avatar_url: "https://gitlab.example.com/uploads/-/system/user/avatar/87854/avatar.png", 108 | web_url: "https://gitlab.com/DouweM" 109 | }, 110 | merged_at: "2018-09-07T11:16:17.520Z", 111 | closed_by: null, 112 | closed_at: null, 113 | created_at: "2017-04-29T08:46:00Z", 114 | updated_at: "2017-04-29T08:46:00Z", 115 | target_branch: "master", 116 | source_branch: "test1", 117 | upvotes: 0, 118 | downvotes: 0, 119 | author: { 120 | id: 1, 121 | name: "Administrator", 122 | username: "admin", 123 | state: "active", 124 | avatar_url: null, 125 | web_url: "https://gitlab.example.com/admin" 126 | }, 127 | assignee: { 128 | id: 1, 129 | name: "Administrator", 130 | username: "admin", 131 | state: "active", 132 | avatar_url: null, 133 | web_url: "https://gitlab.example.com/admin" 134 | }, 135 | assignees: [ 136 | { 137 | name: "Miss Monserrate Beier", 138 | username: "axel.block", 139 | id: 12, 140 | state: "active", 141 | avatar_url: "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", 142 | web_url: "https://gitlab.example.com/axel.block" 143 | } 144 | ], 145 | source_project_id: 2, 146 | target_project_id: 3, 147 | labels: ["Community contribution", "Manage"], 148 | work_in_progress: false, 149 | milestone: { 150 | id: 5, 151 | iid: 1, 152 | project_id: 3, 153 | title: "v2.0", 154 | description: "Assumenda aut placeat expedita exercitationem labore sunt enim earum.", 155 | state: "closed", 156 | created_at: "2015-02-02T19:49:26.013Z", 157 | updated_at: "2015-02-02T19:49:26.013Z", 158 | due_date: "2018-09-22", 159 | start_date: "2018-08-08", 160 | web_url: "https://gitlab.example.com/my-group/my-project/milestones/1" 161 | }, 162 | merge_when_pipeline_succeeds: true, 163 | merge_status: "can_be_merged", 164 | sha: "8888888888888888888888888888888888888888", 165 | merge_commit_sha: null, 166 | user_notes_count: 1, 167 | discussion_locked: null, 168 | should_remove_source_branch: true, 169 | force_remove_source_branch: false, 170 | allow_collaboration: false, 171 | allow_maintainer_to_push: false, 172 | web_url: "http://gitlab.example.com/my-group/my-project/merge_requests/1", 173 | time_stats: { 174 | time_estimate: 0, 175 | total_time_spent: 0, 176 | human_time_estimate: null, 177 | human_total_time_spent: null 178 | }, 179 | squash: false, 180 | approvals_before_merge: null 181 | }) 182 | ); 183 | } 184 | }; 185 | -------------------------------------------------------------------------------- /tests/unit/decorators/testSlackDecorator.test.js: -------------------------------------------------------------------------------- 1 | const SlackDecorator = require("../../../app/decorators/slack"); 2 | const IssueFixture = require("../../fixtures/issue"); 3 | const MergeRequestFixture = require("../../fixtures/mergeRequest"); 4 | 5 | describe("Slack Decorator", () => { 6 | describe("#generateContent", () => { 7 | test("should generate release note content", () => { 8 | const slackDecorator = new SlackDecorator({ 9 | changelog: { 10 | issues: [IssueFixture.issueDefault(), IssueFixture.issueDefaultOther()], 11 | mergeRequests: [ 12 | MergeRequestFixture.mergeRequestDefault(), 13 | MergeRequestFixture.mergeRequestDefaultOther() 14 | ] 15 | }, 16 | labelConfigs: [ 17 | { name: "issues", title: "Closed issues", default: true }, 18 | { name: "mergeRequests", title: "Merged merge requests", default: true }, 19 | { name: "features", title: "Features" }, 20 | { name: "fixes", title: "Bug Fixes" } 21 | ], 22 | tz: "Australia/Melbourne", 23 | releaseDate: "2020-01-04T15:31:39.996Z" 24 | }); 25 | const releaseNote = slackDecorator.generateContent(); 26 | expect(releaseNote).toMatchSnapshot(); 27 | }); 28 | }); 29 | describe("#decorateIssue", () => { 30 | test("should format issue", () => { 31 | const slackDecorator = new SlackDecorator({ 32 | changelog: { 33 | issues: [], 34 | mergeRequests: [] 35 | }, 36 | labelConfigs: [ 37 | { name: "issues", title: "Closed issues", default: true }, 38 | { name: "mergeRequests", title: "Merged merge requests", default: true } 39 | ], 40 | tz: "Australia/Melbourne", 41 | releaseDate: "2020-01-04T15:31:39.996Z" 42 | }); 43 | const issue = slackDecorator.decorateIssue({ 44 | state: "opened", 45 | description: "Ratione dolores corrupti mollitia soluta quia.", 46 | author: { 47 | state: "active", 48 | id: 18, 49 | web_url: "https://gitlab.example.com/eileen.lowe", 50 | name: "Alexandra Bashirian", 51 | avatar_url: null, 52 | username: "eileen.lowe" 53 | }, 54 | milestone: { 55 | project_id: 1, 56 | description: "Ducimus nam enim ex consequatur cumque ratione.", 57 | state: "closed", 58 | due_date: null, 59 | iid: 2, 60 | created_at: "2016-01-04T15:31:39.996Z", 61 | title: "v4.0", 62 | id: 17, 63 | updated_at: "2016-01-04T15:31:39.996Z" 64 | }, 65 | project_id: 1, 66 | assignees: [ 67 | { 68 | state: "active", 69 | id: 1, 70 | name: "Administrator", 71 | web_url: "https://gitlab.example.com/root", 72 | avatar_url: null, 73 | username: "root" 74 | } 75 | ], 76 | assignee: { 77 | state: "active", 78 | id: 1, 79 | name: "Administrator", 80 | web_url: "https://gitlab.example.com/root", 81 | avatar_url: null, 82 | username: "root" 83 | }, 84 | updated_at: "2016-01-04T15:31:51.081Z", 85 | closed_at: null, 86 | closed_by: null, 87 | id: 76, 88 | title: "Consequatur vero maxime deserunt laboriosam est voluptas dolorem.", 89 | created_at: "2016-01-04T15:31:51.081Z", 90 | iid: 6, 91 | labels: ["foo", "bar"], 92 | upvotes: 4, 93 | downvotes: 0, 94 | merge_requests_count: 0, 95 | user_notes_count: 1, 96 | due_date: "2016-07-22", 97 | web_url: "http://example.com/example/example/issues/6", 98 | weight: null, 99 | time_stats: { 100 | time_estimate: 0, 101 | total_time_spent: 0, 102 | human_time_estimate: null, 103 | human_total_time_spent: null 104 | }, 105 | has_tasks: true, 106 | task_status: "10 of 15 tasks completed", 107 | confidential: false, 108 | discussion_locked: false, 109 | _links: { 110 | self: "http://example.com/api/v4/projects/1/issues/76", 111 | notes: "`http://example.com/`api/v4/projects/1/issues/76/notes", 112 | award_emoji: "http://example.com/api/v4/projects/1/issues/76/award_emoji", 113 | project: "http://example.com/api/v4/projects/1" 114 | }, 115 | subscribed: false 116 | }); 117 | expect(issue).toMatchSnapshot(); 118 | }); 119 | }); 120 | describe("#decorateMergeRequest", () => { 121 | test("should format merge request", () => { 122 | const slackDecorator = new SlackDecorator({ 123 | changelog: { 124 | issues: [], 125 | mergeRequests: [] 126 | }, 127 | labelConfigs: [ 128 | { name: "issues", title: "Closed issues", default: true }, 129 | { name: "mergeRequests", title: "Merged merge requests", default: true } 130 | ], 131 | tz: "Australia/Melbourne", 132 | releaseDate: "2020-01-04T15:31:39.996Z" 133 | }); 134 | const issue = slackDecorator.decorateMergeRequest({ 135 | id: 1, 136 | iid: 1, 137 | project_id: 3, 138 | title: "test1", 139 | description: "fixed login page css paddings", 140 | state: "merged", 141 | merged_by: { 142 | id: 87854, 143 | name: "Douwe Maan", 144 | username: "DouweM", 145 | state: "active", 146 | avatar_url: "https://gitlab.example.com/uploads/-/system/user/avatar/87854/avatar.png", 147 | web_url: "https://gitlab.com/DouweM" 148 | }, 149 | merged_at: "2018-09-07T11:16:17.520Z", 150 | closed_by: null, 151 | closed_at: null, 152 | created_at: "2017-04-29T08:46:00Z", 153 | updated_at: "2017-04-29T08:46:00Z", 154 | target_branch: "master", 155 | source_branch: "test1", 156 | upvotes: 0, 157 | downvotes: 0, 158 | author: { 159 | id: 1, 160 | name: "Administrator", 161 | username: "admin", 162 | state: "active", 163 | avatar_url: null, 164 | web_url: "https://gitlab.example.com/admin" 165 | }, 166 | assignee: { 167 | id: 1, 168 | name: "Administrator", 169 | username: "admin", 170 | state: "active", 171 | avatar_url: null, 172 | web_url: "https://gitlab.example.com/admin" 173 | }, 174 | assignees: [ 175 | { 176 | name: "Miss Monserrate Beier", 177 | username: "axel.block", 178 | id: 12, 179 | state: "active", 180 | avatar_url: "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", 181 | web_url: "https://gitlab.example.com/axel.block" 182 | } 183 | ], 184 | source_project_id: 2, 185 | target_project_id: 3, 186 | labels: ["Community contribution", "Manage"], 187 | work_in_progress: false, 188 | milestone: { 189 | id: 5, 190 | iid: 1, 191 | project_id: 3, 192 | title: "v2.0", 193 | description: "Assumenda aut placeat expedita exercitationem labore sunt enim earum.", 194 | state: "closed", 195 | created_at: "2015-02-02T19:49:26.013Z", 196 | updated_at: "2015-02-02T19:49:26.013Z", 197 | due_date: "2018-09-22", 198 | start_date: "2018-08-08", 199 | web_url: "https://gitlab.example.com/my-group/my-project/milestones/1" 200 | }, 201 | merge_when_pipeline_succeeds: true, 202 | merge_status: "can_be_merged", 203 | sha: "8888888888888888888888888888888888888888", 204 | merge_commit_sha: null, 205 | user_notes_count: 1, 206 | discussion_locked: null, 207 | should_remove_source_branch: true, 208 | force_remove_source_branch: false, 209 | allow_collaboration: false, 210 | allow_maintainer_to_push: false, 211 | web_url: "http://gitlab.example.com/my-group/my-project/merge_requests/1", 212 | time_stats: { 213 | time_estimate: 0, 214 | total_time_spent: 0, 215 | human_time_estimate: null, 216 | human_total_time_spent: null 217 | }, 218 | squash: false, 219 | approvals_before_merge: null 220 | }); 221 | expect(issue).toMatchSnapshot(); 222 | }); 223 | }); 224 | }); 225 | -------------------------------------------------------------------------------- /tests/unit/decorators/testGitLabDecorator.test.js: -------------------------------------------------------------------------------- 1 | const GitLabDecorator = require("../../../app/decorators/gitlab"); 2 | const MergeRequestFixture = require("../../fixtures/mergeRequest"); 3 | const IssueFixture = require("../../fixtures/issue"); 4 | describe("Gitlab Decorator", () => { 5 | describe("#generateContent", () => { 6 | test("should generate release note content", () => { 7 | const gitlabDecorator = new GitLabDecorator({ 8 | changelog: { 9 | issues: [IssueFixture.issueDefault(), IssueFixture.issueDefaultOther()], 10 | mergeRequests: [ 11 | MergeRequestFixture.mergeRequestDefault(), 12 | MergeRequestFixture.mergeRequestDefaultOther() 13 | ] 14 | }, 15 | labelConfigs: [ 16 | { name: "issues", title: "Closed issues", default: true }, 17 | { name: "mergeRequests", title: "Merged merge requests", default: true }, 18 | { name: "features", title: "Features" }, 19 | { name: "fixes", title: "Bug Fixes" } 20 | ], 21 | tz: "Australia/Melbourne", 22 | releaseDate: "2020-01-04T15:31:39.996Z" 23 | }); 24 | const releaseNote = gitlabDecorator.generateContent(); 25 | expect(releaseNote).toMatchSnapshot(); 26 | }); 27 | }); 28 | describe("#decorateIssue", () => { 29 | test("should format issue", () => { 30 | const gitlabDecorator = new GitLabDecorator({ 31 | changelog: { 32 | issues: [], 33 | mergeRequests: [] 34 | }, 35 | labelConfigs: [ 36 | { name: "issues", title: "Closed issues", default: true }, 37 | { name: "mergeRequests", title: "Merged merge requests", default: true } 38 | ], 39 | tz: "Australia/Melbourne", 40 | releaseDate: "2020-01-04T15:31:39.996Z" 41 | }); 42 | const issue = gitlabDecorator.decorateIssue({ 43 | state: "opened", 44 | description: "Ratione dolores corrupti mollitia soluta quia.", 45 | author: { 46 | state: "active", 47 | id: 18, 48 | web_url: "https://gitlab.example.com/eileen.lowe", 49 | name: "Alexandra Bashirian", 50 | avatar_url: null, 51 | username: "eileen.lowe" 52 | }, 53 | milestone: { 54 | project_id: 1, 55 | description: "Ducimus nam enim ex consequatur cumque ratione.", 56 | state: "closed", 57 | due_date: null, 58 | iid: 2, 59 | created_at: "2016-01-04T15:31:39.996Z", 60 | title: "v4.0", 61 | id: 17, 62 | updated_at: "2016-01-04T15:31:39.996Z" 63 | }, 64 | project_id: 1, 65 | assignees: [ 66 | { 67 | state: "active", 68 | id: 1, 69 | name: "Administrator", 70 | web_url: "https://gitlab.example.com/root", 71 | avatar_url: null, 72 | username: "root" 73 | } 74 | ], 75 | assignee: { 76 | state: "active", 77 | id: 1, 78 | name: "Administrator", 79 | web_url: "https://gitlab.example.com/root", 80 | avatar_url: null, 81 | username: "root" 82 | }, 83 | updated_at: "2016-01-04T15:31:51.081Z", 84 | closed_at: null, 85 | closed_by: null, 86 | id: 76, 87 | title: "Consequatur vero maxime deserunt laboriosam est voluptas dolorem.", 88 | created_at: "2016-01-04T15:31:51.081Z", 89 | iid: 6, 90 | labels: ["foo", "bar"], 91 | upvotes: 4, 92 | downvotes: 0, 93 | merge_requests_count: 0, 94 | user_notes_count: 1, 95 | due_date: "2016-07-22", 96 | web_url: "http://example.com/example/example/issues/6", 97 | weight: null, 98 | time_stats: { 99 | time_estimate: 0, 100 | total_time_spent: 0, 101 | human_time_estimate: null, 102 | human_total_time_spent: null 103 | }, 104 | has_tasks: true, 105 | task_status: "10 of 15 tasks completed", 106 | confidential: false, 107 | discussion_locked: false, 108 | _links: { 109 | self: "http://example.com/api/v4/projects/1/issues/76", 110 | notes: "`http://example.com/`api/v4/projects/1/issues/76/notes", 111 | award_emoji: "http://example.com/api/v4/projects/1/issues/76/award_emoji", 112 | project: "http://example.com/api/v4/projects/1" 113 | }, 114 | subscribed: false 115 | }); 116 | expect(issue).toMatchSnapshot(); 117 | }); 118 | }); 119 | describe("#decorateMergeRequest", () => { 120 | test("should format merge request", () => { 121 | const gitlabDecorator = new GitLabDecorator({ 122 | changelog: { 123 | issues: [], 124 | mergeRequests: [] 125 | }, 126 | labelConfigs: [ 127 | { name: "issues", title: "Closed issues", default: true }, 128 | { name: "mergeRequests", title: "Merged merge requests", default: true } 129 | ], 130 | tz: "Australia/Melbourne", 131 | releaseDate: "2020-01-04T15:31:39.996Z" 132 | }); 133 | const issue = gitlabDecorator.decorateMergeRequest({ 134 | id: 1, 135 | iid: 1, 136 | project_id: 3, 137 | title: "test1", 138 | description: "fixed login page css paddings", 139 | state: "merged", 140 | merged_by: { 141 | id: 87854, 142 | name: "Douwe Maan", 143 | username: "DouweM", 144 | state: "active", 145 | avatar_url: "https://gitlab.example.com/uploads/-/system/user/avatar/87854/avatar.png", 146 | web_url: "https://gitlab.com/DouweM" 147 | }, 148 | merged_at: "2018-09-07T11:16:17.520Z", 149 | closed_by: null, 150 | closed_at: null, 151 | created_at: "2017-04-29T08:46:00Z", 152 | updated_at: "2017-04-29T08:46:00Z", 153 | target_branch: "master", 154 | source_branch: "test1", 155 | upvotes: 0, 156 | downvotes: 0, 157 | author: { 158 | id: 1, 159 | name: "Administrator", 160 | username: "admin", 161 | state: "active", 162 | avatar_url: null, 163 | web_url: "https://gitlab.example.com/admin" 164 | }, 165 | assignee: { 166 | id: 1, 167 | name: "Administrator", 168 | username: "admin", 169 | state: "active", 170 | avatar_url: null, 171 | web_url: "https://gitlab.example.com/admin" 172 | }, 173 | assignees: [ 174 | { 175 | name: "Miss Monserrate Beier", 176 | username: "axel.block", 177 | id: 12, 178 | state: "active", 179 | avatar_url: "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", 180 | web_url: "https://gitlab.example.com/axel.block" 181 | } 182 | ], 183 | source_project_id: 2, 184 | target_project_id: 3, 185 | labels: ["Community contribution", "Manage"], 186 | work_in_progress: false, 187 | milestone: { 188 | id: 5, 189 | iid: 1, 190 | project_id: 3, 191 | title: "v2.0", 192 | description: "Assumenda aut placeat expedita exercitationem labore sunt enim earum.", 193 | state: "closed", 194 | created_at: "2015-02-02T19:49:26.013Z", 195 | updated_at: "2015-02-02T19:49:26.013Z", 196 | due_date: "2018-09-22", 197 | start_date: "2018-08-08", 198 | web_url: "https://gitlab.example.com/my-group/my-project/milestones/1" 199 | }, 200 | merge_when_pipeline_succeeds: true, 201 | merge_status: "can_be_merged", 202 | sha: "8888888888888888888888888888888888888888", 203 | merge_commit_sha: null, 204 | user_notes_count: 1, 205 | discussion_locked: null, 206 | should_remove_source_branch: true, 207 | force_remove_source_branch: false, 208 | allow_collaboration: false, 209 | allow_maintainer_to_push: false, 210 | web_url: "http://gitlab.example.com/my-group/my-project/merge_requests/1", 211 | time_stats: { 212 | time_estimate: 0, 213 | total_time_spent: 0, 214 | human_time_estimate: null, 215 | human_total_time_spent: null 216 | }, 217 | squash: false, 218 | approvals_before_merge: null 219 | }); 220 | expect(issue).toMatchSnapshot(); 221 | }); 222 | }); 223 | }); 224 | -------------------------------------------------------------------------------- /tests/unit/services/testTagService.test.js: -------------------------------------------------------------------------------- 1 | const TagService = require("../../../app/services/tag"); 2 | 3 | const GitlabRepository = require("../../../app/repositories/gitlab"); 4 | const LoggerService = require("../../../app/services/logger"); 5 | jest.mock("../../../app/repositories/gitlab"); 6 | jest.mock("../../../app/services/logger"); 7 | 8 | const TagFixtures = require("../../fixtures/tag"); 9 | const ProjectFixtures = require("../../fixtures/project"); 10 | 11 | describe("Tag service", () => { 12 | describe("#getLatestAndSecondLatestTag", () => { 13 | let tagService; 14 | let gitlabRepository; 15 | let loggerService; 16 | let res; 17 | describe("Sunny scenario", () => { 18 | beforeAll(async () => { 19 | jest.resetAllMocks(); 20 | loggerService = new LoggerService({}); 21 | gitlabRepository = new GitlabRepository({}); 22 | gitlabRepository.findTagsByProjectId.mockResolvedValue({ 23 | tags: [TagFixtures.tagDefault(), TagFixtures.tagDefaultOther(), TagFixtures.tagDefaultOther2()], 24 | _link: jest.fn() 25 | }); 26 | gitlabRepository.findRepoByProjectId.mockResolvedValue(ProjectFixtures.projectDefault()); 27 | gitlabRepository.findBranchRefsByProjectIdAndSha.mockResolvedValue([{ name: "master" }]); 28 | tagService = new TagService({ 29 | gitlabRepository, 30 | loggerService, 31 | config: { 32 | TARGET_BRANCH: "master", 33 | TARGET_TAG_REGEX: new RegExp(/^v[0-9]+.[0-9]+.[0-9]+$/), 34 | GITLAB_PROJECT_ID: "123456" 35 | } 36 | }); 37 | res = await tagService.getLatestAndSecondLatestTag(); 38 | }); 39 | test("should log info", () => { 40 | expect(loggerService.info).toBeCalledTimes(2); 41 | expect(loggerService.info).toMatchSnapshot(); 42 | }); 43 | test("should return latest and second latest tag", () => { 44 | expect(res).toHaveLength(2); 45 | expect(res).toMatchSnapshot(); 46 | }); 47 | test("should call findTagsByProjectId", () => { 48 | expect(gitlabRepository.findTagsByProjectId).toBeCalledTimes(1); 49 | expect(gitlabRepository.findTagsByProjectId.mock.calls[0]).toMatchSnapshot(); 50 | }); 51 | test("should call findBranchRefsByProjectIdAndSha", () => { 52 | expect(gitlabRepository.findBranchRefsByProjectIdAndSha).toBeCalledTimes(2); 53 | expect(gitlabRepository.findBranchRefsByProjectIdAndSha.mock.calls).toMatchSnapshot(); 54 | }); 55 | }); 56 | describe("Optional scenario", () => { 57 | describe("Only one tag", () => { 58 | beforeAll(async () => { 59 | jest.resetAllMocks(); 60 | loggerService = new LoggerService({}); 61 | gitlabRepository = new GitlabRepository({}); 62 | gitlabRepository.findTagsByProjectId.mockResolvedValue({ 63 | tags: [TagFixtures.tagDefaultOther2()], 64 | _link: jest.fn() 65 | }); 66 | gitlabRepository.findRepoByProjectId.mockResolvedValue(ProjectFixtures.projectDefault()); 67 | gitlabRepository.findBranchRefsByProjectIdAndSha.mockResolvedValue([{ name: "master" }]); 68 | tagService = new TagService({ 69 | gitlabRepository, 70 | loggerService, 71 | config: { 72 | TARGET_BRANCH: "master", 73 | TARGET_TAG_REGEX: new RegExp(/^v[0-9]+.[0-9]+.[0-9]+$/), 74 | GITLAB_PROJECT_ID: "123456" 75 | } 76 | }); 77 | res = await tagService.getLatestAndSecondLatestTag(); 78 | }); 79 | test("should log info", () => { 80 | expect(loggerService.info).toBeCalledTimes(2); 81 | expect(loggerService.info).toMatchSnapshot(); 82 | }); 83 | test("should return latest and second latest tag", () => { 84 | expect(res).toHaveLength(2); 85 | expect(res).toMatchSnapshot(); 86 | }); 87 | test("should call findTagsByProjectId", () => { 88 | expect(gitlabRepository.findTagsByProjectId).toBeCalledTimes(1); 89 | expect(gitlabRepository.findTagsByProjectId.mock.calls[0]).toMatchSnapshot(); 90 | }); 91 | test("should call findRepoByProjectId", () => { 92 | expect(gitlabRepository.findRepoByProjectId).toBeCalledTimes(1); 93 | expect(gitlabRepository.findRepoByProjectId.mock.calls[0]).toMatchSnapshot(); 94 | }); 95 | test("should call findBranchRefsByProjectIdAndSha", () => { 96 | expect(gitlabRepository.findBranchRefsByProjectIdAndSha).toBeCalledTimes(1); 97 | expect(gitlabRepository.findBranchRefsByProjectIdAndSha.mock.calls).toMatchSnapshot(); 98 | }); 99 | }); 100 | }); 101 | describe("Boom scenario", () => { 102 | let err; 103 | describe("Latest tag is not matching the regex", () => { 104 | beforeAll(async () => { 105 | jest.resetAllMocks(); 106 | loggerService = new LoggerService({}); 107 | gitlabRepository = new GitlabRepository({}); 108 | gitlabRepository.findTagsByProjectId.mockResolvedValue({ 109 | tags: [TagFixtures.tagDefault(), TagFixtures.tagDefaultOther(), TagFixtures.tagDefaultOther2()], 110 | _link: jest.fn() 111 | }); 112 | gitlabRepository.findBranchRefsByProjectIdAndSha.mockResolvedValue([{ name: "develop" }]); 113 | tagService = new TagService({ 114 | gitlabRepository, 115 | loggerService, 116 | config: { 117 | TARGET_TAG_REGEX: new RegExp(/^[0-9]+.[0-9]+.[0-9]+$/), 118 | GITLAB_PROJECT_ID: "123456" 119 | } 120 | }); 121 | try { 122 | res = await tagService.getLatestAndSecondLatestTag(); 123 | } catch (e) { 124 | err = e; 125 | } 126 | }); 127 | it("should throw err", () => { 128 | expect(err.message).toMatchSnapshot(); 129 | }); 130 | }); 131 | describe("Latest tag is not in the targeting branch", () => { 132 | beforeAll(async () => { 133 | jest.resetAllMocks(); 134 | loggerService = new LoggerService({}); 135 | gitlabRepository = new GitlabRepository({}); 136 | gitlabRepository.findTagsByProjectId.mockResolvedValue({ 137 | tags: [TagFixtures.tagDefault(), TagFixtures.tagDefaultOther(), TagFixtures.tagDefaultOther2()], 138 | _link: jest.fn() 139 | }); 140 | gitlabRepository.findBranchRefsByProjectIdAndSha.mockResolvedValue([{ name: "master" }]); 141 | tagService = new TagService({ 142 | gitlabRepository, 143 | loggerService, 144 | config: { 145 | TARGET_BRANCH: "develop", 146 | GITLAB_PROJECT_ID: "123456" 147 | } 148 | }); 149 | try { 150 | res = await tagService.getLatestAndSecondLatestTag(); 151 | } catch (e) { 152 | err = e; 153 | } 154 | }); 155 | it("should throw err", () => { 156 | expect(err.message).toMatchSnapshot(); 157 | }); 158 | }); 159 | }); 160 | }); 161 | describe("#isTagsMatchTargetTagRegex", () => { 162 | let tagService; 163 | beforeAll(() => { 164 | const loggerService = new LoggerService({}); 165 | const gitlabRepository = new GitlabRepository({}); 166 | tagService = new TagService({ gitlabRepository, loggerService, config: {} }); 167 | }); 168 | test("should return true when tag matches regex", () => { 169 | const result = tagService.isTagsMatchTargetTagRegex([{ name: "1.1.0" }], /^[0-9]+.[0-9]+.[0-9]+$/); 170 | expect(result).toBeTruthy(); 171 | }); 172 | test("should return false when tag doesn't match regex", () => { 173 | const result = tagService.isTagsMatchTargetTagRegex([{ name: "1.1.0-1" }], /^[0-9]+.[0-9]+.[0-9]+$/); 174 | expect(result).toBeFalsy(); 175 | }); 176 | test("should return true when regex is missing", () => { 177 | const result = tagService.isTagsMatchTargetTagRegex([{ name: "1.1.0-1" }], undefined); 178 | expect(result).toBeTruthy(); 179 | }); 180 | }); 181 | describe("#isBranchesInTargetBranch", () => { 182 | let tagService; 183 | beforeAll(() => { 184 | const loggerService = new LoggerService({}); 185 | const gitlabRepository = new GitlabRepository({}); 186 | tagService = new TagService({ gitlabRepository, loggerService, config: {} }); 187 | }); 188 | test("should return true when branch contains target branch", () => { 189 | const result = tagService.isBranchesInTargetBranch([{ name: "master" }], "master"); 190 | expect(result).toBeTruthy(); 191 | }); 192 | test("should return false when branch doesn't contain target branch", () => { 193 | const result = tagService.isBranchesInTargetBranch([{ name: "develop" }], "master"); 194 | expect(result).toBeFalsy(); 195 | }); 196 | test("should return true when target branch is missing", () => { 197 | const result = tagService.isBranchesInTargetBranch([{ name: "develop" }], undefined); 198 | expect(result).toBeTruthy(); 199 | }); 200 | }); 201 | }); 202 | -------------------------------------------------------------------------------- /tests/functional/testApp.test.js: -------------------------------------------------------------------------------- 1 | const ConfigureContainer = require("../../app/configureContainer"); 2 | const Nock = require("nock"); 3 | const Constants = require("../../app/constants"); 4 | const { asValue } = require("awilix"); 5 | const GitLabPublisher = require("../../app/publishers/gitlab"); 6 | 7 | const TagFixtures = require("../fixtures/tag"); 8 | 9 | jest.mock("../../app/publishers/gitlab"); 10 | 11 | describe("Gitlab release note generator", () => { 12 | describe("#run", () => { 13 | describe("Sunny scenario", () => { 14 | let generatorApp; 15 | let searchTagsNock; 16 | let getProjectNock; 17 | let searchMRNock; 18 | let searchIssueNock; 19 | let searchRefs; 20 | let container; 21 | beforeAll(async () => { 22 | jest.resetAllMocks(); 23 | const GITLAB_API_ENDPOINT = "https://gitlab.com/api/v4"; 24 | const GITLAB_PROJECT_ID = "12345678"; 25 | searchTagsNock = Nock(GITLAB_API_ENDPOINT) 26 | .get(`/projects/${GITLAB_PROJECT_ID}/repository/tags`) 27 | .reply(200, [TagFixtures.tagDefault()]) 28 | .persist(); 29 | searchRefs = Nock(GITLAB_API_ENDPOINT) 30 | .get( 31 | "/projects/12345678/repository/commits/ec0b4f0b5c90ed0fa911a2972ccc452641b31563/refs?type=branch" 32 | ) 33 | .reply(200, [{ type: "branch", name: "master" }]); 34 | // Mock repo api 35 | getProjectNock = Nock(GITLAB_API_ENDPOINT) 36 | .get(`/projects/${GITLAB_PROJECT_ID}`) 37 | .reply(200, { 38 | id: 12345678, 39 | description: null, 40 | default_branch: "master", 41 | visibility: "private", 42 | ssh_url_to_repo: "git@example.com:diaspora/diaspora-project-site.git", 43 | http_url_to_repo: "http://example.com/diaspora/diaspora-project-site.git", 44 | web_url: "http://example.com/diaspora/diaspora-project-site", 45 | readme_url: "http://example.com/diaspora/diaspora-project-site/blob/master/README.md", 46 | tag_list: ["example", "disapora project"], 47 | owner: { 48 | id: 3, 49 | name: "Diaspora", 50 | created_at: "2012-05-26T04:42:42Z" 51 | }, 52 | name: "Diaspora Project Site", 53 | name_with_namespace: "Diaspora / Diaspora Project Site", 54 | path: "diaspora-project-site", 55 | path_with_namespace: "diaspora/diaspora-project-site", 56 | issues_enabled: true, 57 | open_issues_count: 1, 58 | merge_requests_enabled: true, 59 | jobs_enabled: true, 60 | wiki_enabled: true, 61 | snippets_enabled: false, 62 | resolve_outdated_diff_discussions: false, 63 | container_registry_enabled: false, 64 | created_at: "2012-05-26T04:42:42Z", 65 | last_activity_at: "2012-05-27T04:42:42Z", 66 | creator_id: 3, 67 | namespace: { 68 | id: 3, 69 | name: "Diaspora", 70 | path: "diaspora", 71 | kind: "group", 72 | full_path: "diaspora", 73 | avatar_url: "http://localhost:3000/uploads/group/avatar/3/foo.jpg", 74 | web_url: "http://localhost:3000/groups/diaspora" 75 | }, 76 | import_status: "none", 77 | import_error: null, 78 | permissions: { 79 | project_access: { 80 | access_level: 10, 81 | notification_level: 3 82 | }, 83 | group_access: { 84 | access_level: 50, 85 | notification_level: 3 86 | } 87 | }, 88 | archived: false, 89 | avatar_url: "http://example.com/uploads/project/avatar/3/uploads/avatar.png", 90 | license_url: "http://example.com/diaspora/diaspora-client/blob/master/LICENSE", 91 | license: { 92 | key: "lgpl-3.0", 93 | name: "GNU Lesser General Public License v3.0", 94 | nickname: "GNU LGPLv3", 95 | html_url: "http://choosealicense.com/licenses/lgpl-3.0/", 96 | source_url: "http://www.gnu.org/licenses/lgpl-3.0.txt" 97 | }, 98 | shared_runners_enabled: true, 99 | forks_count: 0, 100 | star_count: 0, 101 | runners_token: "b8bc4a7a29eb76ea83cf79e4908c2b", 102 | public_jobs: true, 103 | shared_with_groups: [ 104 | { 105 | group_id: 4, 106 | group_name: "Twitter", 107 | group_full_path: "twitter", 108 | group_access_level: 30 109 | }, 110 | { 111 | group_id: 3, 112 | group_name: "Gitlab Org", 113 | group_full_path: "gitlab-org", 114 | group_access_level: 10 115 | } 116 | ], 117 | repository_storage: "default", 118 | only_allow_merge_if_pipeline_succeeds: false, 119 | only_allow_merge_if_all_discussions_are_resolved: false, 120 | printing_merge_requests_link_enabled: true, 121 | request_access_enabled: false, 122 | merge_method: "merge", 123 | approvals_before_merge: 0, 124 | statistics: { 125 | commit_count: 37, 126 | storage_size: 1038090, 127 | repository_size: 1038090, 128 | wiki_size: 0, 129 | lfs_objects_size: 0, 130 | job_artifacts_size: 0, 131 | packages_size: 0 132 | }, 133 | _links: { 134 | self: "http://example.com/api/v4/projects", 135 | issues: "http://example.com/api/v4/projects/1/issues", 136 | merge_requests: "http://example.com/api/v4/projects/1/merge_requests", 137 | repo_branches: "http://example.com/api/v4/projects/1/repository_branches", 138 | labels: "http://example.com/api/v4/projects/1/labels", 139 | events: "http://example.com/api/v4/projects/1/events", 140 | members: "http://example.com/api/v4/projects/1/members" 141 | } 142 | }); 143 | // Mock MR api 144 | searchMRNock = Nock(GITLAB_API_ENDPOINT) 145 | .get(`/projects/${GITLAB_PROJECT_ID}/merge_requests`) 146 | .query({ 147 | state: "merged", 148 | updated_before: "2012-05-27T04:42:42Z", 149 | updated_after: "2012-05-26T04:42:42Z" 150 | }) 151 | .reply(200, [ 152 | { 153 | id: 1, 154 | iid: 1, 155 | project_id: 3, 156 | title: "test1", 157 | description: "fixed login page css paddings", 158 | state: "merged", 159 | merged_by: { 160 | id: 87854, 161 | name: "Douwe Maan", 162 | username: "DouweM", 163 | state: "active", 164 | avatar_url: "https://gitlab.example.com/uploads/-/system/user/avatar/87854/avatar.png", 165 | web_url: "https://gitlab.com/DouweM" 166 | }, 167 | merged_at: "2012-05-27T04:42:42Z", 168 | closed_by: null, 169 | closed_at: null, 170 | created_at: "2017-04-29T08:46:00Z", 171 | updated_at: "2017-04-29T08:46:00Z", 172 | target_branch: "master", 173 | source_branch: "test1", 174 | upvotes: 0, 175 | downvotes: 0, 176 | author: { 177 | id: 1, 178 | name: "Administrator", 179 | username: "admin", 180 | state: "active", 181 | avatar_url: null, 182 | web_url: "https://gitlab.example.com/admin" 183 | }, 184 | assignee: { 185 | id: 1, 186 | name: "Administrator", 187 | username: "admin", 188 | state: "active", 189 | avatar_url: null, 190 | web_url: "https://gitlab.example.com/admin" 191 | }, 192 | assignees: [ 193 | { 194 | name: "Miss Monserrate Beier", 195 | username: "axel.block", 196 | id: 12, 197 | state: "active", 198 | avatar_url: 199 | "http://www.gravatar.com/avatar/46f6f7dc858ada7be1853f7fb96e81da?s=80&d=identicon", 200 | web_url: "https://gitlab.example.com/axel.block" 201 | } 202 | ], 203 | source_project_id: 2, 204 | target_project_id: 3, 205 | labels: ["Community contribution", "Manage"], 206 | work_in_progress: false, 207 | milestone: { 208 | id: 5, 209 | iid: 1, 210 | project_id: 3, 211 | title: "v2.0", 212 | description: "Assumenda aut placeat expedita exercitationem labore sunt enim earum.", 213 | state: "closed", 214 | created_at: "2015-02-02T19:49:26.013Z", 215 | updated_at: "2015-02-02T19:49:26.013Z", 216 | due_date: "2018-09-22", 217 | start_date: "2018-08-08", 218 | web_url: "https://gitlab.example.com/my-group/my-project/milestones/1" 219 | }, 220 | merge_when_pipeline_succeeds: true, 221 | merge_status: "can_be_merged", 222 | sha: "8888888888888888888888888888888888888888", 223 | merge_commit_sha: null, 224 | user_notes_count: 1, 225 | discussion_locked: null, 226 | should_remove_source_branch: true, 227 | force_remove_source_branch: false, 228 | allow_collaboration: false, 229 | allow_maintainer_to_push: false, 230 | web_url: "http://gitlab.example.com/my-group/my-project/merge_requests/1", 231 | time_stats: { 232 | time_estimate: 0, 233 | total_time_spent: 0, 234 | human_time_estimate: null, 235 | human_total_time_spent: null 236 | }, 237 | squash: false, 238 | approvals_before_merge: null 239 | } 240 | ]); 241 | // Mock issue api 242 | searchIssueNock = Nock(GITLAB_API_ENDPOINT) 243 | .get(`/projects/${GITLAB_PROJECT_ID}/issues`) 244 | .query({ 245 | state: "closed", 246 | updated_before: "2012-05-27T04:42:42Z", 247 | updated_after: "2012-05-26T04:42:42Z" 248 | }) 249 | .reply(200, [ 250 | { 251 | state: "opened", 252 | description: "Ratione dolores corrupti mollitia soluta quia.", 253 | author: { 254 | state: "active", 255 | id: 18, 256 | web_url: "https://gitlab.example.com/eileen.lowe", 257 | name: "Alexandra Bashirian", 258 | avatar_url: null, 259 | username: "eileen.lowe" 260 | }, 261 | milestone: { 262 | project_id: 1, 263 | description: "Ducimus nam enim ex consequatur cumque ratione.", 264 | state: "closed", 265 | due_date: null, 266 | iid: 2, 267 | created_at: "2016-01-04T15:31:39.996Z", 268 | title: "v4.0", 269 | id: 17, 270 | updated_at: "2016-01-04T15:31:39.996Z" 271 | }, 272 | project_id: 1, 273 | assignees: [ 274 | { 275 | state: "active", 276 | id: 1, 277 | name: "Administrator", 278 | web_url: "https://gitlab.example.com/root", 279 | avatar_url: null, 280 | username: "root" 281 | } 282 | ], 283 | assignee: { 284 | state: "active", 285 | id: 1, 286 | name: "Administrator", 287 | web_url: "https://gitlab.example.com/root", 288 | avatar_url: null, 289 | username: "root" 290 | }, 291 | updated_at: "2012-05-27T04:42:42Z", 292 | closed_at: "2012-05-27T04:42:42Z", 293 | closed_by: null, 294 | id: 76, 295 | title: "Consequatur vero maxime deserunt laboriosam est voluptas dolorem.", 296 | created_at: "2012-05-27T04:42:42Z", 297 | iid: 6, 298 | labels: ["foo", "bar"], 299 | upvotes: 4, 300 | downvotes: 0, 301 | merge_requests_count: 0, 302 | user_notes_count: 1, 303 | due_date: "2016-07-22", 304 | web_url: "http://example.com/example/example/issues/6", 305 | weight: null, 306 | time_stats: { 307 | time_estimate: 0, 308 | total_time_spent: 0, 309 | human_time_estimate: null, 310 | human_total_time_spent: null 311 | }, 312 | has_tasks: true, 313 | task_status: "10 of 15 tasks completed", 314 | confidential: false, 315 | discussion_locked: false, 316 | _links: { 317 | self: "http://example.com/api/v4/projects/1/issues/76", 318 | notes: "`http://example.com/`api/v4/projects/1/issues/76/notes", 319 | award_emoji: "http://example.com/api/v4/projects/1/issues/76/award_emoji", 320 | project: "http://example.com/api/v4/projects/1" 321 | }, 322 | subscribed: false 323 | } 324 | ]); 325 | 326 | const config = { 327 | GITLAB_API_ENDPOINT, 328 | GITLAB_PERSONAL_TOKEN: "mockGitLabPersonalToken", 329 | GITLAB_PROJECT_ID, 330 | NODE_ENV: "test", 331 | ...Constants.defaultOptions 332 | }; 333 | container = ConfigureContainer(); 334 | container.register({ config: asValue(config) }); 335 | generatorApp = container.resolve("gitLabReleaseNoteGenerator"); 336 | await generatorApp.run(); 337 | }); 338 | afterAll(() => { 339 | container.dispose(); 340 | }); 341 | test("should query search tags", () => { 342 | searchTagsNock.done(); 343 | }); 344 | test("should query get project", () => { 345 | getProjectNock.done(); 346 | }); 347 | test("should query search MRs", () => { 348 | searchMRNock.done(); 349 | }); 350 | test("should query search issues", () => { 351 | searchIssueNock.done(); 352 | }); 353 | test("publisher should publish release note", () => { 354 | expect(GitLabPublisher).toHaveBeenCalledTimes(1); 355 | const mockGitLabPublisherInstance = GitLabPublisher.mock.instances[0]; 356 | expect(mockGitLabPublisherInstance.publish).toHaveBeenCalledTimes(1); 357 | expect(mockGitLabPublisherInstance.publish.mock.calls[0][0]).toMatchSnapshot(); 358 | }); 359 | }); 360 | }); 361 | }); 362 | --------------------------------------------------------------------------------