├── .gitignore ├── .github └── dependabot.yml ├── .circleci └── config.yml ├── lib ├── util.js ├── git.js ├── update.js ├── common.js ├── merge.js └── api.js ├── action.yml ├── test ├── update.test.js ├── util.test.js ├── common.js ├── api.test.js ├── git.test.js ├── common.test.js └── merge.test.js ├── eslint.config.mjs ├── it └── it.js ├── LICENSE ├── package.json ├── bin └── automerge.js ├── README.md └── dist └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .env 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: cimg/node:20.10.0 6 | steps: 7 | - checkout 8 | - run: 9 | name: yarn 10 | command: yarn 11 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | function branchName(ref) { 2 | const branchPrefix = "refs/heads/"; 3 | if (ref.startsWith(branchPrefix)) { 4 | return ref.substr(branchPrefix.length); 5 | } 6 | } 7 | 8 | module.exports = { branchName }; 9 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "Merge pull requests (automerge-action)" 2 | description: "Automatically merge pull requests that are ready" 3 | runs: 4 | using: "node20" 5 | main: "dist/index.js" 6 | branding: 7 | icon: "git-pull-request" 8 | color: "blue" 9 | -------------------------------------------------------------------------------- /test/update.test.js: -------------------------------------------------------------------------------- 1 | const { update } = require("../lib/update"); 2 | const { createConfig } = require("../lib/common"); 3 | const { pullRequest } = require("./common"); 4 | 5 | test("update will only run when the label matches", async () => { 6 | const pr = pullRequest(); 7 | const config = createConfig({ UPDATE_LABELS: "none" }); 8 | expect(await update({ config }, pr)).toEqual(false); 9 | }); 10 | -------------------------------------------------------------------------------- /test/util.test.js: -------------------------------------------------------------------------------- 1 | const { branchName } = require("../lib/util"); 2 | 3 | describe("branchName", () => { 4 | it("returns the branch name from a reference referring to a branch", async () => { 5 | expect(branchName("refs/heads/main")).toEqual("main"); 6 | expect(branchName("refs/heads/features/branch_with_slashes")).toEqual( 7 | "features/branch_with_slashes" 8 | ); 9 | }); 10 | 11 | it("is falsey for other kinds of git references", async () => { 12 | expect(branchName("refs/tags/v1.0")).toBeUndefined(); 13 | expect(branchName("refs/remotes/origin/main")).toBeUndefined(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import jest from "eslint-plugin-jest"; 3 | import globals from "globals"; 4 | 5 | export default [ 6 | js.configs.recommended, 7 | { 8 | languageOptions: { 9 | parserOptions: { 10 | ecmaVersion: 2017, 11 | sourceType: "module" 12 | }, 13 | globals: { 14 | ...globals.node, 15 | ...globals.es2015, 16 | ...globals.jest 17 | } 18 | }, 19 | plugins: { jest }, 20 | rules: { 21 | semi: "error", 22 | "no-tabs": "error", 23 | "no-console": "off" 24 | } 25 | }, 26 | { 27 | ignores: ["eslint.config.mjs", "dist/*"] 28 | } 29 | ]; 30 | -------------------------------------------------------------------------------- /test/common.js: -------------------------------------------------------------------------------- 1 | function pullRequest() { 2 | return { 3 | number: 1, 4 | title: "Update README", 5 | body: "This PR updates the README", 6 | state: "open", 7 | locked: false, 8 | merged: false, 9 | mergeable: true, 10 | rebaseable: true, 11 | mergeable_state: "clean", 12 | commits: 2, 13 | labels: [{ name: "automerge" }], 14 | head: { 15 | ref: "patch-1", 16 | sha: "2c3b4d5", 17 | user: { login: "username" }, 18 | repo: { 19 | name: "repository", 20 | full_name: "username/repository", 21 | owner: { login: "username" } 22 | } 23 | }, 24 | base: { 25 | ref: "master", 26 | sha: "45600fe", 27 | user: { login: "username" }, 28 | repo: { 29 | name: "repository", 30 | full_name: "username/repository", 31 | owner: { login: "username" } 32 | } 33 | } 34 | }; 35 | } 36 | 37 | module.exports = { pullRequest }; 38 | -------------------------------------------------------------------------------- /it/it.js: -------------------------------------------------------------------------------- 1 | const { Octokit } = require("@octokit/rest"); 2 | 3 | const { executeLocally } = require("../lib/api"); 4 | const { createConfig } = require("../lib/common"); 5 | 6 | async function main() { 7 | require("dotenv").config(); 8 | 9 | const token = process.env.GITHUB_TOKEN; 10 | 11 | const octokit = new Octokit({ 12 | baseUrl: "https://api.github.com", 13 | auth: `token ${token}`, 14 | userAgent: "pascalgn/automerge-action-it" 15 | }); 16 | 17 | const config = createConfig({ 18 | UPDATE_LABELS: "it-update", 19 | MERGE_LABELS: "it-merge", 20 | MERGE_REQUIRED_APPROVALS: "0", 21 | MERGE_REMOVE_LABELS: "it-merge", 22 | MERGE_RETRIES: "3", 23 | MERGE_RETRY_SLEEP: "2000", 24 | MERGE_ERROR_FAIL: "true" 25 | }); 26 | 27 | const context = { token, octokit, config }; 28 | 29 | await executeLocally(context, process.env.URL); 30 | } 31 | 32 | main().catch(e => { 33 | process.exitCode = 1; 34 | console.error(e); 35 | }); 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright 2019 Pascal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "automerge-action", 3 | "version": "0.16.3", 4 | "description": "GitHub action to automatically merge pull requests", 5 | "main": "lib/api.js", 6 | "author": "Pascal", 7 | "license": "MIT", 8 | "private": true, 9 | "bin": { 10 | "automerge-action": "./bin/automerge.js" 11 | }, 12 | "scripts": { 13 | "test": "jest", 14 | "it": "node it/it.js", 15 | "lint": "prettier -l lib/** test/** && eslint .", 16 | "compile": "ncc build bin/automerge.js --license LICENSE -o dist", 17 | "prepublish": "yarn lint && yarn test && yarn compile" 18 | }, 19 | "dependencies": { 20 | "@actions/core": "^1.10.1", 21 | "@octokit/rest": "^20.1.0", 22 | "argparse": "^2.0.1", 23 | "fs-extra": "^11.2.0", 24 | "object-resolve-path": "^1.1.1", 25 | "tmp": "^0.2.3" 26 | }, 27 | "devDependencies": { 28 | "@vercel/ncc": "^0.38.1", 29 | "dotenv": "^16.4.5", 30 | "eslint": "^9.0.0", 31 | "eslint-plugin-jest": "^28.2.0", 32 | "globals": "^15.0.0", 33 | "jest": "^29.7.0", 34 | "prettier": "^3.2.5" 35 | }, 36 | "prettier": { 37 | "trailingComma": "none", 38 | "arrowParens": "avoid" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/api.test.js: -------------------------------------------------------------------------------- 1 | const api = require("../lib/api"); 2 | const { createConfig } = require("../lib/common"); 3 | const { pullRequest } = require("./common"); 4 | 5 | let octokit; 6 | 7 | test("forked PR check_suite/check_run updates are handled", async () => { 8 | // GIVEN 9 | const head_sha = "1234abcd"; 10 | const pr = pullRequest(); 11 | pr.labels = [{ name: "automerge" }]; 12 | pr.head.sha = head_sha; 13 | 14 | const config = createConfig({}); 15 | 16 | let merged = false; 17 | octokit = { 18 | pulls: { 19 | list: jest.fn(() => ({ data: [pr] })), 20 | merge: jest.fn(() => (merged = true)), 21 | listReviews: jest.fn(() => ({ data: [] })) 22 | } 23 | }; 24 | 25 | const event = { 26 | action: "completed", 27 | repository: { owner: { login: "other-username" }, name: "repository" }, 28 | check_suite: { conclusion: "success", head_sha, pull_requests: [] } 29 | }; 30 | 31 | // WHEN 32 | await api.executeGitHubAction({ config, octokit }, "check_suite", event); 33 | expect(merged).toEqual(true); 34 | }); 35 | 36 | test("only merge PRs with required approvals", async () => { 37 | // GIVEN 38 | const head_sha = "1234abcd"; 39 | const pr = pullRequest(); 40 | pr.labels = [{ name: "automerge" }]; 41 | pr.head.sha = head_sha; 42 | 43 | const config = createConfig({}); 44 | config.mergeRequiredApprovals = 2; // let's only merge, if there are two independent approvals 45 | 46 | let merged = false; 47 | octokit = { 48 | pulls: { 49 | list: jest.fn(() => ({ data: [pr] })), 50 | merge: jest.fn(() => (merged = true)), 51 | listReviews: Symbol("listReviews") 52 | }, 53 | paginate: jest.fn(() => []) 54 | }; 55 | 56 | const event = { 57 | action: "completed", 58 | repository: { owner: { login: "other-username" }, name: "repository" }, 59 | check_suite: { conclusion: "success", head_sha, pull_requests: [] } 60 | }; 61 | 62 | // WHEN 63 | await api.executeGitHubAction({ config, octokit }, "check_suite", event); 64 | expect(merged).toEqual(false); // if there's no approval, it should fail 65 | 66 | merged = false; 67 | octokit.paginate.mockReturnValueOnce([ 68 | { state: "APPROVED", user: { login: "approval_user" } }, 69 | { state: "APPROVED", user: { login: "approval_user2" } } 70 | ]); 71 | 72 | // WHEN 73 | await api.executeGitHubAction({ config, octokit }, "check_suite", event); 74 | expect(merged).toEqual(true); // if there are two approvals, it should succeed 75 | 76 | merged = false; 77 | octokit.paginate.mockReturnValueOnce([ 78 | { state: "APPROVED", user: { login: "approval_user" } }, 79 | { state: "APPROVED", user: { login: "approval_user" } } 80 | ]); 81 | 82 | // WHEN a user has given 83 | await api.executeGitHubAction({ config, octokit }, "check_suite", event); 84 | expect(merged).toEqual(false); // if there are only two approvals from the same user, it should fail 85 | }); 86 | -------------------------------------------------------------------------------- /bin/automerge.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const process = require("process"); 4 | 5 | const fse = require("fs-extra"); 6 | const { ArgumentParser } = require("argparse"); 7 | const { Octokit } = require("@octokit/rest"); 8 | 9 | const { ClientError, logger, createConfig } = require("../lib/common"); 10 | const { executeLocally, executeGitHubAction } = require("../lib/api"); 11 | 12 | const pkg = require("../package.json"); 13 | 14 | const OLD_CONFIG = [ 15 | "MERGE_LABEL", 16 | "UPDATE_LABEL", 17 | "LABELS", 18 | "AUTOMERGE", 19 | "AUTOREBASE", 20 | "COMMIT_MESSAGE_TEMPLATE", 21 | "TOKEN" 22 | ]; 23 | 24 | const GITHUB_API_URL = process.env.GITHUB_API_URL || "https://api.github.com"; 25 | 26 | async function main() { 27 | const parser = new ArgumentParser({ 28 | prog: pkg.name, 29 | add_help: true, 30 | description: pkg.description 31 | }); 32 | parser.add_argument("-v", "--version", { 33 | action: "version", 34 | version: pkg.version, 35 | help: "Show version number and exit" 36 | }); 37 | parser.add_argument("url", { 38 | metavar: "", 39 | nargs: "?", 40 | help: "GitHub URL to process instead of environment variables" 41 | }); 42 | 43 | const args = parser.parse_args(); 44 | 45 | if (process.env.LOG === "TRACE") { 46 | logger.level = "trace"; 47 | } else if (process.env.LOG === "DEBUG") { 48 | logger.level = "debug"; 49 | } else if (process.env.LOG && process.env.LOG.length > 0) { 50 | logger.error("Invalid log level:", process.env.LOG); 51 | } 52 | 53 | checkOldConfig(); 54 | 55 | const token = env("GITHUB_TOKEN"); 56 | 57 | const octokit = new Octokit({ 58 | baseUrl: GITHUB_API_URL, 59 | auth: `token ${token}`, 60 | userAgent: "pascalgn/automerge-action" 61 | }); 62 | 63 | const config = createConfig(process.env); 64 | logger.debug("Configuration:", config); 65 | 66 | const context = { token, octokit, config }; 67 | 68 | if (args.url) { 69 | await executeLocally(context, args.url); 70 | } else { 71 | const eventPath = env("GITHUB_EVENT_PATH"); 72 | const eventName = env("GITHUB_EVENT_NAME"); 73 | 74 | const eventDataStr = await fse.readFile(eventPath, "utf8"); 75 | const eventData = JSON.parse(eventDataStr); 76 | 77 | await executeGitHubAction(context, eventName, eventData); 78 | } 79 | } 80 | 81 | function checkOldConfig() { 82 | let error = false; 83 | for (const old of OLD_CONFIG) { 84 | if (process.env[old] != null) { 85 | logger.error("Old configuration option present:", old); 86 | error = true; 87 | } 88 | } 89 | if (error) { 90 | logger.error( 91 | "You have passed configuration options that were used by an old " + 92 | "version of this action. Please see " + 93 | "https://github.com/pascalgn/automerge-action for the latest " + 94 | "documentation of the configuration options!" 95 | ); 96 | throw new Error(`old configuration present!`); 97 | } 98 | } 99 | 100 | function env(name) { 101 | const val = process.env[name]; 102 | if (!val || !val.length) { 103 | throw new ClientError(`environment variable ${name} not set!`); 104 | } 105 | return val; 106 | } 107 | 108 | if (require.main === module) { 109 | main().catch(e => { 110 | if (e instanceof ClientError) { 111 | process.exitCode = 2; 112 | logger.error(e); 113 | } else { 114 | process.exitCode = 1; 115 | logger.error(e); 116 | } 117 | }); 118 | } 119 | -------------------------------------------------------------------------------- /test/git.test.js: -------------------------------------------------------------------------------- 1 | const fse = require("fs-extra"); 2 | 3 | const git = require("../lib/git"); 4 | const { tmpdir } = require("../lib/common"); 5 | 6 | async function init(dir) { 7 | await fse.mkdirs(dir); 8 | await git.git(dir, "init"); 9 | await git.git(dir, "checkout", "-b", "main"); 10 | } 11 | 12 | async function commit(dir, message = "C%d", count = 1) { 13 | for (let i = 1; i <= count; i++) { 14 | await git.git( 15 | dir, 16 | "commit", 17 | "--allow-empty", 18 | "-m", 19 | message.replace(/%d/g, i) 20 | ); 21 | } 22 | } 23 | 24 | test("clone creates the target directory", async () => { 25 | await tmpdir(async path => { 26 | await init(`${path}/origin`); 27 | await commit(`${path}/origin`); 28 | await git.clone(`${path}/origin`, `${path}/ws`, "main", 1); 29 | expect(await fse.exists(`${path}/ws`)).toBe(true); 30 | }); 31 | }); 32 | 33 | test("fetchUntilMergeBase finds the correct merge base", async () => { 34 | await tmpdir(async path => { 35 | const origin = `${path}/origin`; 36 | await init(origin); 37 | await commit(origin, "base %d", 10); 38 | const base = await git.head(origin); 39 | await git.git(origin, "checkout", "-b", "br1"); 40 | await commit(origin, "br1 %d", 20); 41 | await git.git(origin, "checkout", "main"); 42 | await commit(origin, "main %d", 20); 43 | 44 | const ws = `${path}/ws`; 45 | await git.clone(`${path}/origin`, ws, "br1"); 46 | await git.fetch(ws, "main"); 47 | expect(await git.fetchUntilMergeBase(ws, "main", 10000)).toBe(base); 48 | }); 49 | }, 15000); 50 | 51 | test("fetchUntilMergeBase finds the earliest merge base 1", async () => { 52 | await tmpdir(async path => { 53 | const origin = `${path}/origin`; 54 | await init(origin); 55 | await commit(origin, "base %d", 10); 56 | const base = await git.head(origin); 57 | await git.git(origin, "branch", "br1"); 58 | await commit(origin, "main %d", 10); 59 | await git.git(origin, "checkout", "br1"); 60 | await commit(origin, "br1 before merge %d", 5); 61 | await git.git(origin, "merge", "--no-ff", "main"); 62 | await commit(origin, "br1 after merge %d", 10); 63 | await git.git(origin, "checkout", "main"); 64 | await commit(origin, "main after merge %d", 10); 65 | 66 | const ws = `${path}/ws`; 67 | await git.clone(`${path}/origin`, ws, "br1"); 68 | await git.fetch(ws, "main"); 69 | expect(await git.fetchUntilMergeBase(ws, "main", 10000)).toBe(base); 70 | }); 71 | }, 15000); 72 | 73 | test("fetchUntilMergeBase finds the earliest merge base 2", async () => { 74 | await tmpdir(async path => { 75 | const origin = `${path}/origin`; 76 | await init(origin); 77 | await commit(origin, "base a%d", 5); 78 | const base = await git.head(origin); 79 | await commit(origin, "base b%d", 5); 80 | await git.git(origin, "branch", "br1"); 81 | await commit(origin, "main %d", 10); 82 | await git.git(origin, "checkout", "br1"); 83 | await commit(origin, "br1 before merge %d", 5); 84 | await git.git(origin, "merge", "--no-ff", "main"); 85 | await commit(origin, "br1 after merge %d", 10); 86 | await git.git(origin, "checkout", "main"); 87 | await commit(origin, "main after merge %d", 10); 88 | await git.git(origin, "checkout", "-b", "br2", base); 89 | await commit(origin, "br2"); 90 | await git.git(origin, "checkout", "br1"); 91 | await git.git(origin, "merge", "--no-ff", "br2"); 92 | 93 | const ws = `${path}/ws`; 94 | await git.clone(`${path}/origin`, ws, "br1"); 95 | await git.fetch(ws, "main"); 96 | expect(await git.fetchUntilMergeBase(ws, "main", 10000)).toBe(base); 97 | }); 98 | }, 15000); 99 | 100 | test("mergeCommits returns the correct commits", async () => { 101 | await tmpdir(async path => { 102 | await init(path); 103 | await commit(path, "main %d", 2); 104 | const head1 = await git.head(path); 105 | await git.git(path, "checkout", "-b", "branch", "HEAD^"); 106 | const head2 = await git.head(path); 107 | await git.git(path, "merge", "--no-ff", "main"); 108 | 109 | const commits = await git.mergeCommits(path, "HEAD^"); 110 | expect(commits).toHaveLength(1); 111 | expect(commits[0][0]).toBe(head2); 112 | expect(commits[0][1]).toBe(head1); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /lib/git.js: -------------------------------------------------------------------------------- 1 | const { spawn } = require("child_process"); 2 | 3 | const { TimeoutError, logger } = require("./common"); 4 | 5 | class ExitError extends Error { 6 | constructor(message, code) { 7 | super(message); 8 | this.code = code; 9 | } 10 | } 11 | 12 | const FETCH_DEPTH = 10; 13 | 14 | const COMMON_ARGS = [ 15 | "-c", 16 | "user.name=GitHub", 17 | "-c", 18 | "user.email=noreply@github.com" 19 | ]; 20 | 21 | function git(cwd, ...args) { 22 | const stdio = [ 23 | "ignore", 24 | "pipe", 25 | logger.level === "trace" || logger.level === "debug" ? "inherit" : "ignore" 26 | ]; 27 | // the URL passed to the clone command could contain a password! 28 | const command = args.includes("clone") 29 | ? "git clone" 30 | : `git ${args.join(" ")}`; 31 | logger.debug("Executing", command); 32 | return new Promise((resolve, reject) => { 33 | const proc = spawn( 34 | "git", 35 | COMMON_ARGS.concat(args.filter(a => a !== null)), 36 | { cwd, stdio } 37 | ); 38 | const buffers = []; 39 | proc.stdout.on("data", data => buffers.push(data)); 40 | proc.on("error", () => { 41 | reject(new Error(`command failed: ${command}`)); 42 | }); 43 | proc.on("exit", code => { 44 | if (code === 0) { 45 | const data = Buffer.concat(buffers); 46 | resolve(data.toString("utf8").trim()); 47 | } else { 48 | reject( 49 | new ExitError(`command failed with code ${code}: ${command}`, code) 50 | ); 51 | } 52 | }); 53 | }); 54 | } 55 | 56 | async function clone(from, to, branch) { 57 | await git( 58 | ".", 59 | "clone", 60 | "--quiet", 61 | "--shallow-submodules", 62 | "--branch", 63 | branch, 64 | "--depth", 65 | FETCH_DEPTH, 66 | from, 67 | to 68 | ); 69 | } 70 | 71 | async function fetch(dir, branch) { 72 | await git( 73 | dir, 74 | "fetch", 75 | "--quiet", 76 | "--depth", 77 | FETCH_DEPTH, 78 | "origin", 79 | `${branch}:refs/remotes/origin/${branch}` 80 | ); 81 | } 82 | 83 | async function fetchUntilMergeBase(dir, branch, timeout) { 84 | const maxTime = new Date().getTime() + timeout; 85 | const ref = `refs/remotes/origin/${branch}`; 86 | while (new Date().getTime() < maxTime) { 87 | const base = await mergeBase(dir, "HEAD", ref); 88 | if (base) { 89 | const bases = [base]; 90 | const parents = await mergeCommits(dir, ref); 91 | let fetchMore = false; 92 | for (const parent of parents.flat()) { 93 | const b = await mergeBase(dir, parent, ref); 94 | if (b) { 95 | if (!bases.includes(b)) { 96 | bases.push(b); 97 | } 98 | } else { 99 | // we found a commit which does not have a common ancestor with 100 | // the branch we want to merge, so we need to fetch more 101 | fetchMore = true; 102 | break; 103 | } 104 | } 105 | if (!fetchMore) { 106 | const commonBase = await mergeBase(dir, ...bases); 107 | if (!commonBase) { 108 | throw new Error(`failed to find common base for ${bases}`); 109 | } 110 | return commonBase; 111 | } 112 | } 113 | await fetchDeepen(dir); 114 | } 115 | throw new TimeoutError(); 116 | } 117 | 118 | async function fetchDeepen(dir) { 119 | await git(dir, "fetch", "--quiet", "--deepen", FETCH_DEPTH); 120 | } 121 | 122 | async function mergeBase(dir, ...refs) { 123 | if (refs.length === 1) { 124 | return refs[0]; 125 | } else if (refs.length < 1) { 126 | throw new Error("empty refs!"); 127 | } 128 | let todo = refs; 129 | try { 130 | while (todo.length > 1) { 131 | const base = await git(dir, "merge-base", todo[0], todo[1]); 132 | todo = [base].concat(todo.slice(2)); 133 | } 134 | return todo[0]; 135 | } catch (e) { 136 | if (e instanceof ExitError && e.code === 1) { 137 | return null; 138 | } else { 139 | throw e; 140 | } 141 | } 142 | } 143 | 144 | async function mergeCommits(dir, ref) { 145 | return (await git(dir, "rev-list", "--parents", `${ref}..HEAD`)) 146 | .split(/\n/g) 147 | .map(line => line.split(/ /g).slice(1)) 148 | .filter(commit => commit.length > 1); 149 | } 150 | 151 | async function head(dir) { 152 | return await git(dir, "show-ref", "--head", "-s", "/HEAD"); 153 | } 154 | 155 | async function sha(dir, branch) { 156 | return await git(dir, "show-ref", "-s", `refs/remotes/origin/${branch}`); 157 | } 158 | 159 | async function rebase(dir, branch) { 160 | return await git(dir, "rebase", "--quiet", "--autosquash", branch); 161 | } 162 | 163 | async function push(dir, force, branch) { 164 | return await git( 165 | dir, 166 | "push", 167 | "--quiet", 168 | force ? "--force-with-lease" : null, 169 | "origin", 170 | branch 171 | ); 172 | } 173 | 174 | module.exports = { 175 | ExitError, 176 | git, 177 | clone, 178 | fetch, 179 | fetchUntilMergeBase, 180 | fetchDeepen, 181 | mergeBase, 182 | mergeCommits, 183 | head, 184 | sha, 185 | rebase, 186 | push 187 | }; 188 | -------------------------------------------------------------------------------- /test/common.test.js: -------------------------------------------------------------------------------- 1 | const { createConfig } = require("../lib/common"); 2 | 3 | test("createConfig", () => { 4 | const config = createConfig({ 5 | UPDATE_LABELS: " required1,! block1, ! ,required2, !block2 ", 6 | MERGE_LABELS: "", 7 | MERGE_RETRIES: "3", 8 | BASE_BRANCHES: "dev,main" 9 | }); 10 | const expected = { 11 | mergeMethod: "merge", 12 | mergeFilterAuthor: "", 13 | mergeLabels: { 14 | blocking: [], 15 | required: [] 16 | }, 17 | mergeMethodLabels: [], 18 | mergeMethodLabelRequired: false, 19 | mergeForks: true, 20 | mergeCommitMessage: "automatic", 21 | mergeCommitMessageRegex: "", 22 | mergeDeleteBranch: false, 23 | mergeDeleteBranchFilter: [], 24 | mergeErrorFail: false, 25 | mergeReadyState: ["clean", "has_hooks", "unknown", "unstable"], 26 | mergeRetries: 3, 27 | mergeRetrySleep: 5000, 28 | mergeRequiredApprovals: 0, 29 | mergeRemoveLabels: [], 30 | updateMethod: "merge", 31 | updateLabels: { 32 | blocking: ["block1", "block2"], 33 | required: ["required1", "required2"] 34 | }, 35 | updateRetries: 1, 36 | updateRetrySleep: 5000, 37 | baseBranches: ["dev", "main"], 38 | pullRequest: null 39 | }; 40 | expect(config).toEqual(expected); 41 | }); 42 | 43 | test("createConfig with arbitrary pull request (as string)", () => { 44 | const config = createConfig({ 45 | UPDATE_LABELS: " required1,! block1, ! ,required2, !block2 ", 46 | MERGE_LABELS: "", 47 | MERGE_RETRIES: "3", 48 | PULL_REQUEST: "144" 49 | }); 50 | const expected = { 51 | mergeMethod: "merge", 52 | mergeFilterAuthor: "", 53 | mergeLabels: { 54 | blocking: [], 55 | required: [] 56 | }, 57 | mergeMethodLabels: [], 58 | mergeMethodLabelRequired: false, 59 | mergeForks: true, 60 | mergeCommitMessage: "automatic", 61 | mergeCommitMessageRegex: "", 62 | mergeDeleteBranch: false, 63 | mergeDeleteBranchFilter: [], 64 | mergeErrorFail: false, 65 | mergeReadyState: ["clean", "has_hooks", "unknown", "unstable"], 66 | mergeRetries: 3, 67 | mergeRetrySleep: 5000, 68 | mergeRequiredApprovals: 0, 69 | mergeRemoveLabels: [], 70 | updateMethod: "merge", 71 | updateLabels: { 72 | blocking: ["block1", "block2"], 73 | required: ["required1", "required2"] 74 | }, 75 | updateRetries: 1, 76 | updateRetrySleep: 5000, 77 | baseBranches: [], 78 | pullRequest: { 79 | pullRequestNumber: 144 80 | } 81 | }; 82 | expect(config).toEqual(expected); 83 | }); 84 | 85 | test("createConfig with arbitrary pull request (as number)", () => { 86 | const config = createConfig({ 87 | UPDATE_LABELS: " required1,! block1, ! ,required2, !block2 ", 88 | MERGE_LABELS: "", 89 | MERGE_RETRIES: "3", 90 | PULL_REQUEST: 144 91 | }); 92 | const expected = { 93 | mergeMethod: "merge", 94 | mergeFilterAuthor: "", 95 | mergeLabels: { 96 | blocking: [], 97 | required: [] 98 | }, 99 | mergeMethodLabels: [], 100 | mergeMethodLabelRequired: false, 101 | mergeForks: true, 102 | mergeCommitMessage: "automatic", 103 | mergeCommitMessageRegex: "", 104 | mergeDeleteBranch: false, 105 | mergeDeleteBranchFilter: [], 106 | mergeErrorFail: false, 107 | mergeReadyState: ["clean", "has_hooks", "unknown", "unstable"], 108 | mergeRetries: 3, 109 | mergeRetrySleep: 5000, 110 | mergeRequiredApprovals: 0, 111 | mergeRemoveLabels: [], 112 | updateMethod: "merge", 113 | updateLabels: { 114 | blocking: ["block1", "block2"], 115 | required: ["required1", "required2"] 116 | }, 117 | updateRetries: 1, 118 | updateRetrySleep: 5000, 119 | baseBranches: [], 120 | pullRequest: { 121 | pullRequestNumber: 144 122 | } 123 | }; 124 | expect(config).toEqual(expected); 125 | }); 126 | 127 | test("createConfig with arbitrary pull request in another repo", () => { 128 | const config = createConfig({ 129 | UPDATE_LABELS: " required1,! block1, ! ,required2, !block2 ", 130 | MERGE_LABELS: "", 131 | MERGE_RETRIES: "3", 132 | PULL_REQUEST: "pascalgn/automerge-action/144" 133 | }); 134 | const expected = { 135 | mergeMethod: "merge", 136 | mergeFilterAuthor: "", 137 | mergeLabels: { 138 | blocking: [], 139 | required: [] 140 | }, 141 | mergeMethodLabels: [], 142 | mergeMethodLabelRequired: false, 143 | mergeForks: true, 144 | mergeCommitMessage: "automatic", 145 | mergeCommitMessageRegex: "", 146 | mergeDeleteBranch: false, 147 | mergeDeleteBranchFilter: [], 148 | mergeErrorFail: false, 149 | mergeReadyState: ["clean", "has_hooks", "unknown", "unstable"], 150 | mergeRetries: 3, 151 | mergeRetrySleep: 5000, 152 | mergeRequiredApprovals: 0, 153 | mergeRemoveLabels: [], 154 | updateMethod: "merge", 155 | updateLabels: { 156 | blocking: ["block1", "block2"], 157 | required: ["required1", "required2"] 158 | }, 159 | updateRetries: 1, 160 | updateRetrySleep: 5000, 161 | baseBranches: [], 162 | pullRequest: { 163 | repoOwner: "pascalgn", 164 | repoName: "automerge-action", 165 | pullRequestNumber: 144 166 | } 167 | }; 168 | expect(config).toEqual(expected); 169 | }); 170 | -------------------------------------------------------------------------------- /lib/update.js: -------------------------------------------------------------------------------- 1 | const { logger, tmpdir, sleep } = require("./common"); 2 | const git = require("./git"); 3 | 4 | const FETCH_TIMEOUT = 60000; 5 | 6 | async function update(context, pullRequest) { 7 | if (skipPullRequest(context, pullRequest)) { 8 | return false; 9 | } 10 | 11 | logger.info(`Updating PR #${pullRequest.number} ${pullRequest.title}`); 12 | 13 | const { head } = pullRequest; 14 | 15 | const { 16 | token, 17 | octokit, 18 | config: { updateMethod, updateRetries, updateRetrySleep } 19 | } = context; 20 | 21 | let newSha; 22 | 23 | if (updateMethod === "merge") { 24 | newSha = await merge(octokit, updateRetries, updateRetrySleep, pullRequest); 25 | } else if (updateMethod === "rebase") { 26 | const { full_name } = head.repo; 27 | const url = `https://x-access-token:${token}@github.com/${full_name}.git`; 28 | newSha = await tmpdir(path => rebase(path, url, pullRequest)); 29 | } else { 30 | throw new Error(`invalid update method: ${updateMethod}`); 31 | } 32 | 33 | if (newSha != null && newSha != head.sha) { 34 | head.sha = newSha; 35 | return true; 36 | } else { 37 | return false; 38 | } 39 | } 40 | 41 | function skipPullRequest(context, pullRequest) { 42 | const { 43 | config: { updateLabels } 44 | } = context; 45 | 46 | let skip = false; 47 | 48 | if (pullRequest.state !== "open") { 49 | logger.info("Skipping PR update, state is not open:", pullRequest.state); 50 | skip = true; 51 | } 52 | 53 | if (pullRequest.merged === true) { 54 | logger.info("Skipping PR update, already merged!"); 55 | skip = true; 56 | } 57 | 58 | const labels = pullRequest.labels.map(label => label.name); 59 | 60 | for (const label of pullRequest.labels) { 61 | if (updateLabels.blocking.includes(label.name)) { 62 | logger.info("Skipping PR update, blocking label present:", label.name); 63 | skip = true; 64 | } 65 | } 66 | 67 | for (const required of updateLabels.required) { 68 | if (!labels.includes(required)) { 69 | logger.info("Skipping PR update, required label missing:", required); 70 | skip = true; 71 | } 72 | } 73 | 74 | return skip; 75 | } 76 | 77 | async function merge(octokit, updateRetries, updateRetrySleep, pullRequest) { 78 | const mergeableState = await pullRequestState( 79 | octokit, 80 | updateRetries, 81 | updateRetrySleep, 82 | pullRequest 83 | ); 84 | if (mergeableState === "behind") { 85 | const headRef = pullRequest.head.ref; 86 | const baseRef = pullRequest.base.ref; 87 | 88 | logger.debug("Merging latest changes from", baseRef, "into", headRef); 89 | const { status, data } = await octokit.repos.merge({ 90 | owner: pullRequest.base.repo.owner.login, 91 | repo: pullRequest.base.repo.name, 92 | base: headRef, 93 | head: baseRef 94 | }); 95 | 96 | logger.trace("Merge result:", status, data); 97 | 98 | if (status === 204) { 99 | logger.info("No merge performed, branch is up to date!"); 100 | return pullRequest.head.sha; 101 | } else { 102 | logger.info("Merge succeeded, new HEAD:", headRef, data.sha); 103 | return data.sha; 104 | } 105 | } else if (mergeableState === "clean" || mergeableState === "has_hooks") { 106 | logger.info("No update necessary, mergeable_state:", mergeableState); 107 | return pullRequest.head.sha; 108 | } else { 109 | logger.info("No update done due to PR mergeable_state", mergeableState); 110 | return null; 111 | } 112 | } 113 | 114 | async function pullRequestState( 115 | octokit, 116 | updateRetries, 117 | updateRetrySleep, 118 | pullRequest 119 | ) { 120 | if (pullRequest.mergeable_state != null) { 121 | return pullRequest.mergeable_state; 122 | } else { 123 | logger.debug("Getting pull request info for", pullRequest.number, "..."); 124 | let { data: fullPullRequest } = await octokit.pulls.get({ 125 | owner: pullRequest.base.repo.owner.login, 126 | repo: pullRequest.base.repo.name, 127 | pull_number: pullRequest.number 128 | }); 129 | 130 | logger.trace("Full PR:", fullPullRequest); 131 | 132 | for (let run = 1; run <= updateRetries; run++) { 133 | if (fullPullRequest.mergeable_state != null) { 134 | break; 135 | } else { 136 | logger.info("Unknown PR state, mergeable_state: null"); 137 | logger.info( 138 | `Retrying after ${updateRetrySleep} ms ... (${run}/${updateRetries})` 139 | ); 140 | 141 | await sleep(updateRetrySleep); 142 | 143 | const { data } = await octokit.pulls.get({ 144 | owner: pullRequest.base.repo.owner.login, 145 | repo: pullRequest.base.repo.name, 146 | pull_number: pullRequest.number, 147 | headers: { "If-None-Match": "" } 148 | }); 149 | fullPullRequest = data; 150 | } 151 | } 152 | 153 | return fullPullRequest.mergeable_state; 154 | } 155 | } 156 | 157 | async function rebase(dir, url, pullRequest) { 158 | const headRef = pullRequest.head.ref; 159 | const baseRef = pullRequest.base.ref; 160 | 161 | logger.debug("Cloning into", dir, `(${headRef})`); 162 | await git.clone(url, dir, headRef); 163 | 164 | logger.debug("Fetching", baseRef, "..."); 165 | await git.fetch(dir, baseRef); 166 | await git.fetchUntilMergeBase(dir, baseRef, FETCH_TIMEOUT); 167 | 168 | const head = await git.head(dir); 169 | if (head !== pullRequest.head.sha) { 170 | logger.info(`HEAD changed to ${head}, skipping`); 171 | return null; 172 | } 173 | 174 | logger.info(headRef, "HEAD:", head); 175 | 176 | const onto = await git.sha(dir, baseRef); 177 | 178 | logger.info("Rebasing onto", baseRef, onto); 179 | await git.rebase(dir, onto); 180 | 181 | const newHead = await git.head(dir); 182 | if (newHead === head) { 183 | logger.info("Already up to date:", headRef, "->", baseRef, onto); 184 | } else { 185 | logger.debug("Pushing changes..."); 186 | await git.push(dir, true, headRef); 187 | 188 | logger.info("Updated:", headRef, head, "->", newHead); 189 | } 190 | 191 | return newHead; 192 | } 193 | 194 | module.exports = { update }; 195 | -------------------------------------------------------------------------------- /lib/common.js: -------------------------------------------------------------------------------- 1 | const util = require("util"); 2 | const process = require("process"); 3 | const fse = require("fs-extra"); 4 | const tmp = require("tmp"); 5 | 6 | const RESULT_SKIPPED = "skipped"; 7 | const RESULT_NOT_READY = "not_ready"; 8 | const RESULT_AUTHOR_FILTERED = "author_filtered"; 9 | const RESULT_MERGE_FAILED = "merge_failed"; 10 | const RESULT_MERGED = "merged"; 11 | 12 | class ClientError extends Error {} 13 | 14 | class TimeoutError extends Error {} 15 | 16 | function log(prefix, obj) { 17 | if (process.env.NODE_ENV !== "test") { 18 | const now = new Date().toISOString(); 19 | const str = obj.map(o => (typeof o === "object" ? inspect(o) : o)); 20 | if (prefix) { 21 | console.log.apply(console, [now, prefix, ...str]); 22 | } else { 23 | console.log.apply(console, [now, ...str]); 24 | } 25 | } 26 | } 27 | 28 | const logger = { 29 | level: "info", 30 | 31 | trace: (...str) => { 32 | if (logger.level === "trace") { 33 | log("TRACE", str); 34 | } 35 | }, 36 | 37 | debug: (...str) => { 38 | if (logger.level === "trace" || logger.level === "debug") { 39 | log("DEBUG", str); 40 | } 41 | }, 42 | 43 | info: (...str) => log("INFO ", str), 44 | 45 | error: (...str) => { 46 | if (str.length === 1 && str[0] instanceof Error) { 47 | if (logger.level === "trace" || logger.level === "debug") { 48 | log(null, [str[0].stack || str[0]]); 49 | } else { 50 | log("ERROR", [str[0].message || str[0]]); 51 | } 52 | } else { 53 | log("ERROR", str); 54 | } 55 | } 56 | }; 57 | 58 | function inspect(obj) { 59 | return util.inspect(obj, false, null, true); 60 | } 61 | 62 | function createConfig(env = {}) { 63 | function parseMergeLabels(str, defaultValue) { 64 | const arr = (str == null ? defaultValue : str) 65 | .split(",") 66 | .map(s => s.trim()); 67 | return { 68 | required: arr.filter(s => !s.startsWith("!") && s.length > 0), 69 | blocking: arr 70 | .filter(s => s.startsWith("!")) 71 | .map(s => s.substr(1).trim()) 72 | .filter(s => s.length > 0) 73 | }; 74 | } 75 | 76 | function parseLabelMethods(str) { 77 | return (str ? str.split(",") : []).map(lm => { 78 | const [label, method] = lm.split("="); 79 | if (!label || !method) { 80 | throw new Error( 81 | `Couldn't parse "${lm}" as "