├── .github └── workflows │ ├── create-pr.js.yml │ ├── node.js.yml │ └── release.js.yml ├── .gitignore ├── .husky └── pre-commit ├── .npmignore ├── LICENSE ├── README.md ├── cli └── index.js ├── package-lock.json ├── package.json ├── src ├── github-client.ts ├── index.ts ├── release-message.ts └── release.mustache ├── test ├── fixtures │ └── test.mustache ├── github-client.ts └── index.ts └── tsconfig.json /.github/workflows/create-pr.js.yml: -------------------------------------------------------------------------------- 1 | name: Create a pull request for QA 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Use Node.js 14 | uses: actions/setup-node@v2 15 | with: 16 | node-version: 16.x 17 | cache: "npm" 18 | 19 | - run: npm ci 20 | - run: npm run build --if-present 21 | - name: Create a release pull request 22 | env: 23 | GITHUB_PR_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | run: | 26 | ./cli/index.js uiur/github-pr-release 27 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [18.x, 20.x] 19 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | cache: "npm" 28 | - run: npm ci 29 | - run: npm run build --if-present 30 | - run: npm test 31 | -------------------------------------------------------------------------------- /.github/workflows/release.js.yml: -------------------------------------------------------------------------------- 1 | name: npm publish 2 | on: 3 | pull_request: 4 | types: [closed] 5 | 6 | # Enable running this workflow manually from the Actions tab 7 | workflow_dispatch: 8 | 9 | jobs: 10 | publish: 11 | # Run this job on master when a release PR is merged 12 | if: ${{ github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'master' }} 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v2 17 | with: 18 | node-version: "16.x" 19 | cache: "npm" 20 | - run: npm ci 21 | - run: npm run build --if-present 22 | - run: npm test 23 | - run: | 24 | npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 25 | git config user.email "kazato.sugimoto@gmail.com" 26 | git config user.name "Kazato Sugimoto" 27 | npm run release --ci 28 | env: 29 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io 2 | 3 | ### Node ### 4 | # Logs 5 | logs 6 | *.log 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 20 | .grunt 21 | 22 | # node-waf configuration 23 | .lock-wscript 24 | 25 | # Compiled binary addons (http://nodejs.org/api/addons.html) 26 | build/Release 27 | 28 | # Dependency directory 29 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 30 | node_modules 31 | 32 | # Debug log from npm 33 | npm-debug.log 34 | 35 | test.js 36 | dist 37 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | test 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Kazato Sugimoto 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # github-pr-release 2 | 3 | [![](https://img.shields.io/npm/v/github-pr-release.svg)](https://www.npmjs.com/package/github-pr-release) 4 | 5 | Create a release pull request using GitHub API. Inspired by [git-pr-release](https://github.com/motemen/git-pr-release). 6 | 7 | - No dependency on git. You can easily deploy it to Heroku / AWS Lambda / Google Cloud Functions etc. 8 | - Fast because it uses only Github API. 9 | - Written in TypeScript / JavaScript. 10 | 11 | [![Gyazo](http://i.gyazo.com/7484a59ade4e96ce9a015f1aa817cab8.png)](http://gyazo.com/7484a59ade4e96ce9a015f1aa817cab8) 12 | 13 | ## Usage 14 | 15 | ### API: release(config) 16 | 17 | Create a release pull request and return Promise. 18 | 19 | You must pass a config as an argument. 20 | 21 | ```javascript 22 | const release = require("github-pr-release"); 23 | 24 | const config = { 25 | token: "your github token", 26 | owner: "uiur", 27 | repo: "awesome-web-app", 28 | head: "master", // optional 29 | base: "production", // optional 30 | template: "/path/to/template.mustache", // optional 31 | }; 32 | 33 | release(config).then(function (pullRequest) { 34 | // success 35 | // `pullRequest` is an object that github api returns. 36 | // See: https://developer.github.com/v3/pulls/#get-a-single-pull-request 37 | }); 38 | ``` 39 | 40 | Also, the following environment variables can be used for the config: 41 | 42 | - `GITHUB_PR_RELEASE_OWNER` 43 | - `GITHUB_PR_RELEASE_REPO` 44 | - `GITHUB_PR_RELEASE_TOKEN` 45 | - `GITHUB_PR_RELEASE_HEAD` 46 | - `GITHUB_PR_RELEASE_BASE` 47 | - `GITHUB_PR_RELEASE_ENDPOINT` 48 | 49 | ### CLI 50 | 51 | You can create a release pull request by the following command: 52 | 53 | ```sh 54 | ❯ npx github-pr-release owner/repo --head master --base production 55 | # `GITHUB_PR_RELEASE_TOKEN` is required 56 | ``` 57 | 58 | `--help`: 59 | 60 | ``` 61 | ❯ npx github-pr-release --help 62 | Usage: github-pr-release [repo] 63 | 64 | Options: 65 | --help Show help [boolean] 66 | --version Show version number [boolean] 67 | --head [default: "master"] 68 | --base [default: "production"] 69 | 70 | Examples: 71 | github-pr-release uiur/github-pr-release --head master --base production 72 | ``` 73 | 74 | ## Install 75 | 76 | ``` 77 | npm install github-pr-release 78 | ``` 79 | 80 | ## Tips 81 | 82 | ### Pull request titles 83 | 84 | If one of pull requests of which consist a release pull request has a title like "Bump to v1.0", the title of the release pull request becomes "Release v1.0". Otherwise, it uses timestamps like "Release 2000-01-01 00:00:00" in local timezone. 85 | 86 | ### Specify a message format 87 | 88 | You can specify a template to change the message format. Pass a template path to `config.template`. 89 | 90 | ```javascript 91 | release({ 92 | token: 'token' 93 | owner: 'uiur', 94 | repo: 'awesome-web-app', 95 | template: './template.mustache' 96 | }) 97 | ``` 98 | 99 | The default template is below. The first line is treated as the title. 100 | 101 | ```mustache 102 | Release {{version}} 103 | {{#prs}} 104 | - [ ] #{{number}} {{title}} {{#assignee}}@{{login}}{{/assignee}}{{^assignee}}{{#user}}@{{login}}{{/user}}{{/assignee}} 105 | {{/prs}} 106 | ``` 107 | 108 | ### GitHub Enterprise 109 | 110 | If you use this plugin in GitHub Enterprise, you can specify endpoint domain for GitHub Enterprise. 111 | 112 | ```javascript 113 | release({ 114 | token: 'token' 115 | owner: 'uiur', 116 | repo: 'awesome-web-app', 117 | endpoint: 'https://github.yourdomain.com/api/v3' 118 | }) 119 | ``` 120 | 121 | ## Example 122 | 123 | ### GitHub Actions 124 | 125 | Creating release pull requests can be automated using GitHub Actions. 126 | 127 | Create `.github/workflows/create-pr-release.yml` with the following content: 128 | 129 | ```yml 130 | name: Create release pull requests 131 | 132 | on: 133 | push: 134 | branches: [master] 135 | 136 | jobs: 137 | build: 138 | runs-on: ubuntu-latest 139 | 140 | steps: 141 | - uses: actions/checkout@v2 142 | - uses: actions/setup-node@v2 143 | with: 144 | node-version: 16.x 145 | cache: "yarn" 146 | 147 | - run: yarn install 148 | - name: Create release pull requests 149 | run: | 150 | npx github-pr-release $GITHUB_REPOSITORY --head master --base production 151 | env: 152 | GITHUB_PR_RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }} 153 | ``` 154 | 155 | ### hubot 156 | 157 | ![](http://i.gyazo.com/018755d09bbc857aeafdf48372912d79.png) 158 | 159 | ```coffee 160 | release = require('github-pr-release') 161 | module.exports = (robot) -> 162 | robot.respond /release/i, (msg) -> 163 | release(config).then((pullRequest) -> 164 | msg.send pullRequest.html_url 165 | ) 166 | .catch((err) -> 167 | msg.send("Create release PR failed: " + err.message) 168 | ) 169 | ``` 170 | 171 | ## Development 172 | 173 | The release flow of github-pr-release is managed with github-pr-release itself. 174 | 175 | It creates a release pull request when merging a topic branch or pushing to the main branch. 176 | The update can be published by merging a release pull request. 177 | 178 | See: 179 | 180 | https://github.com/uiur/github-pr-release/pulls?q=is%3Apr+is%3Aopen+Release 181 | 182 | ## License 183 | 184 | MIT 185 | -------------------------------------------------------------------------------- /cli/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const yargs = require("yargs/yargs"); 4 | const { hideBin } = require("yargs/helpers"); 5 | const argv = yargs(hideBin(process.argv)) 6 | .usage("Usage: $0 [repo]") 7 | .example("$0 uiur/github-pr-release --head master --base production", "") 8 | .demandCommand(1) 9 | .default("head", "master") 10 | .default("base", "production").argv; 11 | 12 | const createReleasePR = require("../"); 13 | 14 | async function main() { 15 | const repoInput = argv._[0]; 16 | const [owner, repo] = repoInput.split("/"); 17 | const config = { 18 | owner, 19 | repo, 20 | head: argv.head, 21 | base: argv.base, 22 | }; 23 | 24 | const pullRequest = await createReleasePR(config); 25 | 26 | console.log(pullRequest.html_url); 27 | } 28 | 29 | main().catch((err) => { 30 | console.error(err); 31 | process.exit(1); 32 | }); 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "github-pr-release", 3 | "version": "1.3.5", 4 | "description": "Create a release pull request by Github API", 5 | "main": "dist/index.js", 6 | "bin": { 7 | "github-pr-release": "cli/index.js" 8 | }, 9 | "author": "Kazato Sugimoto", 10 | "license": "MIT", 11 | "scripts": { 12 | "build": "tsc && cp src/*.mustache dist/", 13 | "test": "mocha -r ts-node/register -r intelli-espower-loader \"test/**/*.ts\"", 14 | "release": "release-it", 15 | "prepare": "husky install" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/uiur/github-pr-release.git" 20 | }, 21 | "dependencies": { 22 | "es6-promise": "^2.3.0", 23 | "moment": "^2.9.0", 24 | "mustache": "^3.0.1", 25 | "parse-link-header": "^2.0.0", 26 | "request": "^2.58.0", 27 | "yargs": "^17.3.1" 28 | }, 29 | "devDependencies": { 30 | "@types/mocha": "^9.1.0", 31 | "@types/mustache": "^3.2.0", 32 | "@types/parse-link-header": "^1.0.1", 33 | "@types/power-assert": "^1.5.8", 34 | "@types/prettier": "2.4.3", 35 | "@types/request": "^2.48.8", 36 | "@types/yargs": "^17.0.8", 37 | "husky": "^7.0.4", 38 | "intelli-espower-loader": "^1.1.0", 39 | "lint-staged": "^12.2.2", 40 | "mocha": "^10.1.0", 41 | "nock": "^10.0.5", 42 | "power-assert": "^0.11.0", 43 | "prettier": "2.5.1", 44 | "release-it": "^15.5.0", 45 | "standard": "^12.0.1", 46 | "ts-node": "^10.4.0", 47 | "typescript": "^4.5.5" 48 | }, 49 | "release-it": { 50 | "github": { 51 | "release": true 52 | }, 53 | "npm": { 54 | "skipChecks": true 55 | } 56 | }, 57 | "engines": { 58 | "node": ">=0.12.0" 59 | }, 60 | "lint-staged": { 61 | "**/*": "prettier --write --ignore-unknown" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/github-client.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | import request, { RequestResponse } from "request"; 4 | import parseLinkHeader from "parse-link-header"; 5 | 6 | interface Config { 7 | owner?: string; 8 | repo?: string; 9 | token?: string; 10 | head?: string; 11 | base?: string; 12 | endpoint?: string; 13 | } 14 | 15 | export interface PullRequest { 16 | title: string; 17 | body: string; 18 | } 19 | 20 | export default class GithubClient { 21 | private owner: string; 22 | private repo: string; 23 | private token: string; 24 | private head: string; 25 | private base: string; 26 | private endpoint: string; 27 | 28 | constructor(config: Config) { 29 | this.owner = config.owner || process.env.GITHUB_PR_RELEASE_OWNER; 30 | this.repo = config.repo || process.env.GITHUB_PR_RELEASE_REPO; 31 | this.token = config.token || process.env.GITHUB_PR_RELEASE_TOKEN; 32 | this.head = config.head || process.env.GITHUB_PR_RELEASE_HEAD || "master"; 33 | this.base = 34 | config.base || process.env.GITHUB_PR_RELEASE_BASE || "production"; 35 | this.endpoint = 36 | config.endpoint || 37 | process.env.GITHUB_PR_RELEASE_ENDPOINT || 38 | "https://api.github.com"; 39 | } 40 | 41 | private pullRequestEndpoint() { 42 | return this.endpoint + "/repos/" + this.owner + "/" + this.repo + "/pulls"; 43 | } 44 | 45 | private headers() { 46 | return { 47 | Authorization: "token " + this.token, 48 | "User-Agent": "uiur/github-pr-release", 49 | }; 50 | } 51 | 52 | private get(url: string, query: object) { 53 | query = query || {}; 54 | 55 | return new Promise((resolve, reject) => { 56 | request.get( 57 | { 58 | url: url, 59 | qs: query, 60 | headers: this.headers(), 61 | json: true, 62 | }, 63 | (err, res) => { 64 | if (err) return reject(err); 65 | resolve(res); 66 | } 67 | ); 68 | }); 69 | } 70 | 71 | private post(url: string, body: object) { 72 | body = body || {}; 73 | 74 | return new Promise((resolve, reject) => { 75 | request.post( 76 | { 77 | url: url, 78 | body: body, 79 | json: true, 80 | headers: this.headers(), 81 | }, 82 | function (err, res, body) { 83 | if (err) return reject(err); 84 | 85 | resolve(res); 86 | } 87 | ); 88 | }); 89 | } 90 | 91 | private patch(url: string, body: object) { 92 | body = body || {}; 93 | 94 | return new Promise((resolve, reject) => { 95 | request.patch( 96 | { 97 | url: url, 98 | body: body, 99 | json: true, 100 | headers: this.headers(), 101 | }, 102 | function (err, res, body) { 103 | if (err) return reject(err); 104 | 105 | resolve(res); 106 | } 107 | ); 108 | }); 109 | } 110 | 111 | async prepareReleasePR() { 112 | const res: any = await this.post(this.pullRequestEndpoint(), { 113 | title: "Preparing release pull request...", 114 | head: this.head, 115 | base: this.base, 116 | }); 117 | 118 | if (res.statusCode === 201) { 119 | return res.body; 120 | } else if (res.statusCode === 422) { 121 | const errMessage = res.body.errors[0].message; 122 | if (!errMessage.match(/pull request already exists/)) { 123 | return Promise.reject(new Error(errMessage)); 124 | } 125 | const res2: any = await this.get(this.pullRequestEndpoint(), { 126 | base: this.base, 127 | head: this.head, 128 | state: "open", 129 | }); 130 | 131 | return res2.body[0]; 132 | } else { 133 | return Promise.reject(new Error(res.body.message)); 134 | } 135 | } 136 | 137 | getPRCommits(pr) { 138 | let result = []; 139 | 140 | const getCommits = (page) => { 141 | page = page || 1; 142 | 143 | return this.get( 144 | this.pullRequestEndpoint() + "/" + pr.number + "/commits", 145 | { 146 | per_page: 100, 147 | page: page, 148 | } 149 | ).then(function (res: any) { 150 | const commits = res.body; 151 | result = result.concat(commits); 152 | 153 | const link = parseLinkHeader(res.headers.link); 154 | 155 | if (link && link.next) { 156 | return getCommits(page + 1); 157 | } else { 158 | return result; 159 | } 160 | }); 161 | }; 162 | 163 | return getCommits(null).catch(console.error.bind(console)); 164 | } 165 | 166 | async collectReleasePRs(releasePR) { 167 | const commits = await this.getPRCommits(releasePR); 168 | const shas = commits.map((commit) => commit.sha); 169 | 170 | return await this.get(this.pullRequestEndpoint(), { 171 | state: "closed", 172 | base: this.head.split(":").at(-1), 173 | per_page: 100, 174 | sort: "updated", 175 | direction: "desc", 176 | }).then(function (res: any) { 177 | const prs = res.body; 178 | 179 | const mergedPRs = prs.filter(function (pr) { 180 | return pr.merged_at !== null; 181 | }); 182 | 183 | const prsToRelease = mergedPRs.reduce(function (result, pr) { 184 | if ( 185 | shas.indexOf(pr.head.sha) > -1 || 186 | shas.indexOf(pr.merge_commit_sha) > -1 187 | ) { 188 | result.push(pr); 189 | } 190 | 191 | return result; 192 | }, []); 193 | 194 | prsToRelease.sort(function (a, b) { 195 | return Number(new Date(a.merged_at)) - Number(new Date(b.merged_at)); 196 | }); 197 | 198 | return prsToRelease; 199 | }); 200 | } 201 | 202 | assignReviewers(pr, prs) { 203 | const reviewers = prs 204 | .map((pr) => (pr.assignee ? pr.assignee : pr.user)) 205 | .filter((user) => user.type === "User") 206 | .map((user) => user.login); 207 | 208 | return this.post( 209 | this.pullRequestEndpoint() + "/" + pr.number + "/requested_reviewers", 210 | { reviewers } 211 | ).then(function (res: any) { 212 | return res.body; 213 | }); 214 | } 215 | 216 | updatePR(pr, data): Promise { 217 | return this.patch(this.pullRequestEndpoint() + "/" + pr.number, data).then( 218 | (res: any) => res.body 219 | ); 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import GithubClient, { PullRequest } from "./github-client"; 2 | import path from "path"; 3 | 4 | import fs from "fs"; 5 | import releaseMessage from "./release-message"; 6 | 7 | interface ReleaseConfig { 8 | token?: string; 9 | owner?: string; 10 | repo?: string; 11 | head?: string; 12 | base?: string; 13 | template?: string; 14 | githubClient?: GithubClient; 15 | } 16 | 17 | export default async function createReleasePR( 18 | config: ReleaseConfig 19 | ): Promise { 20 | const client = config.githubClient || new GithubClient(config); 21 | 22 | const releasePR = await client.prepareReleasePR(); 23 | const prs = await client.collectReleasePRs(releasePR); 24 | const templatePath = 25 | config.template || path.join(__dirname, "release.mustache"); 26 | const template = fs.readFileSync(templatePath, "utf8"); 27 | const message = releaseMessage(template, prs); 28 | 29 | client.assignReviewers(releasePR, prs); 30 | return client.updatePR(releasePR, message); 31 | } 32 | 33 | module.exports = createReleasePR; 34 | -------------------------------------------------------------------------------- /src/release-message.ts: -------------------------------------------------------------------------------- 1 | import { render } from "mustache"; 2 | import moment from "moment"; 3 | 4 | interface ReleaseMessage { 5 | title: string; 6 | body: string; 7 | } 8 | 9 | export default function releaseMessage( 10 | template: string, 11 | prs: any[] 12 | ): ReleaseMessage { 13 | let version = moment().format("YYYY-MM-DD HH:mm:ss"); 14 | 15 | prs.some(function (pr) { 16 | const m = pr.title.match(/Bump to (.*)/i); 17 | 18 | if (m) { 19 | version = m[1]; 20 | return true; 21 | } 22 | }); 23 | 24 | const text: string = render(template, { version: version, prs: prs }); 25 | const lines = text.split("\n"); 26 | const title = lines[0]; 27 | const body = lines.slice(1); 28 | 29 | return { 30 | title: title, 31 | body: body.join("\n"), 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/release.mustache: -------------------------------------------------------------------------------- 1 | Release {{version}} 2 | {{#prs}} 3 | - [ ] #{{number}} {{title}} {{#assignee}}@{{login}}{{/assignee}}{{^assignee}}{{#user}}@{{login}}{{/user}}{{/assignee}} 4 | {{/prs}} 5 | -------------------------------------------------------------------------------- /test/fixtures/test.mustache: -------------------------------------------------------------------------------- 1 | party party 2 | {{#prs}} 3 | party #{{number}} {{title}} 4 | {{/prs}} 5 | -------------------------------------------------------------------------------- /test/github-client.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | import assert from "power-assert"; 3 | import nock from "nock"; 4 | 5 | import GithubClient from "../src/github-client"; 6 | 7 | describe("GithubClient", function () { 8 | before(function () { 9 | this.client = new GithubClient({ 10 | owner: "uiureo", 11 | repo: "awesome-app", 12 | token: "token", 13 | head: "master", 14 | base: "production", 15 | }); 16 | }); 17 | 18 | it("works", function () { 19 | assert(this.client.token === "token"); 20 | }); 21 | 22 | describe("#prepareReleasePR()", function () { 23 | describe("when pr doesn't exist", function () { 24 | nock("https://api.github.com/") 25 | .post("/repos/uiureo/awesome-app/pulls") 26 | .reply(201, { 27 | number: 42, 28 | }); 29 | 30 | it("creates pr", function (done) { 31 | this.client 32 | .prepareReleasePR() 33 | .then(function (pr) { 34 | assert(pr.number === 42); 35 | done(); 36 | }) 37 | .catch(done); 38 | }); 39 | }); 40 | 41 | describe("when pr already exists", function () { 42 | nock("https://api.github.com/") 43 | .post("/repos/uiureo/awesome-app/pulls") 44 | .reply(422, { 45 | message: "Validation Failed", 46 | errors: [ 47 | { 48 | resource: "PullRequest", 49 | code: "custom", 50 | message: "A pull request already exists for uiureo:master.", 51 | }, 52 | ], 53 | documentation_url: 54 | "https://developer.github.com/v3/pulls/#create-a-pull-request", 55 | }); 56 | 57 | nock("https://api.github.com/") 58 | .get("/repos/uiureo/awesome-app/pulls") 59 | .query(true) 60 | .reply(200, [{ number: 3, title: "super big release" }]); 61 | 62 | it("returns the pr", function (done) { 63 | this.client 64 | .prepareReleasePR() 65 | .then(function (pr) { 66 | assert(pr.number === 3); 67 | done(); 68 | }) 69 | .catch(done); 70 | }); 71 | }); 72 | 73 | describe("when no changes between head and base", function () { 74 | nock("https://api.github.com/") 75 | .post("/repos/uiureo/awesome-app/pulls") 76 | .reply(422, { 77 | message: "Validation Failed", 78 | errors: [ 79 | { 80 | resource: "PullRequest", 81 | code: "custom", 82 | message: "No commits between production and master", 83 | }, 84 | ], 85 | documentation_url: 86 | "https://developer.github.com/v3/pulls/#create-a-pull-request", 87 | }); 88 | 89 | it("rejects with error message", function (done) { 90 | this.client.prepareReleasePR().catch(function (error) { 91 | assert(error.message === "No commits between production and master"); 92 | done(); 93 | }); 94 | }); 95 | }); 96 | 97 | describe("when repository is not found", function () { 98 | nock("https://api.github.com/") 99 | .post("/repos/uiureo/awesome-app/pulls") 100 | .reply(404, { 101 | message: "Not Found", 102 | }); 103 | 104 | it("returns an error", function (done) { 105 | this.client.prepareReleasePR().catch(function (error) { 106 | assert(error.message === "Not Found"); 107 | done(); 108 | }); 109 | }); 110 | }); 111 | }); 112 | 113 | describe("#getPRCommits()", function () { 114 | var commitsEndpoint = 115 | "/repos/uiureo/awesome-app/pulls/42/commits?per_page=100"; 116 | 117 | nock("https://api.github.com") 118 | .get(commitsEndpoint + "&page=1") 119 | .reply(200, [{ sha: "sha0" }, { sha: "sha1" }], { 120 | Link: '; rel="next", ; rel="last"', 121 | }) 122 | .get(commitsEndpoint + "&page=2") 123 | .reply(200, [{ sha: "sha2" }, { sha: "sha3" }]); 124 | 125 | it("returns pr", function (done) { 126 | this.client 127 | .getPRCommits({ number: 42 }) 128 | .then(function (commits) { 129 | assert(commits.length === 4); 130 | assert(commits[0].sha === "sha0"); 131 | 132 | done(); 133 | }) 134 | .catch(done); 135 | }); 136 | }); 137 | 138 | describe("#collectReleasePRs()", function () { 139 | nock("https://api.github.com") 140 | .get("/repos/uiureo/awesome-app/pulls/42/commits") 141 | .query(true) 142 | .reply(200, [{ sha: "0" }, { sha: "1" }, { sha: "2" }, { sha: "3" }]) 143 | .get( 144 | "/repos/uiureo/awesome-app/pulls?state=closed&base=master&per_page=100&sort=updated&direction=desc" 145 | ) 146 | .reply(200, [ 147 | { number: 10, head: { sha: "0" }, merged_at: null }, 148 | { 149 | number: 3, 150 | head: { sha: "_3" }, 151 | merged_at: "2015-12-27T00:00:00Z", 152 | merge_commit_sha: "3", 153 | }, 154 | { number: 2, head: { sha: "2" }, merged_at: "2015-12-26T00:00:00Z" }, 155 | { number: 1, head: { sha: "1" }, merged_at: "2015-12-25T00:00:00Z" }, 156 | { 157 | number: 100, 158 | head: { sha: "100" }, 159 | merged_at: "2015-12-27T00:00:00Z", 160 | }, 161 | ]); 162 | 163 | it("returns prs that is going to be released", function (done) { 164 | this.client 165 | .collectReleasePRs({ number: 42 }) 166 | .then(function (prs) { 167 | assert(prs.length === 3); 168 | 169 | var numbers = prs.map(function (pr) { 170 | return pr.number; 171 | }); 172 | assert.deepEqual(numbers, [1, 2, 3], "sorted by merged_at asc"); 173 | 174 | done(); 175 | }) 176 | .catch(done); 177 | }); 178 | }); 179 | 180 | describe("#collectReleasePRs(): head option with `org:`", function () { 181 | nock("https://api.github.com") 182 | .get("/repos/uiureo/awesome-app/pulls/42/commits") 183 | .query(true) 184 | .reply(200, []) 185 | .get( 186 | "/repos/uiureo/awesome-app/pulls?state=closed&base=branch&per_page=100&sort=updated&direction=desc" 187 | ) 188 | .reply(200, []); 189 | 190 | it("returns prs that is going to be released", function (done) { 191 | const client = new GithubClient({ 192 | owner: "uiureo", 193 | repo: "awesome-app", 194 | head: "org:branch", 195 | }); 196 | client 197 | .collectReleasePRs({ number: 42 }) 198 | .then(function (prs) { 199 | assert(prs.length === 0); 200 | done(); 201 | }) 202 | .catch(done); 203 | }); 204 | }); 205 | 206 | describe("#assignReviewers()", function () { 207 | const USER1 = "pr1-owner"; 208 | const USER2 = "pr2-owner"; 209 | const BOT = "bot"; 210 | nock("https://api.github.com") 211 | .post("/repos/uiureo/awesome-app/pulls/42/requested_reviewers") 212 | .query(true) 213 | .reply(200, (_, requestBody) => ({ 214 | requested_reviewers: requestBody.reviewers.map((login) => ({ login })), 215 | })); 216 | 217 | it("returns pr that has reviewers", function (done) { 218 | const prs = [ 219 | { assignee: { login: USER1, type: "User" } }, 220 | { user: { login: USER2, type: "User" } }, 221 | { user: { login: BOT, type: "Bot" } }, 222 | ]; 223 | this.client 224 | .assignReviewers({ number: 42 }, prs) 225 | .then(function (pr) { 226 | assert(pr.requested_reviewers.length === 2); 227 | assert(pr.requested_reviewers[0].login === USER1); 228 | assert(pr.requested_reviewers[1].login === USER2); 229 | 230 | done(); 231 | }) 232 | .catch(done); 233 | }); 234 | }); 235 | 236 | describe("#updatePR()", function () { 237 | nock("https://api.github.com/") 238 | .patch("/repos/uiureo/awesome-app/pulls/42") 239 | .reply(200, { 240 | title: "updated", 241 | number: 42, 242 | }); 243 | 244 | it("updates a PR", function (done) { 245 | var pr = { number: 42 }; 246 | this.client.updatePR(pr, { title: "updated" }).then(function (pr) { 247 | assert(pr.title === "updated"); 248 | done(); 249 | }); 250 | }); 251 | }); 252 | }); 253 | 254 | describe("Github Enterprise support", function () { 255 | before(function () { 256 | this.client = new GithubClient({ 257 | owner: "uiureo", 258 | repo: "awesome-app", 259 | token: "token", 260 | endpoint: "https://ghe.big.company/api/v2", 261 | }); 262 | }); 263 | 264 | describe("#prepareReleasePR()", function () { 265 | nock("https://ghe.big.company") 266 | .post("/api/v2/repos/uiureo/awesome-app/pulls") 267 | .reply(201, { 268 | number: 42, 269 | }); 270 | 271 | it("creates pr", function (done) { 272 | this.client 273 | .prepareReleasePR() 274 | .then(function (pr) { 275 | assert(pr.number === 42); 276 | done(); 277 | }) 278 | .catch(done); 279 | }); 280 | }); 281 | }); 282 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | import assert from "power-assert"; 4 | import release from "../src"; 5 | import GithubClient from "../src/github-client"; 6 | 7 | class MockGithubClient extends GithubClient { 8 | async prepareReleasePR() { 9 | return { number: 42 }; 10 | } 11 | 12 | async collectReleasePRs() { 13 | return [ 14 | { number: 42, title: "foo", user: { login: "uiureo" } }, 15 | { number: 43, title: "bar", user: { login: "hiroshi" } }, 16 | ]; 17 | } 18 | 19 | async updatePR(releasePR, message) { 20 | return message; 21 | } 22 | 23 | async assignReviewers(releasePR, prs) { 24 | return { requested_reviewers: [] }; 25 | } 26 | } 27 | 28 | describe("release()", function () { 29 | it("generates a default PR message", function (done) { 30 | release({ githubClient: new MockGithubClient({}) }) 31 | .then(function (result) { 32 | assert(/^Release/.test(result.title)); 33 | assert(/#42 foo @uiureo/.test(result.body)); 34 | assert(/#43 bar @hiroshi/.test(result.body)); 35 | }) 36 | .then(done, done); 37 | }); 38 | 39 | it("uses the specified template in config", function (done) { 40 | release({ 41 | githubClient: new MockGithubClient({}), 42 | template: "./test/fixtures/test.mustache", 43 | }) 44 | .then(function (result) { 45 | assert(result.title === "party party"); 46 | }) 47 | .then(done, done); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "target": "es6", 8 | "rootDir": "src", 9 | "outDir": "dist", 10 | "noImplicitAny": false 11 | }, 12 | "include": ["src"], 13 | "exclude": ["tests", "node_modules"] 14 | } 15 | --------------------------------------------------------------------------------