├── .nvmrc ├── .eslintignore ├── .npmrc ├── dist ├── index.d.ts ├── index.js.cache ├── index.d.ts.map ├── utilities │ ├── prTitleParsers.d.ts │ ├── prTitleParsers.d.ts.map │ ├── log.d.ts │ ├── log.d.ts.map │ ├── inputParsers.d.ts.map │ └── inputParsers.d.ts ├── eventHandlers │ ├── index.d.ts │ ├── index.d.ts.map │ ├── pullRequest │ │ ├── index.d.ts │ │ └── index.d.ts.map │ └── continuousIntegrationEnd │ │ ├── index.d.ts │ │ └── index.d.ts.map ├── common │ ├── delay.d.ts │ ├── delay.d.ts.map │ ├── computeRequiresStrictStatusChecksForRefs.d.ts.map │ ├── getPullRequestCommits.d.ts │ ├── getPullRequestCommits.d.ts.map │ ├── listBranchProtectionRules.d.ts.map │ ├── computeRequiresStrictStatusChecksForRefs.d.ts │ ├── getPullRequestInformation.d.ts.map │ ├── merge.d.ts.map │ ├── getPullRequestInformation.d.ts │ ├── listBranchProtectionRules.d.ts │ ├── makeGraphqlIterator.d.ts.map │ ├── makeGraphqlIterator.d.ts │ └── merge.d.ts ├── index.js ├── types.d.ts.map └── types.d.ts ├── .prettierignore ├── .prettierrc.yml ├── commitlint.config.js ├── src ├── eventHandlers │ ├── index.ts │ ├── pullRequest │ │ ├── index.ts │ │ └── index.spec.ts │ └── continuousIntegrationEnd │ │ ├── index.ts │ │ └── index.spec.ts ├── common │ ├── delay.ts │ ├── delay.spec.ts │ ├── computeRequiresStrictStatusChecksForRefs.ts │ ├── __snapshots__ │ │ └── getPullRequestInformation.spec.ts.snap │ ├── makeGraphqlIterator.ts │ ├── getPullRequestCommits.ts │ ├── listBranchProtectionRules.ts │ ├── computeRequiresStrictStatusChecksForRefs.spec.ts │ ├── listBranchProtectionRules.spec.ts │ ├── getPullRequestInformation.ts │ ├── getPullRequestInformation.spec.ts │ └── merge.ts ├── utilities │ ├── log.ts │ ├── inputParsers.ts │ ├── prTitleParsers.ts │ ├── inputParsers.spec.ts │ ├── log.spec.ts │ └── prTitleParsers.spec.ts ├── index.ts └── types.ts ├── tsconfig.json ├── CODEOWNERS ├── .gitattributes ├── .editorconfig ├── .vscode ├── extensions.json ├── settings.json └── launch.json ├── decisions ├── DECISION_TEMPLATE.md └── README.md ├── .github ├── pull_request_template.md ├── dependabot.yaml ├── ISSUE_TEMPLATE │ ├── LIMITATION.md │ └── BUG.md └── workflows │ ├── continuous-delivery.yaml │ └── continuous-integration.yaml ├── jest.config.js ├── tsconfig.production.json ├── .eslintrc.yaml ├── .gitignore ├── test ├── setup.ts ├── test.github.payload.json ├── utilities.ts ├── TestEnv.js └── fixtures │ ├── ctx.push.json │ ├── ctx.check-suite.json │ └── ctx.pull-request.json ├── cSpell.json ├── LICENSE ├── rfcs ├── README.md └── RFC_TEMPLATE.md ├── action.yml ├── package.json ├── CONTRIBUTING.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v20 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /lib 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | lib 4 | package.json 5 | package-lock.json 6 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | proseWrap: 'always' 2 | singleQuote: true 3 | trailingComma: 'all' 4 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /dist/index.js.cache: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ridedott/merge-me-action/HEAD/dist/index.js.cache -------------------------------------------------------------------------------- /src/eventHandlers/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Event Handler Exports 3 | */ 4 | export * from './continuousIntegrationEnd'; 5 | export * from './pullRequest'; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "." 4 | }, 5 | "extends": "./tsconfig.production.json", 6 | "include": ["./**/*"] 7 | } 8 | -------------------------------------------------------------------------------- /dist/index.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"","sourceRoot":"","sources":["file:///home/runner/work/merge-me-action/merge-me-action/src/index.ts"],"names":[],"mappings":""} -------------------------------------------------------------------------------- /dist/utilities/prTitleParsers.d.ts: -------------------------------------------------------------------------------- 1 | export declare const checkPullRequestTitleForMergePreset: (title: string) => boolean; 2 | //# sourceMappingURL=prTitleParsers.d.ts.map -------------------------------------------------------------------------------- /dist/eventHandlers/index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Event Handler Exports 3 | */ 4 | export * from './continuousIntegrationEnd'; 5 | export * from './pullRequest'; 6 | //# sourceMappingURL=index.d.ts.map -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/about-codeowners/ 2 | # for more info about CODEOWNERS file 3 | 4 | * @ridedott/swe-platform-backend 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Autodetect text files 2 | * text=auto eol=lf 3 | 4 | # Definitively text files 5 | *.js text 6 | *.json text 7 | *.md text 8 | *.ts text 9 | *.yaml text 10 | *.yml text 11 | -------------------------------------------------------------------------------- /dist/common/delay.d.ts: -------------------------------------------------------------------------------- 1 | export declare const EXPONENTIAL_BACKOFF = 2; 2 | export declare const MINIMUM_WAIT_TIME = 1000; 3 | export declare const delay: (duration: number) => Promise; 4 | //# sourceMappingURL=delay.d.ts.map -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /dist/eventHandlers/index.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["file:///home/runner/work/merge-me-action/merge-me-action/src/eventHandlers/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,cAAc,4BAA4B,CAAC;AAC3C,cAAc,eAAe,CAAC"} -------------------------------------------------------------------------------- /dist/utilities/prTitleParsers.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"prTitleParsers.d.ts","sourceRoot":"","sources":["file:///home/runner/work/merge-me-action/merge-me-action/src/utilities/prTitleParsers.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,mCAAmC,UAAW,MAAM,KAAG,OA6CnE,CAAC"} -------------------------------------------------------------------------------- /dist/eventHandlers/pullRequest/index.d.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from '@actions/github'; 2 | export declare const pullRequestHandle: (octokit: ReturnType, gitHubLogin: string, maximumRetries: number) => Promise; 3 | //# sourceMappingURL=index.d.ts.map -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "EditorConfig.EditorConfig", 5 | "esbenp.prettier-vscode", 6 | "orta.vscode-jest", 7 | "richie5um2.vscode-sort-json", 8 | "streetsidesoftware.code-spell-checker" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /dist/eventHandlers/continuousIntegrationEnd/index.d.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from '@actions/github'; 2 | export declare const continuousIntegrationEndHandle: (octokit: ReturnType, gitHubLogin: string, maximumRetries: number) => Promise; 3 | //# sourceMappingURL=index.d.ts.map -------------------------------------------------------------------------------- /dist/common/delay.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"delay.d.ts","sourceRoot":"","sources":["file:///home/runner/work/merge-me-action/merge-me-action/src/common/delay.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,mBAAmB,IAAI,CAAC;AACrC,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAEtC,eAAO,MAAM,KAAK,aAAoB,MAAM,KAAG,QAAQ,IAAI,CAKvD,CAAC"} -------------------------------------------------------------------------------- /dist/utilities/log.d.ts: -------------------------------------------------------------------------------- 1 | export declare const logDebug: (message: unknown) => void; 2 | export declare const logError: (message: unknown) => void; 3 | export declare const logInfo: (message: unknown) => void; 4 | export declare const logWarning: (message: unknown) => void; 5 | //# sourceMappingURL=log.d.ts.map -------------------------------------------------------------------------------- /src/common/delay.ts: -------------------------------------------------------------------------------- 1 | export const EXPONENTIAL_BACKOFF = 2; 2 | export const MINIMUM_WAIT_TIME = 1000; 3 | 4 | export const delay = async (duration: number): Promise => 5 | new Promise((resolve: () => void): void => { 6 | setTimeout((): void => { 7 | resolve(); 8 | }, duration); 9 | }); 10 | -------------------------------------------------------------------------------- /src/common/delay.spec.ts: -------------------------------------------------------------------------------- 1 | import { delay } from './delay'; 2 | 3 | it('resolves promise after waiting for the specified duration', async (): Promise => { 4 | expect.assertions(1); 5 | 6 | const start = Date.now(); 7 | 8 | await delay(50); 9 | 10 | expect(Date.now()).toBeGreaterThanOrEqual(start + 50); 11 | }); 12 | -------------------------------------------------------------------------------- /dist/eventHandlers/pullRequest/index.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["file:///home/runner/work/merge-me-action/merge-me-action/src/eventHandlers/pullRequest/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAW,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAStD,eAAO,MAAM,iBAAiB,YACnB,WAAW,iBAAiB,CAAC,eACzB,MAAM,kBACH,MAAM,KACrB,QAAQ,IAAI,CA8Dd,CAAC"} -------------------------------------------------------------------------------- /dist/utilities/log.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"log.d.ts","sourceRoot":"","sources":["file:///home/runner/work/merge-me-action/merge-me-action/src/utilities/log.ts"],"names":[],"mappings":"AAiBA,eAAO,MAAM,QAAQ,YAL2B,OAAO,KAAK,IAK1B,CAAC;AACnC,eAAO,MAAM,QAAQ,YAN2B,OAAO,KAAK,IAM1B,CAAC;AACnC,eAAO,MAAM,OAAO,YAP4B,OAAO,KAAK,IAO5B,CAAC;AACjC,eAAO,MAAM,UAAU,YARyB,OAAO,KAAK,IAQtB,CAAC"} -------------------------------------------------------------------------------- /dist/eventHandlers/continuousIntegrationEnd/index.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["file:///home/runner/work/merge-me-action/merge-me-action/src/eventHandlers/continuousIntegrationEnd/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAW,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAoEtD,eAAO,MAAM,8BAA8B,YAChC,WAAW,iBAAiB,CAAC,eACzB,MAAM,kBACH,MAAM,KACrB,QAAQ,IAAI,CAwEd,CAAC"} -------------------------------------------------------------------------------- /dist/common/computeRequiresStrictStatusChecksForRefs.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"computeRequiresStrictStatusChecksForRefs.d.ts","sourceRoot":"","sources":["file:///home/runner/work/merge-me-action/merge-me-action/src/common/computeRequiresStrictStatusChecksForRefs.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AAEnE;;;GAGG;AACH,eAAO,MAAM,wCAAwC,0BAC5B,oBAAoB,EAAE,QACvC,MAAM,EAAE,KACb,OAAO,EASP,CAAC"} -------------------------------------------------------------------------------- /dist/utilities/inputParsers.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"inputParsers.d.ts","sourceRoot":"","sources":["file:///home/runner/work/merge-me-action/merge-me-action/src/utilities/inputParsers.ts"],"names":[],"mappings":"AAIA,oBAAY,mBAAmB;IAC7B,KAAK,UAAU;IACf,MAAM,WAAW;IACjB,MAAM,WAAW;CAClB;AAED,oBAAY,mBAAmB;IAC7B,gBAAgB,qBAAqB;IACrC,gBAAgB,qBAAqB;CACtC;AAED,eAAO,MAAM,qBAAqB,QAAO,mBAYxC,CAAC;AAEF,eAAO,MAAM,qBAAqB,QAAO,mBAAmB,GAAG,SAc9D,CAAC"} -------------------------------------------------------------------------------- /dist/common/getPullRequestCommits.d.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from '@actions/github'; 2 | import { PullRequestCommitNode } from '../types'; 3 | export declare const getPullRequestCommitsIterator: (octokit: ReturnType, query: { 4 | pullRequestNumber: number; 5 | repositoryName: string; 6 | repositoryOwner: string; 7 | }) => AsyncGenerator; 8 | //# sourceMappingURL=getPullRequestCommits.d.ts.map -------------------------------------------------------------------------------- /decisions/DECISION_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # [name] 2 | 3 | ## Status 4 | 5 | 6 | 7 | ## Context 8 | 9 | 10 | 11 | ## Decision 12 | 13 | 14 | 15 | ## Consequences 16 | 17 | 18 | -------------------------------------------------------------------------------- /dist/common/getPullRequestCommits.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"getPullRequestCommits.d.ts","sourceRoot":"","sources":["file:///home/runner/work/merge-me-action/merge-me-action/src/common/getPullRequestCommits.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAG7C,OAAO,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AAgCjD,eAAO,MAAM,6BAA6B,YAC/B,WAAW,iBAAiB,CAAC,SAC/B;IACL,iBAAiB,EAAE,MAAM,CAAC;IAC1B,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,MAAM,CAAC;CACzB,KACA,eAAe,qBAAqB,CAQnC,CAAC"} -------------------------------------------------------------------------------- /dist/common/listBranchProtectionRules.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"listBranchProtectionRules.d.ts","sourceRoot":"","sources":["file:///home/runner/work/merge-me-action/merge-me-action/src/common/listBranchProtectionRules.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAwB7C,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,0BAA0B,EAAE,OAAO,CAAC;CACrC;AAED;;;GAGG;AACH,eAAO,MAAM,yBAAyB,YAC3B,WAAW,iBAAiB,CAAC,mBACrB,MAAM,kBACP,MAAM,KACrB,QAAQ,oBAAoB,EAAE,CAsBhC,CAAC"} -------------------------------------------------------------------------------- /dist/common/computeRequiresStrictStatusChecksForRefs.d.ts: -------------------------------------------------------------------------------- 1 | import { BranchProtectionRule } from './listBranchProtectionRules'; 2 | /** 3 | * Returns an array of booleans indicating whether the provided pull requests 4 | * require their branches to be up to date before merging. 5 | */ 6 | export declare const computeRequiresStrictStatusChecksForRefs: (branchProtectionRules: BranchProtectionRule[], refs: string[]) => boolean[]; 7 | //# sourceMappingURL=computeRequiresStrictStatusChecksForRefs.d.ts.map -------------------------------------------------------------------------------- /dist/utilities/inputParsers.d.ts: -------------------------------------------------------------------------------- 1 | export declare enum AllowedMergeMethods { 2 | MERGE = "MERGE", 3 | SQUASH = "SQUASH", 4 | REBASE = "REBASE" 5 | } 6 | export declare enum AllowedMergePresets { 7 | DEPENDABOT_MINOR = "DEPENDABOT_MINOR", 8 | DEPENDABOT_PATCH = "DEPENDABOT_PATCH" 9 | } 10 | export declare const parseInputMergeMethod: () => AllowedMergeMethods; 11 | export declare const parseInputMergePreset: () => AllowedMergePresets | undefined; 12 | //# sourceMappingURL=inputParsers.d.ts.map -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | This Pull Request fulfills the following requirements: 2 | 3 | - [ ] The commit message follows our guidelines. 4 | - [ ] Tests for the changes have been added if needed. 5 | - [ ] Does not affect documentation or it has been added or updated. 6 | 7 | 14 | 15 | Resolves # 16 | -------------------------------------------------------------------------------- /dist/common/getPullRequestInformation.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"getPullRequestInformation.d.ts","sourceRoot":"","sources":["file:///home/runner/work/merge-me-action/merge-me-action/src/common/getPullRequestInformation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAG7C,OAAO,EAEL,sBAAsB,EACvB,MAAM,UAAU,CAAC;AAqIlB,eAAO,MAAM,qDAAqD,YACvD,WAAW,iBAAiB,CAAC,SAC/B;IACL,iBAAiB,EAAE,MAAM,CAAC;IAC1B,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,MAAM,CAAC;CACzB,WACQ;IACP,uBAAuB,EAAE,OAAO,CAAC;CAClC,KACA,QAAQ,sBAAsB,GAAG,SAAS,CAa5C,CAAC"} -------------------------------------------------------------------------------- /dist/common/merge.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"merge.d.ts","sourceRoot":"","sources":["file:///home/runner/work/merge-me-action/merge-me-action/src/common/merge.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,OAAO,EAAyB,sBAAsB,EAAE,MAAM,UAAU,CAAC;AAUzE,MAAM,WAAW,kBAAkB;IACjC,cAAc,EAAE,MAAM,CAAC;IACvB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE;QAAE,IAAI,EAAE;YAAE,KAAK,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,GAAG,SAAS,CAAC;CACrD;AAwID,eAAO,MAAM,QAAQ,YACV,WAAW,iBAAiB,CAAC;oBAKpB,MAAM;gCACM,OAAO;6LAclC,sBAAsB,KACxB,QAAQ,IAAI,CAyCd,CAAC"} -------------------------------------------------------------------------------- /dist/common/getPullRequestInformation.d.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from '@actions/github'; 2 | import { PullRequestInformation } from '../types'; 3 | export declare const getMergeablePullRequestInformationByPullRequestNumber: (octokit: ReturnType, query: { 4 | pullRequestNumber: number; 5 | repositoryName: string; 6 | repositoryOwner: string; 7 | }, options: { 8 | githubPreviewApiEnabled: boolean; 9 | }) => Promise; 10 | //# sourceMappingURL=getPullRequestInformation.d.ts.map -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | coveragePathIgnorePatterns: ['/node_modules/', '/test/'], 3 | coverageReporters: ['lcov', 'text', 'text-summary'], 4 | coverageThreshold: { 5 | global: { 6 | branches: 100, 7 | functions: 100, 8 | lines: 100, 9 | statements: 100, 10 | }, 11 | }, 12 | logHeapUsage: true, 13 | preset: 'ts-jest', 14 | resetMocks: true, 15 | roots: ['/src'], 16 | setupFilesAfterEnv: ['/test/setup.ts'], 17 | testEnvironment: '/test/TestEnv.js', 18 | }; 19 | -------------------------------------------------------------------------------- /dist/common/listBranchProtectionRules.d.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from '@actions/github'; 2 | export interface BranchProtectionRule { 3 | pattern: string; 4 | requiresStrictStatusChecks: boolean; 5 | } 6 | /** 7 | * Returns an array containing a repository's configured partial branch 8 | * protection rules. 9 | */ 10 | export declare const listBranchProtectionRules: (octokit: ReturnType, repositoryOwner: string, repositoryName: string) => Promise; 11 | //# sourceMappingURL=listBranchProtectionRules.d.ts.map -------------------------------------------------------------------------------- /dist/common/makeGraphqlIterator.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"makeGraphqlIterator.d.ts","sourceRoot":"","sources":["file:///home/runner/work/merge-me-action/merge-me-action/src/common/makeGraphqlIterator.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,kBAAkB,CAAC;AAIjE,MAAM,WAAW,YAAY,CAAC,QAAQ;IACpC,KAAK,EAAE,KAAK,CAAC;QACX,IAAI,EAAE,QAAQ,CAAC;KAChB,CAAC,CAAC;IACH,QAAQ,EAAE;QACR,SAAS,EAAE,MAAM,CAAC;QAClB,WAAW,EAAE,OAAO,CAAC;KACtB,CAAC;CACH;AAED,eAAO,MAAM,mBAAmB,0BACrB,WAAW,iBAAiB,CAAC;oCAGxB,wBAAwB;gBAExB,MAAM;WACX,MAAM;gDA8BhB,CAAC"} -------------------------------------------------------------------------------- /src/utilities/log.ts: -------------------------------------------------------------------------------- 1 | import { debug, error, info, warning } from '@actions/core'; 2 | 3 | const stringify = (value: unknown): string => 4 | typeof value === 'string' 5 | ? value 6 | : value instanceof Error 7 | ? value.stack ?? value.toString() 8 | : typeof value === 'number' 9 | ? value.toString() 10 | : JSON.stringify(value); 11 | 12 | const log = 13 | (logger: (value: string) => void): ((message: unknown) => void) => 14 | (message: unknown): void => { 15 | logger(stringify(message)); 16 | }; 17 | 18 | export const logDebug = log(debug); 19 | export const logError = log(error); 20 | export const logInfo = log(info); 21 | export const logWarning = log(warning); 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.diagnosticLevel": "Error", 3 | "cSpell.enabled": true, 4 | "cSpell.ignorePaths": [ 5 | ".eslintrc", 6 | "*.config.js", 7 | "*.json", 8 | "*.log", 9 | "*.yaml", 10 | "**/*.json", 11 | "coverage/**", 12 | "dist/**", 13 | "node_modules/**" 14 | ], 15 | "editor.formatOnSave": true, 16 | "eslint.codeAction.disableRuleComment": { 17 | "enable": true, 18 | "location": "separateLine" 19 | }, 20 | "eslint.codeAction.showDocumentation": { 21 | "enable": true 22 | }, 23 | "eslint.lintTask.enable": true, 24 | "eslint.run": "onType", 25 | "jest.autoRun": {}, 26 | "typescript.tsdk": "./node_modules/typescript/lib" 27 | } 28 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - allow: 5 | - dependency-type: direct 6 | commit-message: 7 | include: scope 8 | prefix: chore 9 | directory: / 10 | open-pull-requests-limit: 99 11 | package-ecosystem: github-actions 12 | reviewers: 13 | - 'ridedott/platform' 14 | schedule: 15 | interval: daily 16 | - allow: 17 | - dependency-type: direct 18 | commit-message: 19 | include: scope 20 | prefix: chore 21 | directory: / 22 | open-pull-requests-limit: 99 23 | package-ecosystem: npm 24 | reviewers: 25 | - 'ridedott/platform' 26 | schedule: 27 | interval: daily 28 | versioning-strategy: increase 29 | -------------------------------------------------------------------------------- /dist/common/makeGraphqlIterator.d.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from '@actions/github'; 2 | import type { GraphQlQueryResponseData } from '@octokit/graphql'; 3 | export interface IterableList { 4 | edges: Array<{ 5 | node: Iterable; 6 | }>; 7 | pageInfo: { 8 | endCursor: string; 9 | hasNextPage: boolean; 10 | }; 11 | } 12 | export declare const makeGraphqlIterator: (octokit: ReturnType, options: { 13 | extractListFunction: (response: GraphQlQueryResponseData) => IterableList | undefined; 14 | parameters: object; 15 | query: string; 16 | }) => AsyncGenerator; 17 | //# sourceMappingURL=makeGraphqlIterator.d.ts.map -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | require('./sourcemap-register.js');const { readFileSync, writeFileSync } = require('fs'), { Script } = require('vm'), { wrap } = require('module'); 2 | const basename = __dirname + '/index.js'; 3 | const source = readFileSync(basename + '.cache.js', 'utf-8'); 4 | const cachedData = !process.pkg && require('process').platform !== 'win32' && readFileSync(basename + '.cache'); 5 | const scriptOpts = { filename: basename + '.cache.js', columnOffset: -62 } 6 | const script = new Script(wrap(source), cachedData ? Object.assign({ cachedData }, scriptOpts) : scriptOpts); 7 | (script.runInThisContext())(exports, require, module, __filename, __dirname); 8 | if (cachedData) process.on('exit', () => { try { writeFileSync(basename + '.cache', script.createCachedData()); } catch(e) {} }); 9 | -------------------------------------------------------------------------------- /tsconfig.production.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "alwaysStrict": true, 5 | "charset": "utf-8", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "downlevelIteration": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "lib": ["ES2019"], 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "noImplicitAny": false, 14 | "noImplicitReturns": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "outDir": "lib", 18 | "pretty": true, 19 | "rootDir": "src", 20 | "sourceMap": true, 21 | "strict": true, 22 | "strictNullChecks": true, 23 | "target": "ES2019" 24 | }, 25 | "files": ["./src/index.ts"] 26 | } 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/LIMITATION.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Limitation 3 | about: Tell us about your needs 4 | labels: enhancement 5 | --- 6 | 7 | **What do you want to achieve?** 8 | 9 | 14 | 15 | **What is the current way of working?** 16 | 17 | 21 | 22 | **How much does it hurt?** 23 | 24 | 28 | -------------------------------------------------------------------------------- /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | env: 2 | es6: true 3 | node: true 4 | extends: '@ridedott/eslint-config' 5 | overrides: 6 | - env: 7 | jest: true 8 | files: 9 | - src/**/*.spec.ts 10 | rules: 11 | '@typescript-eslint/camelcase': 'off' 12 | '@typescript-eslint/no-magic-numbers': 'off' 13 | jest/require-hook: 'error' 14 | max-lines: 'off' 15 | max-lines-per-function: 'off' 16 | no-magic-numbers: 'off' 17 | max-statements: 'off' 18 | - files: 19 | - src/**/computeRequiresStrictStatusChecksForRefs.* 20 | rules: 21 | 'unicorn/prevent-abbreviations': 'off' 22 | parserOptions: 23 | ecmaVersion: 2020 24 | project: tsconfig.json 25 | sourceType: module 26 | rules: 27 | jest/require-hook: 'off' 28 | no-negated-condition: 'off' 29 | -------------------------------------------------------------------------------- /src/common/computeRequiresStrictStatusChecksForRefs.ts: -------------------------------------------------------------------------------- 1 | import { isMatch } from 'micromatch'; 2 | 3 | import { BranchProtectionRule } from './listBranchProtectionRules'; 4 | 5 | /** 6 | * Returns an array of booleans indicating whether the provided pull requests 7 | * require their branches to be up to date before merging. 8 | */ 9 | export const computeRequiresStrictStatusChecksForRefs = ( 10 | branchProtectionRules: BranchProtectionRule[], 11 | refs: string[], 12 | ): boolean[] => 13 | refs.map((reference: string): boolean => 14 | branchProtectionRules.some( 15 | ({ 16 | pattern, 17 | requiresStrictStatusChecks, 18 | }: BranchProtectionRule): boolean => 19 | isMatch(reference, pattern) && requiresStrictStatusChecks === true, 20 | ), 21 | ); 22 | -------------------------------------------------------------------------------- /dist/common/merge.d.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from '@actions/github'; 2 | import { PullRequestInformation } from '../types'; 3 | export interface PullRequestDetails { 4 | commitHeadline: string; 5 | pullRequestId: string; 6 | reviewEdge: { 7 | node: { 8 | state: string; 9 | }; 10 | } | undefined; 11 | } 12 | export declare const tryMerge: (octokit: ReturnType, { maximumRetries, requiresStrictStatusChecks, }: { 13 | maximumRetries: number; 14 | requiresStrictStatusChecks: boolean; 15 | }, { commitMessageHeadline, mergeableState, mergeStateStatus, merged, pullRequestId, pullRequestNumber, pullRequestState, pullRequestTitle, reviewEdges, repositoryName, repositoryOwner, }: PullRequestInformation) => Promise; 16 | //# sourceMappingURL=merge.d.ts.map -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Firebase 9 | .runtimeconfig.json 10 | 11 | # Cache files 12 | .cache 13 | *.cache 14 | 15 | # Runtime environment supports npm an Yarn lockfiles. A decision was made to use 16 | # npm, therefore all other lock files are ignored. 17 | pnpm-lock.yaml 18 | yarn.lock 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Dependency directories 27 | node_modules 28 | 29 | # Optional REPL history 30 | .node_repl_history 31 | 32 | # Output of 'npm pack' 33 | *.tgz 34 | 35 | # Yarn Integrity file 36 | .yarn-integrity 37 | 38 | # dotenv environment variables file 39 | .env 40 | 41 | # MacOS-specific files 42 | .DS_Store 43 | 44 | # Coverage output 45 | coverage 46 | 47 | # Build output 48 | dist 49 | lib 50 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import * as nock from 'nock'; 3 | 4 | jest.spyOn(core, 'debug').mockImplementation(); 5 | jest.spyOn(core, 'error').mockImplementation(); 6 | jest.spyOn(core, 'info').mockImplementation(); 7 | jest.spyOn(core, 'warning').mockImplementation(); 8 | 9 | beforeAll((): void => { 10 | nock.disableNetConnect(); 11 | }); 12 | 13 | afterEach((): void => { 14 | // Reset all mocks after each test run. 15 | jest.clearAllMocks(); 16 | 17 | // Assert all HTTP mocks were called. 18 | if (nock.isDone() !== true) { 19 | const pending = nock.pendingMocks(); 20 | 21 | nock.cleanAll(); 22 | 23 | throw new Error(`Pending mocks detected: ${pending.toString()}.`); 24 | } 25 | 26 | // Reset network recording after each test run. 27 | nock.restore(); 28 | nock.activate(); 29 | }); 30 | -------------------------------------------------------------------------------- /cSpell.json: -------------------------------------------------------------------------------- 1 | { 2 | "dictionaries": [ 3 | "companies", 4 | "filetypes", 5 | "misc", 6 | "node", 7 | "npm", 8 | "softwareTerms", 9 | "typescript" 10 | ], 11 | "flagWords": [], 12 | "ignorePaths": [ 13 | ".eslintrc", 14 | "*.config.js", 15 | "*.json", 16 | "*.log", 17 | "*.yaml", 18 | "**/*.json", 19 | "CHANGELOG.md", 20 | "coverage/**", 21 | "dist/**", 22 | "generated/**", 23 | "lib/**", 24 | "node_modules/**" 25 | ], 26 | "language": "en-US", 27 | "version": "0.1", 28 | "words": [ 29 | "backoff", 30 | "codeowners", 31 | "commitlint", 32 | "coverallsapp", 33 | "dependabot", 34 | "docblock", 35 | "dottbott", 36 | "editorconfig", 37 | "gitignore", 38 | "mergeable", 39 | "octokit", 40 | "prettierignore", 41 | "prettierrc", 42 | "rebased", 43 | "releaserc", 44 | "retryable", 45 | "ridedott", 46 | "ridedottmerge", 47 | "webhook" 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 3 | about: Report an issue 4 | labels: bug 5 | --- 6 | 7 | **How would you describe the issue?** 8 | 9 | 13 | 14 | **How can we reproduce the issue?** 15 | 16 | 21 | 22 | **What are the expected results?** 23 | 24 | 28 | 29 | **What are the actual results?** 30 | 31 | 34 | 35 | **How much does it hurt?** 36 | 37 | 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 EmTransit B.V. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /test/test.github.payload.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "string", 3 | "check_suite": { 4 | "head_commit": { 5 | "message": "Update test\n\n\nsi" 6 | }, 7 | "pull_requests": [ 8 | { 9 | "id": "test-ID-1", 10 | "number": 1 11 | } 12 | ] 13 | }, 14 | "commits": [ 15 | { 16 | "message": "Update test\n\n\nsi" 17 | } 18 | ], 19 | "installation": { 20 | "id": 2 21 | }, 22 | "issue": { 23 | "body": "string", 24 | "html_url": "string", 25 | "number": 2 26 | }, 27 | "pull_request": { 28 | "body": "string", 29 | "html_url": "string", 30 | "node_id": "MDExOlB1bGxSZXF1ZXN0MzE3MDI5MjU4", 31 | "number": 2, 32 | "title": "Update test", 33 | "user": { 34 | "login": "dependabot[bot]" 35 | } 36 | }, 37 | "pusher": { 38 | "name": "dependabot[bot]" 39 | }, 40 | "ref": "refs/heads/test-branch", 41 | "repository": { 42 | "full_name": "test-actor/Test-Repo", 43 | "html_url": "string", 44 | "name": "Test-Repo", 45 | "owner": { 46 | "login": "string", 47 | "name": "string" 48 | } 49 | }, 50 | "sender": { 51 | "login": "dependabot[bot]", 52 | "type": "string" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/utilities/inputParsers.ts: -------------------------------------------------------------------------------- 1 | import { getInput } from '@actions/core'; 2 | 3 | import { logWarning } from './log'; 4 | 5 | export enum AllowedMergeMethods { 6 | MERGE = 'MERGE', 7 | SQUASH = 'SQUASH', 8 | REBASE = 'REBASE', 9 | } 10 | 11 | export enum AllowedMergePresets { 12 | DEPENDABOT_MINOR = 'DEPENDABOT_MINOR', 13 | DEPENDABOT_PATCH = 'DEPENDABOT_PATCH', 14 | } 15 | 16 | export const parseInputMergeMethod = (): AllowedMergeMethods => { 17 | const input = getInput('MERGE_METHOD'); 18 | 19 | if (input.length === 0 || AllowedMergeMethods[input] === undefined) { 20 | logWarning( 21 | 'MERGE_METHOD value input is ignored because it is malformed, defaulting to SQUASH.', 22 | ); 23 | 24 | return AllowedMergeMethods.SQUASH; 25 | } 26 | 27 | return AllowedMergeMethods[input]; 28 | }; 29 | 30 | export const parseInputMergePreset = (): AllowedMergePresets | undefined => { 31 | const input = getInput('PRESET'); 32 | 33 | if (input.length === 0) { 34 | return undefined; 35 | } 36 | 37 | if (AllowedMergePresets[input] === undefined) { 38 | logWarning('PRESET value input is ignored because it is malformed.'); 39 | 40 | return undefined; 41 | } 42 | 43 | return AllowedMergePresets[input]; 44 | }; 45 | -------------------------------------------------------------------------------- /src/common/__snapshots__/getPullRequestInformation.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`getPullRequestInformation returns pull request information with mergeStateStatus field 1`] = ` 4 | Object { 5 | "authorLogin": "test-author", 6 | "commitMessage": "test message", 7 | "commitMessageHeadline": "test message headline", 8 | "mergeStateStatus": "CLEAN", 9 | "mergeableState": "MERGEABLE", 10 | "merged": false, 11 | "pullRequestId": "123", 12 | "pullRequestNumber": 1, 13 | "pullRequestState": "OPEN", 14 | "pullRequestTitle": "test", 15 | "repositoryName": "test-repository", 16 | "repositoryOwner": "test-owner", 17 | "reviewEdges": Array [], 18 | } 19 | `; 20 | 21 | exports[`getPullRequestInformation returns pull request information without mergeStateStatus field 1`] = ` 22 | Object { 23 | "authorLogin": "test-author", 24 | "commitMessage": "test message", 25 | "commitMessageHeadline": "test message headline", 26 | "mergeableState": "MERGEABLE", 27 | "merged": false, 28 | "pullRequestId": "123", 29 | "pullRequestNumber": 1, 30 | "pullRequestState": "OPEN", 31 | "pullRequestTitle": "test", 32 | "repositoryName": "test-repository", 33 | "repositoryOwner": "test-owner", 34 | "reviewEdges": Array [], 35 | } 36 | `; 37 | -------------------------------------------------------------------------------- /decisions/README.md: -------------------------------------------------------------------------------- 1 | # Decision records 2 | 3 | ## What is a decision record? 4 | 5 | A **decision record** is a document that captures an important project-related 6 | decision made along with its context and consequences. 7 | 8 | A **decision** is a software design choice that addresses a significant 9 | requirement. 10 | 11 | A **significant requirement** is a requirement that has a measurable effect on a 12 | software system’s architecture, it's development speed and scalability. 13 | 14 | ## Decision record file name convention 15 | 16 | All decision records files must be in markdown format with `.md` extension. 17 | 18 | File name convention: 19 | 20 | `[date] [name].md` 21 | 22 | - The `date` format: `YYYY-MM-DD`. This is ISO standard and helps for sorting by 23 | date. 24 | 25 | - The `name` has a present tense imperative verb phrase. This helps readability 26 | and matches our commit message format. 27 | 28 | - The `name` uses sentence capitalization and spaces. This is helpful for 29 | readability. 30 | 31 | Examples: 32 | 33 | - `2017-01-01 Add an eslint-immutable plugin.md` 34 | 35 | - `2017-01-02 Improve project directories structure.md` 36 | 37 | - `2017-01-03 Improve deployment security.md` 38 | 39 | ## Decision record template 40 | 41 | All decision record files must follow the 42 | [decision template](./DECISION_TEMPLATE.md). 43 | -------------------------------------------------------------------------------- /test/utilities.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-confusing-void-expression */ 2 | import { AllowedMergeMethods } from '../src/utilities/inputParsers'; 3 | 4 | export const useSetTimeoutImmediateInvocation = (): jest.SpyInstance< 5 | NodeJS.Timeout, 6 | [callback: () => void, ms?: number | undefined] 7 | > => 8 | jest 9 | .spyOn(global, 'setTimeout') 10 | .mockImplementation( 11 | (callback: () => void): NodeJS.Timeout => 12 | callback() as unknown as NodeJS.Timeout, 13 | ); 14 | 15 | export const approveAndMergePullRequestMutation = ( 16 | mergeMethod: AllowedMergeMethods, 17 | ): string => ` 18 | mutation ($commitHeadline: String!, $pullRequestId: ID!) { 19 | addPullRequestReview(input: {event: APPROVE, pullRequestId: $pullRequestId}) { 20 | clientMutationId 21 | } 22 | mergePullRequest(input: {commitBody: " ", commitHeadline: $commitHeadline, mergeMethod: ${mergeMethod}, pullRequestId: $pullRequestId}) { 23 | clientMutationId 24 | } 25 | } 26 | `; 27 | 28 | export const mergePullRequestMutation = ( 29 | mergeMethod: AllowedMergeMethods, 30 | ): string => ` 31 | mutation ($commitHeadline: String!, $pullRequestId: ID!) { 32 | mergePullRequest(input: {commitBody: " ", commitHeadline: $commitHeadline, mergeMethod: ${mergeMethod}, pullRequestId: $pullRequestId}) { 33 | clientMutationId 34 | } 35 | } 36 | `; 37 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { getInput, setFailed } from '@actions/core'; 2 | import { context, getOctokit } from '@actions/github'; 3 | 4 | import { 5 | continuousIntegrationEndHandle, 6 | pullRequestHandle, 7 | } from './eventHandlers'; 8 | import { logInfo, logWarning } from './utilities/log'; 9 | 10 | const DEFAULT_MAXIMUM_RETRIES = 3; 11 | 12 | const GITHUB_TOKEN = getInput('GITHUB_TOKEN'); 13 | const GITHUB_LOGIN = getInput('GITHUB_LOGIN'); 14 | const MAXIMUM_RETRIES = 15 | getInput('MAXIMUM_RETRIES').trim() === '' 16 | ? DEFAULT_MAXIMUM_RETRIES 17 | : Number.parseInt(getInput('MAXIMUM_RETRIES'), 10); 18 | 19 | const octokit = getOctokit(GITHUB_TOKEN); 20 | 21 | const main = async (): Promise => { 22 | logInfo(`Automatic merges enabled for GitHub login: ${GITHUB_LOGIN}.`); 23 | 24 | switch (context.eventName) { 25 | case 'check_suite': 26 | case 'workflow_run': 27 | return continuousIntegrationEndHandle( 28 | octokit, 29 | GITHUB_LOGIN, 30 | MAXIMUM_RETRIES, 31 | ); 32 | case 'pull_request': 33 | case 'pull_request_target': 34 | return pullRequestHandle(octokit, GITHUB_LOGIN, MAXIMUM_RETRIES); 35 | default: 36 | logWarning(`Unknown event ${context.eventName}, skipping.`); 37 | } 38 | }; 39 | 40 | // eslint-disable-next-line unicorn/prefer-top-level-await 41 | main().catch((error: Error): void => { 42 | setFailed( 43 | `An unexpected error occurred: ${error.message}, ${ 44 | error.stack ?? 'no stack trace' 45 | }.`, 46 | ); 47 | }); 48 | -------------------------------------------------------------------------------- /rfcs/README.md: -------------------------------------------------------------------------------- 1 | # Requests for comments 2 | 3 | ## What is an RFC? 4 | 5 | An RFC is a document describing ideas and intents prior to implementation so 6 | that it may be freely discussed and perfected without wasting time on an 7 | implementation that might be invalid by design. 8 | 9 | ## What is the difference with decisions? 10 | 11 | A decision log tracks simple decisions that affect the entire codebase, such as 12 | style guidelines alignments, technology choices and configuration changes. 13 | 14 | RFCs have a more focused scope around feature implementation. 15 | 16 | ## RFC file name convention 17 | 18 | All RFCs must be in markdown format with an `.md` extension. 19 | 20 | File name convention: 21 | 22 | `[date] [name].md` 23 | 24 | - The `date` format: `YYYY-MM-DD`. This is an ISO standard and helps for sorting 25 | by date. This should be the creation date of an RFC. 26 | 27 | - The `name` has a present tense imperative verb phrase. This helps readability 28 | and matches the commit message format. 29 | 30 | - The `name` uses sentence capitalization and spaces. This is helpful for 31 | readability. 32 | 33 | Examples: 34 | 35 | - `2000-01-01 Verify email addresses.md` 36 | 37 | - `2000-01-02 Saturate IoT logs with metadata.md` 38 | 39 | - `2000-01-03 Improve logging performance.md` 40 | 41 | ## RFC template 42 | 43 | All RFCs files must follow the [RFC template](./RFC_TEMPLATE.md) and be written 44 | in third person passive voice to emphasize who or what receives the action of 45 | the verb, and de-emphasize the subject. 46 | -------------------------------------------------------------------------------- /src/common/makeGraphqlIterator.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-await-in-loop */ 2 | /* eslint-disable @typescript-eslint/prefer-destructuring */ 3 | 4 | import { getOctokit } from '@actions/github'; 5 | import type { GraphQlQueryResponseData } from '@octokit/graphql'; 6 | 7 | const MAX_PAGE_SIZE = 100; 8 | 9 | export interface IterableList { 10 | edges: Array<{ 11 | node: Iterable; 12 | }>; 13 | pageInfo: { 14 | endCursor: string; 15 | hasNextPage: boolean; 16 | }; 17 | } 18 | 19 | export const makeGraphqlIterator = async function* ( 20 | octokit: ReturnType, 21 | options: { 22 | extractListFunction: ( 23 | response: GraphQlQueryResponseData, 24 | ) => IterableList | undefined; 25 | parameters: object; 26 | query: string; 27 | }, 28 | ): AsyncGenerator { 29 | const { query, parameters, extractListFunction } = options; 30 | 31 | let cursor: string | undefined = undefined; 32 | let hasNextPage: boolean = true; 33 | 34 | const { pageSize = MAX_PAGE_SIZE }: { pageSize?: number } = parameters; 35 | 36 | while (hasNextPage) { 37 | const response = await octokit.graphql(query, { 38 | ...parameters, 39 | endCursor: cursor, 40 | pageSize, 41 | }); 42 | 43 | const list = extractListFunction(response); 44 | 45 | if (list === undefined) { 46 | return; 47 | } 48 | 49 | cursor = list.pageInfo.endCursor; 50 | hasNextPage = list.pageInfo.hasNextPage; 51 | 52 | for (const { node } of list.edges) { 53 | yield node; 54 | } 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | branding: 2 | color: gray-dark 3 | icon: git-merge 4 | description: 5 | 'Automatically merge Pull Requests from the indicated github account.' 6 | inputs: 7 | GITHUB_LOGIN: 8 | default: 'dependabot' 9 | description: > 10 | The GitHub login for which automatic merges are enabled. Supports 11 | micromatch. 12 | required: false 13 | GITHUB_TOKEN: 14 | description: 'A GitHub token.' 15 | required: true 16 | ENABLE_GITHUB_API_PREVIEW: 17 | default: 'false' 18 | description: > 19 | Indicates if GitHub preview APIs can be used to access pull request fields 20 | that provide more detailed information about the merge state. 21 | required: false 22 | MERGE_METHOD: 23 | default: SQUASH 24 | description: 25 | 'Represents available types of methods to use when merging a pull request. 26 | One of: MERGE, STASH or REBASE' 27 | required: false 28 | PRESET: 29 | description: 30 | 'Enable additional functionality to better personalize the behavior. One 31 | of: DEPENDABOT_MINOR or DEPENDABOT_PATCH.' 32 | required: false 33 | ENABLED_FOR_MANUAL_CHANGES: 34 | description: 35 | 'Enable automatic merges when changes are made to the PR by a different 36 | author than the original one. Requires commits to be signed. One of: 37 | "true" or "false".' 38 | default: 'false' 39 | required: false 40 | MAXIMUM_RETRIES: 41 | description: 'Maximum retry attempts to merge or get the PR information.' 42 | default: '3' 43 | required: false 44 | name: 'Merge me!' 45 | runs: 46 | main: dist/index.js 47 | using: node20 48 | -------------------------------------------------------------------------------- /src/utilities/prTitleParsers.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | /* eslint-disable max-statements */ 3 | 4 | import { AllowedMergePresets, parseInputMergePreset } from './inputParsers'; 5 | 6 | export const checkPullRequestTitleForMergePreset = (title: string): boolean => { 7 | const category = parseInputMergePreset(); 8 | 9 | if (category === undefined) { 10 | return true; 11 | } 12 | 13 | const semanticVersionTitleRegExp = 14 | /bump .* from (?\S+) to (?\S+)/iu; 15 | const match = semanticVersionTitleRegExp.exec(title); 16 | 17 | if (match === null) { 18 | return true; 19 | } 20 | 21 | const semVersionRegExp = 22 | /^(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)$/u; 23 | 24 | const matchGroups = match.groups; 25 | // Using non-null assertions per: https://github.com/microsoft/TypeScript/issues/32098 26 | const fromMatch = semVersionRegExp.exec(matchGroups!.from!); 27 | const toMatch = semVersionRegExp.exec(matchGroups!.to!); 28 | 29 | if (fromMatch === null || toMatch === null) { 30 | return true; 31 | } 32 | 33 | const fromMatchGroups = fromMatch.groups; 34 | const toMatchGroups = toMatch.groups; 35 | 36 | const fromMajor = fromMatchGroups!.major!; 37 | const toMajor = toMatchGroups!.major!; 38 | 39 | if (Number.parseInt(fromMajor, 10) !== Number.parseInt(toMajor, 10)) { 40 | return false; 41 | } 42 | 43 | const fromMinor = fromMatchGroups!.minor!; 44 | const toMinor = toMatchGroups!.minor!; 45 | 46 | if (Number.parseInt(fromMinor, 10) !== Number.parseInt(toMinor, 10)) { 47 | return category === AllowedMergePresets.DEPENDABOT_MINOR; 48 | } 49 | 50 | return true; 51 | }; 52 | -------------------------------------------------------------------------------- /src/common/getPullRequestCommits.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from '@actions/github'; 2 | import type { GraphQlQueryResponseData } from '@octokit/graphql'; 3 | 4 | import { PullRequestCommitNode } from '../types'; 5 | import { IterableList, makeGraphqlIterator } from './makeGraphqlIterator'; 6 | 7 | const findPullRequestCommitsQuery = ` 8 | query FindPullRequestsInfoByReferenceName($repositoryOwner: String!, $repositoryName: String!, $pullRequestNumber: Int!, $pageSize: Int!, $endCursor: String) { 9 | repository(owner: $repositoryOwner, name: $repositoryName) { 10 | pullRequest(number: $pullRequestNumber) { 11 | commits(first: $pageSize, after: $endCursor) { 12 | edges { 13 | node { 14 | commit { 15 | author { 16 | user { 17 | login 18 | } 19 | } 20 | signature { 21 | isValid 22 | } 23 | } 24 | } 25 | } 26 | pageInfo { 27 | endCursor 28 | hasNextPage 29 | } 30 | } 31 | } 32 | } 33 | } 34 | `; 35 | 36 | export const getPullRequestCommitsIterator = ( 37 | octokit: ReturnType, 38 | query: { 39 | pullRequestNumber: number; 40 | repositoryName: string; 41 | repositoryOwner: string; 42 | }, 43 | ): AsyncGenerator => 44 | makeGraphqlIterator(octokit, { 45 | extractListFunction: ( 46 | response: GraphQlQueryResponseData, 47 | ): IterableList => 48 | response.repository.pullRequest?.commits, 49 | parameters: query, 50 | query: findPullRequestCommitsQuery, 51 | }); 52 | -------------------------------------------------------------------------------- /src/common/listBranchProtectionRules.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from '@actions/github'; 2 | import type { GraphQlQueryResponseData } from '@octokit/graphql'; 3 | 4 | import { IterableList, makeGraphqlIterator } from './makeGraphqlIterator'; 5 | 6 | const listBranchProtectionRulesQuery = ` 7 | query($endCursor: String, $pageSize: Int!, $repositoryName: String!, $repositoryOwner: String!) { 8 | repository(name: $repositoryName, owner: $repositoryOwner) { 9 | branchProtectionRules(first: $pageSize, after: $endCursor) { 10 | edges { 11 | node { 12 | pattern 13 | requiresStrictStatusChecks 14 | } 15 | } 16 | pageInfo { 17 | endCursor 18 | hasNextPage 19 | } 20 | } 21 | } 22 | } 23 | `; 24 | 25 | export interface BranchProtectionRule { 26 | pattern: string; 27 | requiresStrictStatusChecks: boolean; 28 | } 29 | 30 | /** 31 | * Returns an array containing a repository's configured partial branch 32 | * protection rules. 33 | */ 34 | export const listBranchProtectionRules = async ( 35 | octokit: ReturnType, 36 | repositoryOwner: string, 37 | repositoryName: string, 38 | ): Promise => { 39 | const iterator = makeGraphqlIterator(octokit, { 40 | extractListFunction: ( 41 | response: GraphQlQueryResponseData, 42 | ): IterableList => 43 | response.repository.branchProtectionRules, 44 | parameters: { 45 | pageSize: 100, 46 | repositoryName, 47 | repositoryOwner, 48 | }, 49 | query: listBranchProtectionRulesQuery, 50 | }); 51 | 52 | const branchProtectionRules: BranchProtectionRule[] = []; 53 | 54 | for await (const node of iterator) { 55 | // eslint-disable-next-line functional/immutable-data 56 | branchProtectionRules.push(node); 57 | } 58 | 59 | return branchProtectionRules; 60 | }; 61 | -------------------------------------------------------------------------------- /dist/types.d.ts.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"","sourceRoot":"","sources":["file:///home/runner/work/merge-me-action/merge-me-action/src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE;QACN,cAAc,EAAE,MAAM,CAAC;KACxB,CAAC;CACH;AAED,MAAM,MAAM,WAAW,GAAG,KAAK,CAC3B;IACE,IAAI,EAAE;QACJ,KAAK,EACD,UAAU,GACV,mBAAmB,GACnB,WAAW,GACX,WAAW,GACX,SAAS,CAAC;KACf,CAAC;CACH,GACD,SAAS,CACZ,CAAC;AAEF,MAAM,WAAW,sBAAsB;IACrC,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC,cAAc,EAAE,cAAc,CAAC;IAC/B,MAAM,EAAE,OAAO,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,gBAAgB,EAAE,gBAAgB,CAAC;IACnC,gBAAgB,EAAE,MAAM,CAAC;IACzB,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;CAC1B;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE;QACN,KAAK,EAAE,MAAM,CAAC;KACf,CAAC;IACF,IAAI,EAAE;QACJ,GAAG,EAAE,MAAM,CAAC;KACb,CAAC;IACF,OAAO,EAAE;QACP,KAAK,EAAE,KAAK,CAAC;YACX,IAAI,EAAE;gBACJ,MAAM,EAAE;oBACN,OAAO,EAAE,MAAM,CAAC;oBAChB,eAAe,EAAE,MAAM,CAAC;iBACzB,CAAC;aACH,CAAC;SACH,CAAC,CAAC;KACJ,CAAC;IACF,EAAE,EAAE,MAAM,CAAC;IACX,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC,SAAS,EAAE,cAAc,CAAC;IAC1B,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE;QAAE,KAAK,EAAE,WAAW,CAAA;KAAE,CAAC;IAChC,KAAK,EAAE,gBAAgB,CAAC;IACxB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE;QACN,MAAM,EAAE;YACN,IAAI,EAAE;gBACJ,KAAK,EAAE,MAAM,CAAC;aACf,CAAC;SACH,CAAC;QACF,SAAS,EAAE;YACT,OAAO,EAAE,OAAO,CAAC;SAClB,GAAG,IAAI,CAAC;KACV,CAAC;CACH;AAED,MAAM,WAAW,mCAAmC;IAClD,UAAU,EAAE;QACV,WAAW,EAAE,WAAW,CAAC;KAC1B,CAAC;CACH;AAED,MAAM,WAAW,8BAA8B;IAC7C,UAAU,EAAE;QACV,WAAW,EAAE;YACX,OAAO,EAAE;gBACP,KAAK,EAAE,KAAK,CAAC;oBACX,IAAI,EAAE,qBAAqB,CAAC;iBAC7B,CAAC,CAAC;gBACH,QAAQ,EAAE;oBACR,SAAS,MAAC;oBACV,WAAW,MAAC;iBACb,CAAC;aACH,CAAC;SACH,CAAC;KACH,CAAC;CACH;AAED,MAAM,MAAM,cAAc,GAAG,aAAa,GAAG,WAAW,GAAG,SAAS,CAAC;AAErE,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,CAAC;AAE5D,MAAM,MAAM,gBAAgB,GACxB,QAAQ,GACR,SAAS,GACT,OAAO,GACP,OAAO,GACP,OAAO,GACP,WAAW,GACX,SAAS,GACT,UAAU,CAAC"} -------------------------------------------------------------------------------- /src/utilities/inputParsers.spec.ts: -------------------------------------------------------------------------------- 1 | import * as actionsCore from '@actions/core'; 2 | 3 | import { parseInputMergeMethod, parseInputMergePreset } from './inputParsers'; 4 | 5 | const getInputSpy = jest.spyOn(actionsCore, 'getInput').mockImplementation(); 6 | 7 | describe('parseInputMergeMethod', (): void => { 8 | it.each(['MERGE', 'SQUASH', 'REBASE'])( 9 | 'parse allowed method', 10 | (mergeMethod: string): void => { 11 | expect.assertions(1); 12 | 13 | getInputSpy.mockReturnValueOnce(mergeMethod); 14 | 15 | expect(parseInputMergeMethod()).toStrictEqual(mergeMethod); 16 | }, 17 | ); 18 | 19 | it('returns default merge method if merge method is not allowed', (): void => { 20 | expect.assertions(1); 21 | 22 | getInputSpy.mockReturnValueOnce('OTHER'); 23 | 24 | expect(parseInputMergeMethod()).toBe('SQUASH'); 25 | }); 26 | 27 | it('returns undefined if merge method is not provided', (): void => { 28 | expect.assertions(1); 29 | 30 | getInputSpy.mockReturnValueOnce(''); 31 | 32 | expect(parseInputMergeMethod()).toBe('SQUASH'); 33 | }); 34 | }); 35 | 36 | describe('parseInputMergePreset', (): void => { 37 | it.each(['DEPENDABOT_MINOR', 'DEPENDABOT_PATCH'])( 38 | 'parse allowed category', 39 | (mergeCategory: string): void => { 40 | expect.assertions(1); 41 | 42 | getInputSpy.mockReturnValueOnce(mergeCategory); 43 | 44 | expect(parseInputMergePreset()).toStrictEqual(mergeCategory); 45 | }, 46 | ); 47 | 48 | it('returns default merge category if merge category is not allowed', (): void => { 49 | expect.assertions(1); 50 | 51 | getInputSpy.mockReturnValueOnce('OTHER'); 52 | 53 | expect(parseInputMergePreset()).toBeUndefined(); 54 | }); 55 | 56 | it('returns default merge category if merge category is not provided', (): void => { 57 | expect.assertions(1); 58 | 59 | getInputSpy.mockReturnValueOnce(''); 60 | 61 | expect(parseInputMergePreset()).toBeUndefined(); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /dist/types.d.ts: -------------------------------------------------------------------------------- 1 | export interface CommitMessageHeadlineGroup { 2 | groups: { 3 | commitHeadline: string; 4 | }; 5 | } 6 | export type ReviewEdges = Array<{ 7 | node: { 8 | state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'DISMISSED' | 'PENDING'; 9 | }; 10 | } | undefined>; 11 | export interface PullRequestInformation { 12 | authorLogin: string; 13 | commitMessage: string; 14 | commitMessageHeadline: string; 15 | mergeStateStatus?: MergeStateStatus; 16 | mergeableState: MergeableState; 17 | merged: boolean; 18 | pullRequestId: string; 19 | pullRequestNumber: number; 20 | pullRequestState: PullRequestState; 21 | pullRequestTitle: string; 22 | repositoryName: string; 23 | repositoryOwner: string; 24 | reviewEdges: ReviewEdges; 25 | } 26 | export interface PullRequest { 27 | author: { 28 | login: string; 29 | }; 30 | base: { 31 | ref: string; 32 | }; 33 | commits: { 34 | edges: Array<{ 35 | node: { 36 | commit: { 37 | message: string; 38 | messageHeadline: string; 39 | }; 40 | }; 41 | }>; 42 | }; 43 | id: string; 44 | mergeStateStatus?: MergeStateStatus; 45 | mergeable: MergeableState; 46 | merged: boolean; 47 | number: number; 48 | reviews: { 49 | edges: ReviewEdges; 50 | }; 51 | state: PullRequestState; 52 | title: string; 53 | } 54 | export interface PullRequestCommitNode { 55 | commit: { 56 | author: { 57 | user: { 58 | login: string; 59 | }; 60 | }; 61 | signature: { 62 | isValid: boolean; 63 | } | null; 64 | }; 65 | } 66 | export interface FindPullRequestInfoByNumberResponse { 67 | repository: { 68 | pullRequest: PullRequest; 69 | }; 70 | } 71 | export interface FindPullRequestCommitsResponse { 72 | repository: { 73 | pullRequest: { 74 | commits: { 75 | edges: Array<{ 76 | node: PullRequestCommitNode; 77 | }>; 78 | pageInfo: { 79 | endCursor: any; 80 | hasNextPage: any; 81 | }; 82 | }; 83 | }; 84 | }; 85 | } 86 | export type MergeableState = 'CONFLICTING' | 'MERGEABLE' | 'UNKNOWN'; 87 | export type PullRequestState = 'CLOSED' | 'MERGED' | 'OPEN'; 88 | export type MergeStateStatus = 'BEHIND' | 'BLOCKED' | 'CLEAN' | 'DIRTY' | 'DRAFT' | 'HAS_HOOKS' | 'UNKNOWN' | 'UNSTABLE'; 89 | -------------------------------------------------------------------------------- /.github/workflows/continuous-delivery.yaml: -------------------------------------------------------------------------------- 1 | name: Continuous Delivery 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | if: contains(github.event.commits[0].message, 'chore(release)') == false 11 | name: Release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v6 16 | with: 17 | # Fetch all history. 18 | fetch-depth: 0 19 | persist-credentials: false 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v6.1.0 22 | with: 23 | cache: 'npm' 24 | node-version: 20 25 | registry-url: 'https://npm.pkg.github.com' 26 | - name: Install dependencies 27 | run: npm ci --ignore-scripts --no-audit --no-progress --prefer-offline 28 | - name: Build 29 | run: | 30 | # Workaround https://github.com/zeit/ncc/issues/457. 31 | mv tsconfig.production.json tsconfig.json 32 | npm run build 33 | - env: 34 | GITHUB_TOKEN: ${{ secrets.DOTTBOTT_TOKEN }} 35 | id: release 36 | name: Release 37 | uses: ridedott/release-me-action@v3.10.85 38 | with: 39 | commit-assets: | 40 | ./dist 41 | node-module: true 42 | release-rules-append: | 43 | [ 44 | { "release": false, "scope": "deps-dev", "type": "chore" } 45 | ] 46 | - if: steps.release.outputs.released == 'true' 47 | name: Setup Node.js with GitHub Packages 48 | uses: actions/setup-node@v6.1.0 49 | with: 50 | registry-url: 'https://npm.pkg.github.com' 51 | - env: 52 | NODE_AUTH_TOKEN: ${{ secrets.DOTTBOTT_TOKEN }} 53 | if: steps.release.outputs.released == 'true' 54 | name: Publish to GitHub Packages 55 | run: npm publish 56 | - if: steps.release.outputs.released == 'true' 57 | name: Authenticate Git 58 | uses: actions/checkout@v6 59 | with: 60 | fetch-depth: 1 61 | persist-credentials: true 62 | token: ${{ secrets.DOTTBOTT_TOKEN }} 63 | - if: steps.release.outputs.released == 'true' 64 | name: Tag 65 | run: | 66 | git config --global user.name '{{ secrets.DOTTBOTT_USER_NAME }}' 67 | git config --global user.email '{{ secrets.DOTTBOTT_USER_EMAIL }}' 68 | git push origin :refs/tags/'v${{ steps.release.outputs.major }}' 69 | git tag 'v${{ steps.release.outputs.major }}' --force 70 | git push --tags 71 | timeout-minutes: 10 72 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface CommitMessageHeadlineGroup { 2 | groups: { 3 | commitHeadline: string; 4 | }; 5 | } 6 | 7 | export type ReviewEdges = Array< 8 | | { 9 | node: { 10 | state: 11 | | 'APPROVED' 12 | | 'CHANGES_REQUESTED' 13 | | 'COMMENTED' 14 | | 'DISMISSED' 15 | | 'PENDING'; 16 | }; 17 | } 18 | | undefined 19 | >; 20 | 21 | export interface PullRequestInformation { 22 | authorLogin: string; 23 | commitMessage: string; 24 | commitMessageHeadline: string; 25 | mergeStateStatus?: MergeStateStatus; 26 | mergeableState: MergeableState; 27 | merged: boolean; 28 | pullRequestId: string; 29 | pullRequestNumber: number; 30 | pullRequestState: PullRequestState; 31 | pullRequestTitle: string; 32 | repositoryName: string; 33 | repositoryOwner: string; 34 | reviewEdges: ReviewEdges; 35 | } 36 | 37 | export interface PullRequest { 38 | author: { 39 | login: string; 40 | }; 41 | base: { 42 | ref: string; 43 | }; 44 | commits: { 45 | edges: Array<{ 46 | node: { 47 | commit: { 48 | message: string; 49 | messageHeadline: string; 50 | }; 51 | }; 52 | }>; 53 | }; 54 | id: string; 55 | mergeStateStatus?: MergeStateStatus; 56 | mergeable: MergeableState; 57 | merged: boolean; 58 | number: number; 59 | reviews: { edges: ReviewEdges }; 60 | state: PullRequestState; 61 | title: string; 62 | } 63 | 64 | export interface PullRequestCommitNode { 65 | commit: { 66 | author: { 67 | user: { 68 | login: string; 69 | }; 70 | }; 71 | signature: { 72 | isValid: boolean; 73 | } | null; 74 | }; 75 | } 76 | 77 | export interface FindPullRequestInfoByNumberResponse { 78 | repository: { 79 | pullRequest: PullRequest; 80 | }; 81 | } 82 | 83 | export interface FindPullRequestCommitsResponse { 84 | repository: { 85 | pullRequest: { 86 | commits: { 87 | edges: Array<{ 88 | node: PullRequestCommitNode; 89 | }>; 90 | pageInfo: { 91 | endCursor; 92 | hasNextPage; 93 | }; 94 | }; 95 | }; 96 | }; 97 | } 98 | 99 | export type MergeableState = 'CONFLICTING' | 'MERGEABLE' | 'UNKNOWN'; 100 | 101 | export type PullRequestState = 'CLOSED' | 'MERGED' | 'OPEN'; 102 | 103 | export type MergeStateStatus = 104 | | 'BEHIND' 105 | | 'BLOCKED' 106 | | 'CLEAN' 107 | | 'DIRTY' 108 | | 'DRAFT' 109 | | 'HAS_HOOKS' 110 | | 'UNKNOWN' 111 | | 'UNSTABLE'; 112 | -------------------------------------------------------------------------------- /src/eventHandlers/pullRequest/index.ts: -------------------------------------------------------------------------------- 1 | import { getInput } from '@actions/core'; 2 | import { context, getOctokit } from '@actions/github'; 3 | import { isMatch } from 'micromatch'; 4 | 5 | import { computeRequiresStrictStatusChecksForRefs as computeRequiresStrictStatusChecksForReferences } from '../../common/computeRequiresStrictStatusChecksForRefs'; 6 | import { getMergeablePullRequestInformationByPullRequestNumber } from '../../common/getPullRequestInformation'; 7 | import { listBranchProtectionRules } from '../../common/listBranchProtectionRules'; 8 | import { tryMerge } from '../../common/merge'; 9 | import { logInfo, logWarning } from '../../utilities/log'; 10 | 11 | export const pullRequestHandle = async ( 12 | octokit: ReturnType, 13 | gitHubLogin: string, 14 | maximumRetries: number, 15 | ): Promise => { 16 | const githubPreviewApiEnabled = 17 | getInput('ENABLE_GITHUB_API_PREVIEW') === 'true'; 18 | const { pull_request: pullRequest } = context.payload; 19 | 20 | if (pullRequest === undefined) { 21 | logWarning('Required pull request information is unavailable.'); 22 | 23 | return; 24 | } 25 | 26 | const [branchProtectionRules, pullRequestInformation] = await Promise.all([ 27 | await listBranchProtectionRules( 28 | octokit, 29 | context.repo.owner, 30 | context.repo.repo, 31 | ), 32 | getMergeablePullRequestInformationByPullRequestNumber( 33 | octokit, 34 | { 35 | pullRequestNumber: pullRequest.number, 36 | repositoryName: context.repo.repo, 37 | repositoryOwner: context.repo.owner, 38 | }, 39 | { 40 | githubPreviewApiEnabled, 41 | }, 42 | ), 43 | ]); 44 | 45 | const [requiresStrictStatusChecks] = 46 | computeRequiresStrictStatusChecksForReferences(branchProtectionRules, [ 47 | pullRequest.base.ref as string, 48 | ]); 49 | 50 | if (pullRequestInformation === undefined) { 51 | logWarning('Unable to fetch pull request information.'); 52 | } else if (isMatch(pullRequestInformation.authorLogin, gitHubLogin)) { 53 | logInfo( 54 | `Found pull request information: ${JSON.stringify( 55 | pullRequestInformation, 56 | )}.`, 57 | ); 58 | 59 | await tryMerge( 60 | octokit, 61 | { 62 | maximumRetries, 63 | requiresStrictStatusChecks, 64 | }, 65 | { 66 | ...pullRequestInformation, 67 | commitMessageHeadline: pullRequest.title, 68 | }, 69 | ); 70 | } else { 71 | logInfo( 72 | `Pull request #${pullRequestInformation.pullRequestNumber.toString()} created by ${ 73 | pullRequestInformation.authorLogin 74 | }, not ${gitHubLogin}, skipping.`, 75 | ); 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /src/utilities/log.spec.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | 3 | import { logDebug, logError, logInfo, logWarning } from './log'; 4 | 5 | const debugSpy = jest.spyOn(core, 'debug').mockImplementation(); 6 | const errorSpy = jest.spyOn(core, 'error').mockImplementation(); 7 | const infoSpy = jest.spyOn(core, 'info').mockImplementation(); 8 | const warningSpy = jest.spyOn(core, 'warning').mockImplementation(); 9 | 10 | /* eslint-disable functional/immutable-data */ 11 | const errorWithoutStack = new Error('I am an error.'); 12 | delete errorWithoutStack.stack; 13 | 14 | const errorWithStack = new Error('I am an error.'); 15 | errorWithStack.stack = 'I am a stack.'; 16 | /* eslint-enable functional/immutable-data */ 17 | 18 | describe.each< 19 | [ 20 | string, 21 | (value: unknown) => void, 22 | jest.SpyInstance< 23 | void, 24 | [message: Error | string, properties?: core.AnnotationProperties] 25 | >, 26 | ] 27 | >([ 28 | ['logError', logError, errorSpy], 29 | ['logWarning', logWarning, warningSpy], 30 | ])( 31 | '%s', 32 | ( 33 | _: string, 34 | logFunction: (value: unknown) => void, 35 | coreFunction: jest.SpyInstance< 36 | void, 37 | [message: Error | string, properties?: core.AnnotationProperties] 38 | >, 39 | ): void => { 40 | it.each<[unknown, string]>([ 41 | ['I am a string.', 'I am a string.'], 42 | [{ property: 1 }, '{"property":1}'], 43 | [errorWithoutStack, 'Error: I am an error.'], 44 | [errorWithStack, 'I am a stack.'], 45 | [1, '1'], 46 | ])( 47 | 'logs value in a correct format (sample %#)', 48 | (logged: unknown, expected: string): void => { 49 | expect.assertions(1); 50 | 51 | logFunction(logged); 52 | 53 | expect(coreFunction).toHaveBeenCalledWith(expected); 54 | }, 55 | ); 56 | }, 57 | ); 58 | 59 | describe.each< 60 | [string, (value: unknown) => void, jest.SpyInstance] 61 | >([ 62 | ['logDebug', logDebug, debugSpy], 63 | ['logInfo', logInfo, infoSpy], 64 | ])( 65 | '%s', 66 | ( 67 | _: string, 68 | logFunction: (value: unknown) => void, 69 | coreFunction: jest.SpyInstance, 70 | ): void => { 71 | it.each<[unknown, string]>([ 72 | ['I am a string.', 'I am a string.'], 73 | [{ property: 1 }, '{"property":1}'], 74 | [errorWithoutStack, 'Error: I am an error.'], 75 | [errorWithStack, 'I am a stack.'], 76 | [1, '1'], 77 | ])( 78 | 'logs value in a correct format (sample %#)', 79 | (logged: unknown, expected: string): void => { 80 | expect.assertions(1); 81 | 82 | logFunction(logged); 83 | 84 | expect(coreFunction).toHaveBeenCalledWith(expected); 85 | }, 86 | ); 87 | }, 88 | ); 89 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "args": ["${relativeFile}"], 5 | "cwd": "${workspaceRoot}", 6 | "internalConsoleOptions": "neverOpen", 7 | "name": "run:ts", 8 | "protocol": "inspector", 9 | "request": "launch", 10 | "runtimeArgs": ["-r", "ts-node/register"], 11 | "type": "node" 12 | }, 13 | { 14 | "args": ["--runInBand"], 15 | "console": "integratedTerminal", 16 | "cwd": "${workspaceFolder}", 17 | "disableOptimisticBPs": true, 18 | "internalConsoleOptions": "neverOpen", 19 | "name": "test", 20 | "program": "${workspaceFolder}/node_modules/.bin/jest", 21 | "request": "launch", 22 | "type": "node", 23 | "windows": { 24 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 25 | } 26 | }, 27 | { 28 | "args": ["--runInBand", "--watch"], 29 | "console": "integratedTerminal", 30 | "cwd": "${workspaceFolder}", 31 | "disableOptimisticBPs": true, 32 | "internalConsoleOptions": "neverOpen", 33 | "name": "test:watch", 34 | "program": "${workspaceFolder}/node_modules/.bin/jest", 35 | "request": "launch", 36 | "type": "node", 37 | "windows": { 38 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 39 | } 40 | }, 41 | { 42 | "args": ["--runInBand", "${relativeFile}"], 43 | "console": "integratedTerminal", 44 | "disableOptimisticBPs": true, 45 | "internalConsoleOptions": "neverOpen", 46 | "name": "test:current", 47 | "program": "${workspaceFolder}/node_modules/.bin/jest", 48 | "request": "launch", 49 | "type": "node", 50 | "windows": { 51 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 52 | } 53 | }, 54 | { 55 | "args": ["--runInBand", "--watch", "${relativeFile}"], 56 | "console": "integratedTerminal", 57 | "disableOptimisticBPs": true, 58 | "internalConsoleOptions": "neverOpen", 59 | "name": "test:current:watch", 60 | "program": "${workspaceFolder}/node_modules/.bin/jest", 61 | "request": "launch", 62 | "type": "node", 63 | "windows": { 64 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 65 | } 66 | }, 67 | { 68 | "args": ["--coverage", "--runInBand", "--watch", "${relativeFile}"], 69 | "console": "integratedTerminal", 70 | "disableOptimisticBPs": true, 71 | "internalConsoleOptions": "neverOpen", 72 | "name": "test:current:watch:coverage", 73 | "program": "${workspaceFolder}/node_modules/.bin/jest", 74 | "request": "launch", 75 | "type": "node", 76 | "windows": { 77 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 78 | } 79 | } 80 | ], 81 | "version": "0.2.0" 82 | } 83 | -------------------------------------------------------------------------------- /src/common/computeRequiresStrictStatusChecksForRefs.spec.ts: -------------------------------------------------------------------------------- 1 | import { computeRequiresStrictStatusChecksForRefs as computeRequiresStrictStatusChecksForReferences } from './computeRequiresStrictStatusChecksForRefs'; 2 | 3 | /** 4 | * Tests 5 | */ 6 | describe('computeRequiresStrictStatusChecksForRefs', (): void => { 7 | it('returns false for all refs when no branch protection rules exist for the repository', (): void => { 8 | expect.assertions(1); 9 | 10 | const result = computeRequiresStrictStatusChecksForReferences( 11 | [], 12 | ['master', 'dev'], 13 | ); 14 | 15 | expect(result).toStrictEqual([false, false]); 16 | }); 17 | 18 | it('returns false for all refs when none of the branch protection rule patterns match provided refs', (): void => { 19 | expect.assertions(1); 20 | 21 | const result = computeRequiresStrictStatusChecksForReferences( 22 | [ 23 | { 24 | pattern: 'test1', 25 | requiresStrictStatusChecks: true, 26 | }, 27 | { 28 | pattern: 'test2', 29 | requiresStrictStatusChecks: true, 30 | }, 31 | ], 32 | ['master', 'dev'], 33 | ); 34 | 35 | expect(result).toStrictEqual([false, false]); 36 | }); 37 | 38 | it('returns false for all refs when all matching branch protection rule patterns do not require strict status checks', (): void => { 39 | expect.assertions(1); 40 | 41 | const result = computeRequiresStrictStatusChecksForReferences( 42 | [ 43 | { 44 | pattern: 'dev', 45 | requiresStrictStatusChecks: false, 46 | }, 47 | { 48 | pattern: 'master', 49 | requiresStrictStatusChecks: false, 50 | }, 51 | ], 52 | ['master', 'dev'], 53 | ); 54 | 55 | expect(result).toStrictEqual([false, false]); 56 | }); 57 | 58 | it('returns true for all refs when branch protection rule patterns match provided refs with wildcard', (): void => { 59 | expect.assertions(1); 60 | 61 | const result = computeRequiresStrictStatusChecksForReferences( 62 | [ 63 | { 64 | pattern: 'test*', 65 | requiresStrictStatusChecks: true, 66 | }, 67 | ], 68 | ['test', 'testing', 'master'], 69 | ); 70 | 71 | expect(result).toStrictEqual([true, true, false]); 72 | }); 73 | 74 | it('returns true for refs when matching branch protection rules require strict status checks', (): void => { 75 | expect.assertions(1); 76 | 77 | const result = computeRequiresStrictStatusChecksForReferences( 78 | [ 79 | { 80 | pattern: 'dev', 81 | requiresStrictStatusChecks: true, 82 | }, 83 | { 84 | pattern: 'master', 85 | requiresStrictStatusChecks: false, 86 | }, 87 | ], 88 | ['master', 'dev'], 89 | ); 90 | 91 | expect(result).toStrictEqual([false, true]); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /test/TestEnv.js: -------------------------------------------------------------------------------- 1 | const { env } = require('process'); 2 | const NodeEnvironment = require('jest-environment-node'); 3 | 4 | class CustomEnvironment extends NodeEnvironment { 5 | constructor(config, context) { 6 | switch (context.docblockPragmas['webhook-pragma']) { 7 | case 'check_suite': 8 | env.GITHUB_ACTION = 'ridedottmerge-me-action'; 9 | env.GITHUB_ACTOR = 'dependabot[bot]'; 10 | env.GITHUB_EVENT_NAME = 'check_suite'; 11 | env.GITHUB_EVENT_PATH = './test/fixtures/ctx.check-suite.json'; 12 | env.GITHUB_REF = 'refs/heads/test-branch'; 13 | env.GITHUB_REPOSITORY = 'test-actor/Test-Repo'; 14 | env.GITHUB_SHA = 'ffac537e6cbbf934b08745a378932722df287a53'; 15 | env.GITHUB_WORKFLOW = 'Auto merge'; 16 | break; 17 | case 'pull_request': 18 | env.GITHUB_ACTION = 'ridedottmerge-me-action'; 19 | env.GITHUB_ACTOR = 'dependabot[bot]'; 20 | env.GITHUB_EVENT_NAME = 'pull_request'; 21 | env.GITHUB_EVENT_PATH = './test/fixtures/ctx.pull-request.json'; 22 | env.GITHUB_REF = 'refs/heads/test-branch'; 23 | env.GITHUB_REPOSITORY = 'test-actor/Test-Repo'; 24 | env.GITHUB_SHA = 'ffac537e6cbbf934b08745a378932722df287a53'; 25 | env.GITHUB_WORKFLOW = 'Auto merge'; 26 | break; 27 | case 'pull_request_for_major_bump': 28 | env.GITHUB_ACTION = 'ridedottmerge-me-action'; 29 | env.GITHUB_ACTOR = 'dependabot[bot]'; 30 | env.GITHUB_EVENT_NAME = 'pull_request'; 31 | env.GITHUB_EVENT_PATH = 32 | './test/fixtures/ctx.pull-request-for-major-bump.json'; 33 | env.GITHUB_REF = 'refs/heads/test-branch'; 34 | env.GITHUB_REPOSITORY = 'test-actor/Test-Repo'; 35 | env.GITHUB_SHA = 'ffac537e6cbbf934b08745a378932722df287a53'; 36 | env.GITHUB_WORKFLOW = 'Auto merge'; 37 | break; 38 | case 'push': 39 | env.GITHUB_ACTION = 'ridedottmerge-me-action'; 40 | env.GITHUB_ACTOR = 'dependabot[bot]'; 41 | env.GITHUB_EVENT_NAME = 'push'; 42 | env.GITHUB_EVENT_PATH = './test/fixtures/ctx.push.json'; 43 | env.GITHUB_REF = 'refs/heads/test-branch'; 44 | env.GITHUB_REPOSITORY = 'test-actor/Test-Repo'; 45 | env.GITHUB_SHA = 'ffac537e6cbbf934b08745a378932722df287a53'; 46 | env.GITHUB_WORKFLOW = 'Continuous Integration'; 47 | break; 48 | 49 | default: 50 | env.GITHUB_ACTION = 'test-value'; 51 | env.GITHUB_ACTOR = 'test-actor'; 52 | env.GITHUB_EVENT_NAME = 'pull_request'; 53 | env.GITHUB_EVENT_PATH = './test/test.github.payload.json'; 54 | env.GITHUB_REF = 'refs/heads/test-branch'; 55 | env.GITHUB_REPOSITORY = 'test-actor/Test-Repo'; 56 | env.GITHUB_SHA = 'ffac537e6cbbf934b08745a378932722df287a53'; 57 | env.GITHUB_WORKFLOW = 'pull_requests'; 58 | break; 59 | } 60 | super(config, context); 61 | this.testPath = context.testPath; 62 | } 63 | 64 | async setup() { 65 | await super.setup(); 66 | } 67 | 68 | async teardown() { 69 | await super.teardown(); 70 | } 71 | 72 | runScript(script) { 73 | return super.runScript(script); 74 | } 75 | } 76 | 77 | module.exports = CustomEnvironment; 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "commitizen": { 4 | "path": "cz-conventional-changelog" 5 | } 6 | }, 7 | "dependencies": { 8 | "@actions/core": "^1.11.1", 9 | "@actions/github": "^5.1.1", 10 | "micromatch": "^4.0.8" 11 | }, 12 | "description": "Automatically merges Pull Requests.", 13 | "devDependencies": { 14 | "@commitlint/cli": "^20.2.0", 15 | "@commitlint/config-conventional": "^20.2.0", 16 | "@ridedott/eslint-config": "^2.27.40", 17 | "@semantic-release/changelog": "^6.0.3", 18 | "@semantic-release/exec": "^7.1.0", 19 | "@semantic-release/git": "^10.0.1", 20 | "@semantic-release/npm": "^12.0.2", 21 | "@semantic-release/release-notes-generator": "^14.1.0", 22 | "@types/jest": "^27.5.0", 23 | "@types/micromatch": "^4.0.10", 24 | "@types/node": "^20.8.9", 25 | "@vercel/ncc": "^0.38.4", 26 | "commitizen": "^4.3.1", 27 | "cspell": "^9.4.0", 28 | "eslint": "^8.57.1", 29 | "http-status-codes": "^2.2.0", 30 | "husky": "^9.1.7", 31 | "jest": "^26.6.3", 32 | "lint-staged": "^16.2.7", 33 | "nock": "^13.5.6", 34 | "npm-run-all": "^4.1.5", 35 | "prettier": "^3.0.3", 36 | "semantic-release": "^24.2.9", 37 | "ts-jest": "^26.5.6", 38 | "ts-node": "^10.9.2" 39 | }, 40 | "engines": { 41 | "node": "20", 42 | "npm": ">=9" 43 | }, 44 | "files": [ 45 | "dist", 46 | "src" 47 | ], 48 | "husky": { 49 | "hooks": { 50 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", 51 | "pre-commit": "npm run husky:pre-commit" 52 | } 53 | }, 54 | "license": "UNLICENSED", 55 | "lint-staged": { 56 | "*.ts": [ 57 | "eslint" 58 | ], 59 | "*.{json,md,ts,yml,yaml}": [ 60 | "prettier --write" 61 | ] 62 | }, 63 | "main": "./dist/index.js", 64 | "name": "@ridedott/merge-me-action", 65 | "private": false, 66 | "publishConfig": { 67 | "access": "restricted", 68 | "registry": "https://npm.pkg.github.com" 69 | }, 70 | "repository": { 71 | "type": "git", 72 | "url": "git@github.com:ridedott/merge-me-action.git" 73 | }, 74 | "scripts": { 75 | "build": "run-s clean:dist build:dist", 76 | "build:dist": "ncc build ./src/index.ts --minify --source-map --v8-cache", 77 | "build:ts": "tsc --project tsconfig.production.json", 78 | "build:ts:watch": "tsc --project tsconfig.production.json --watch", 79 | "clean": "run-p clean:*", 80 | "clean:dist": "rm -rf dist", 81 | "clean:lib": "rm -rf lib", 82 | "cz": "git-cz", 83 | "cz:retry": "git-cz --retry", 84 | "format": "prettier --check '**/*.{js,json,md,ts,yml,yaml}'", 85 | "format:fix": "prettier --write '**/*.{js,json,md,ts,yml,yaml}'", 86 | "husky:lint-staged": "lint-staged", 87 | "husky:pre-commit": "run-p spellcheck husky:lint-staged", 88 | "lint": "eslint --resolve-plugins-relative-to './node_modules/@ridedott/eslint-config' '**/*.ts'", 89 | "lint:fix": "eslint --fix --resolve-plugins-relative-to './node_modules/@ridedott/eslint-config' '**/*.ts'", 90 | "semantic-release": "semantic-release", 91 | "spellcheck": "cspell '**/*'", 92 | "test": "jest", 93 | "test:ci": "jest --ci --collect-coverage", 94 | "test:coverage": "jest --collect-coverage", 95 | "test:watch": "jest --watch --verbose false", 96 | "types": "tsc --noEmit" 97 | }, 98 | "version": "2.10.142" 99 | } 100 | -------------------------------------------------------------------------------- /rfcs/RFC_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # [name] 2 | 3 | ## Motivation 4 | 5 | Give a short description of the motivation behind this change to give some 6 | context without diving too deeply into details: 7 | 8 | - Why does it need to be done? 9 | - What use cases does it support? 10 | - What is the expected outcome? 11 | 12 | ## Guide-level explanation 13 | 14 | Explain the proposal as if it was already in the codebase and one were 15 | explaining it to a consumer. That generally means: 16 | 17 | - Description of an exposed interface. 18 | - Explaining the feature largely in terms of examples. 19 | - Explaining how consumers should think about the feature, and how it should 20 | impact the way they use the application. It should explain the impact as 21 | concretely as possible. 22 | - Provide sample error messages, deprecation warnings, or migration guidance. 23 | - Describe impact on all customers in great detail; Operators, Riders and 24 | Developers in particular. 25 | 26 | ## Reference-level explanation 27 | 28 | This is the technical portion of the RFC. Explain the design in sufficient 29 | detail such that: 30 | 31 | - Its interaction with other features is clear. 32 | - It is clear how the feature would be implemented. 33 | - It is clear what new technologies and techniques will be used to implement the 34 | feature. 35 | - Corner cases are dissected by example. 36 | 37 | The section should return to the examples given in the previous section, and 38 | explain more fully how the detailed proposal makes those examples work. 39 | 40 | ## Drawbacks 41 | 42 | Why should it not be done? 43 | 44 | ## Rationale and alternatives 45 | 46 | - Why is this design the best in the space of possible designs? 47 | - What other designs have been considered and what is the rationale for not 48 | choosing them? 49 | - What is the impact of not doing this? 50 | 51 | ## Prior art 52 | 53 | Discuss prior art, both the good and the bad, in relation to this proposal. A 54 | few examples of what this can include are: 55 | 56 | - Does this feature exist in similar applications, APIs? 57 | - If done by some other community, what were their experiences with it and how 58 | may we learn from them? 59 | - Are there any published papers or great posts that discuss this? 60 | 61 | This section is intended to encourage you as an author to think about the 62 | lessons from other languages and provide readers of your RFC with a fuller 63 | picture. If there is no prior art, that is fine - your ideas are interesting 64 | whether they are brand new or if they are an adaptation from other languages. 65 | 66 | ## Unresolved questions 67 | 68 | - What parts of the design do you expect to resolve through the RFC process 69 | before this gets merged? 70 | - What parts of the design do you expect to resolve through the implementation 71 | of this feature before stabilization? 72 | - What related issues do you consider out of scope for this RFC that could be 73 | addressed in the future independently of the solution that comes out of this 74 | RFC? 75 | 76 | ## Future possibilities 77 | 78 | Think about what the natural extension and evolution of your proposal would be 79 | and how it would affect the application and project as a whole in a holistic 80 | way. Try to use this section as a tool to more fully consider all possible 81 | interactions with the project in your proposal. Also consider how all of it fits 82 | into the road map for the project and of the relevant sub-team. 83 | 84 | This is also a good place to "dump ideas" if they are out of scope for the RFC 85 | you are writing but otherwise related. 86 | 87 | If you have tried and cannot think of any future possibilities, you may simply 88 | state that you cannot think of anything. 89 | 90 | Note that having something written down in the future-possibilities section is 91 | not a reason to accept the current or a future RFC; such notes should be in the 92 | section on motivation or rationale in this or subsequent RFCs. The section 93 | merely provides additional information. 94 | -------------------------------------------------------------------------------- /src/common/listBranchProtectionRules.spec.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from '@actions/github'; 2 | import { StatusCodes } from 'http-status-codes'; 3 | import * as nock from 'nock'; 4 | 5 | import { listBranchProtectionRules } from './listBranchProtectionRules'; 6 | 7 | /** 8 | * Test interfaces and types 9 | */ 10 | interface BranchProtectionRuleNode { 11 | node: { 12 | pattern: string; 13 | requiresStrictStatusChecks: boolean; 14 | }; 15 | } 16 | 17 | interface GraphQLResponse { 18 | repository: { 19 | branchProtectionRules: { 20 | edges: BranchProtectionRuleNode[]; 21 | pageInfo: { 22 | endCursor: string; 23 | hasNextPage: boolean; 24 | }; 25 | }; 26 | }; 27 | } 28 | 29 | /** 30 | * Test utilities 31 | */ 32 | const octokit = getOctokit('SECRET_GITHUB_TOKEN'); 33 | const repositoryName = 'test-repository'; 34 | const repositoryOwner = 'test-owner'; 35 | 36 | const makeBranchProtectionRuleNode = ( 37 | pattern: string = 'master', 38 | requiresStrictStatusChecks: boolean = true, 39 | ): BranchProtectionRuleNode => ({ 40 | node: { 41 | pattern, 42 | requiresStrictStatusChecks, 43 | }, 44 | }); 45 | 46 | const makeGraphQLResponse = ( 47 | branchProtectionRuleNodes: BranchProtectionRuleNode[] = [], 48 | endCursor: string = '', 49 | hasNextPage: boolean = false, 50 | ): GraphQLResponse => ({ 51 | repository: { 52 | branchProtectionRules: { 53 | edges: branchProtectionRuleNodes, 54 | pageInfo: { 55 | endCursor, 56 | hasNextPage, 57 | }, 58 | }, 59 | }, 60 | }); 61 | 62 | /** 63 | * Tests 64 | */ 65 | describe('listBranchProtectionRules', (): void => { 66 | it('returns an empty array when the repository has no branch protection rules', async (): Promise => { 67 | expect.assertions(1); 68 | 69 | nock('https://api.github.com').post('/graphql').reply(StatusCodes.OK, { 70 | data: makeGraphQLResponse(), 71 | }); 72 | 73 | const result = await listBranchProtectionRules( 74 | octokit, 75 | repositoryOwner, 76 | repositoryName, 77 | ); 78 | 79 | expect(result).toStrictEqual([]); 80 | }); 81 | 82 | it('returns an array of branch protection rules when a repository has branch protection rules configured', async (): Promise => { 83 | expect.assertions(1); 84 | 85 | nock('https://api.github.com') 86 | .post('/graphql') 87 | .reply(StatusCodes.OK, { 88 | data: makeGraphQLResponse([ 89 | makeBranchProtectionRuleNode('dev', true), 90 | makeBranchProtectionRuleNode('master', false), 91 | ]), 92 | }); 93 | 94 | const result = await listBranchProtectionRules( 95 | octokit, 96 | repositoryOwner, 97 | repositoryName, 98 | ); 99 | 100 | expect(result).toStrictEqual([ 101 | { 102 | pattern: 'dev', 103 | requiresStrictStatusChecks: true, 104 | }, 105 | { 106 | pattern: 'master', 107 | requiresStrictStatusChecks: false, 108 | }, 109 | ]); 110 | }); 111 | 112 | it('returns all results when distributed across multiple pages', async (): Promise => { 113 | expect.assertions(1); 114 | 115 | nock('https://api.github.com') 116 | .post('/graphql') 117 | .reply(StatusCodes.OK, { 118 | data: makeGraphQLResponse( 119 | [ 120 | makeBranchProtectionRuleNode('dev', true), 121 | makeBranchProtectionRuleNode('master', false), 122 | ], 123 | 'next-page-cursor', 124 | true, 125 | ), 126 | }) 127 | .post('/graphql') 128 | .reply(StatusCodes.OK, { 129 | data: makeGraphQLResponse( 130 | [makeBranchProtectionRuleNode('test', true)], 131 | '', 132 | false, 133 | ), 134 | }); 135 | 136 | const result = await listBranchProtectionRules( 137 | octokit, 138 | repositoryOwner, 139 | repositoryName, 140 | ); 141 | 142 | expect(result).toStrictEqual([ 143 | { 144 | pattern: 'dev', 145 | requiresStrictStatusChecks: true, 146 | }, 147 | { 148 | pattern: 'master', 149 | requiresStrictStatusChecks: false, 150 | }, 151 | { 152 | pattern: 'test', 153 | requiresStrictStatusChecks: true, 154 | }, 155 | ]); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /src/common/getPullRequestInformation.ts: -------------------------------------------------------------------------------- 1 | import { getOctokit } from '@actions/github'; 2 | import type { GraphQlQueryResponseData } from '@octokit/graphql'; 3 | 4 | import { 5 | FindPullRequestInfoByNumberResponse, 6 | PullRequestInformation, 7 | } from '../types'; 8 | 9 | const MERGEABLE_STATUS_UNKNOWN_ERROR = 'Mergeable state is not known yet.'; 10 | 11 | const pullRequestFields = (githubPreviewApiEnabled: boolean): string => { 12 | const fields = [ 13 | `author { 14 | login 15 | }`, 16 | `commits(last: 1) { 17 | edges { 18 | node { 19 | commit { 20 | author { 21 | name 22 | } 23 | messageHeadline 24 | message 25 | } 26 | } 27 | } 28 | }`, 29 | 'id', 30 | 'mergeable', 31 | 'merged', 32 | ...(githubPreviewApiEnabled ? ['mergeStateStatus'] : []), 33 | 'number', 34 | `reviews(last: 1, states: APPROVED) { 35 | edges { 36 | node { 37 | state 38 | } 39 | } 40 | }`, 41 | 'state', 42 | 'title', 43 | ]; 44 | 45 | return `{ 46 | ${fields.join('\n')} 47 | }`; 48 | }; 49 | 50 | const findPullRequestInfoByNumberQuery = ( 51 | githubPreviewApiEnabled: boolean, 52 | ): string => ` 53 | query FindPullRequestInfoByNumber( 54 | $repositoryOwner: String!, 55 | $repositoryName: String!, 56 | $pullRequestNumber: Int! 57 | ) { 58 | repository(owner: $repositoryOwner, name: $repositoryName) { 59 | pullRequest(number: $pullRequestNumber) ${pullRequestFields( 60 | githubPreviewApiEnabled, 61 | )} 62 | } 63 | } 64 | `; 65 | 66 | const getPullRequestInformationByPullRequestNumber = async ( 67 | octokit: ReturnType, 68 | query: { 69 | pullRequestNumber: number; 70 | repositoryName: string; 71 | repositoryOwner: string; 72 | }, 73 | options: { 74 | githubPreviewApiEnabled: boolean; 75 | }, 76 | ): Promise => { 77 | const response = await octokit.graphql( 78 | findPullRequestInfoByNumberQuery(options.githubPreviewApiEnabled), 79 | { 80 | ...query, 81 | ...(options.githubPreviewApiEnabled 82 | ? { 83 | mediaType: { 84 | previews: ['merge-info'], 85 | }, 86 | } 87 | : {}), 88 | }, 89 | ); 90 | 91 | if (response === null || response.repository.pullRequest === null) { 92 | return undefined; 93 | } 94 | 95 | const { 96 | repository: { 97 | pullRequest: { 98 | author: { login: authorLogin }, 99 | id: pullRequestId, 100 | commits: { 101 | edges: [ 102 | { 103 | node: { 104 | commit: { 105 | message: commitMessage, 106 | messageHeadline: commitMessageHeadline, 107 | }, 108 | }, 109 | }, 110 | ], 111 | }, 112 | number: pullRequestNumber, 113 | reviews: { edges: reviewEdges }, 114 | mergeStateStatus, 115 | mergeable: mergeableState, 116 | merged, 117 | state: pullRequestState, 118 | title: pullRequestTitle, 119 | }, 120 | }, 121 | } = response as FindPullRequestInfoByNumberResponse; 122 | 123 | return { 124 | authorLogin, 125 | commitMessage, 126 | commitMessageHeadline, 127 | ...(mergeStateStatus !== undefined ? { mergeStateStatus } : {}), 128 | mergeableState, 129 | merged, 130 | pullRequestId, 131 | pullRequestNumber, 132 | pullRequestState, 133 | pullRequestTitle, 134 | repositoryName: query.repositoryName, 135 | repositoryOwner: query.repositoryOwner, 136 | reviewEdges, 137 | }; 138 | }; 139 | 140 | export const getMergeablePullRequestInformationByPullRequestNumber = async ( 141 | octokit: ReturnType, 142 | query: { 143 | pullRequestNumber: number; 144 | repositoryName: string; 145 | repositoryOwner: string; 146 | }, 147 | options: { 148 | githubPreviewApiEnabled: boolean; 149 | }, 150 | ): Promise => { 151 | const pullRequestInformation = 152 | await getPullRequestInformationByPullRequestNumber(octokit, query, options); 153 | 154 | if (pullRequestInformation === undefined) { 155 | return undefined; 156 | } 157 | 158 | if (pullRequestInformation.mergeableState === 'UNKNOWN') { 159 | throw new Error(MERGEABLE_STATUS_UNKNOWN_ERROR); 160 | } 161 | 162 | return pullRequestInformation; 163 | }; 164 | -------------------------------------------------------------------------------- /.github/workflows/continuous-integration.yaml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | name: Build 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v6 15 | with: 16 | persist-credentials: false 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v6.1.0 19 | with: 20 | cache: 'npm' 21 | node-version: 20 22 | registry-url: 'https://npm.pkg.github.com' 23 | - name: Install dependencies 24 | run: npm ci --ignore-scripts --no-audit --no-progress --prefer-offline 25 | - name: Build 26 | run: | 27 | # Workaround https://github.com/zeit/ncc/issues/457. 28 | mv tsconfig.production.json tsconfig.json 29 | npm run build 30 | timeout-minutes: 5 31 | format: 32 | name: Format 33 | runs-on: ubuntu-latest 34 | steps: 35 | - name: Checkout 36 | uses: actions/checkout@v6 37 | with: 38 | persist-credentials: false 39 | - name: Setup Node.js 40 | uses: actions/setup-node@v6.1.0 41 | with: 42 | cache: 'npm' 43 | node-version: 20 44 | registry-url: 'https://npm.pkg.github.com' 45 | - name: Install dependencies 46 | run: npm ci --ignore-scripts --no-audit --no-progress --prefer-offline 47 | - name: Format 48 | run: npm run format 49 | timeout-minutes: 5 50 | lint: 51 | name: Lint 52 | runs-on: ubuntu-latest 53 | steps: 54 | - name: Checkout 55 | uses: actions/checkout@v6 56 | with: 57 | persist-credentials: false 58 | - name: Setup Node.js 59 | uses: actions/setup-node@v6.1.0 60 | with: 61 | cache: 'npm' 62 | node-version: 20 63 | registry-url: 'https://npm.pkg.github.com' 64 | - name: Install dependencies 65 | run: npm ci --ignore-scripts --no-audit --no-progress --prefer-offline 66 | - name: Lint 67 | run: npm run lint 68 | timeout-minutes: 5 69 | spellcheck: 70 | name: Spellcheck 71 | runs-on: ubuntu-latest 72 | steps: 73 | - name: Checkout 74 | uses: actions/checkout@v6 75 | with: 76 | persist-credentials: false 77 | - name: Setup Node.js 78 | uses: actions/setup-node@v6.1.0 79 | with: 80 | cache: 'npm' 81 | node-version: 20 82 | registry-url: 'https://npm.pkg.github.com' 83 | - name: Install dependencies 84 | run: npm ci --ignore-scripts --no-audit --no-progress --prefer-offline 85 | - name: Spellcheck 86 | run: npm run spellcheck 87 | timeout-minutes: 5 88 | test: 89 | name: Test 90 | runs-on: ubuntu-latest 91 | steps: 92 | - name: Checkout 93 | uses: actions/checkout@v6 94 | with: 95 | persist-credentials: false 96 | - name: Setup Node.js 97 | uses: actions/setup-node@v6.1.0 98 | with: 99 | cache: 'npm' 100 | node-version: 20 101 | - name: Install latest npm 102 | run: npm install --global npm@latest 103 | - name: Install dependencies 104 | run: npm ci --ignore-scripts --no-audit --no-progress --prefer-offline 105 | - name: Test 106 | run: npm run test:ci 107 | - name: Upload coverage to Coveralls 108 | uses: coverallsapp/github-action@master 109 | with: 110 | github-token: ${{ secrets.GITHUB_TOKEN }} 111 | timeout-minutes: 5 112 | types: 113 | name: Types 114 | runs-on: ubuntu-latest 115 | steps: 116 | - name: Checkout 117 | uses: actions/checkout@v6 118 | with: 119 | persist-credentials: false 120 | - name: Setup Node.js 121 | uses: actions/setup-node@v6.1.0 122 | with: 123 | cache: 'npm' 124 | node-version: 20 125 | registry-url: 'https://npm.pkg.github.com' 126 | - name: Install dependencies 127 | run: npm ci --ignore-scripts --no-audit --no-progress --prefer-offline 128 | - name: Types 129 | run: npm run types 130 | timeout-minutes: 5 131 | merge-me: 132 | needs: 133 | - build 134 | - format 135 | - lint 136 | - spellcheck 137 | - test 138 | - types 139 | name: Merge me! 140 | runs-on: ubuntu-latest 141 | steps: 142 | - name: Merge me! 143 | uses: ridedott/merge-me-action@master 144 | with: 145 | GITHUB_TOKEN: ${{ secrets.DOTTBOTT_TOKEN }} 146 | timeout-minutes: 5 147 | -------------------------------------------------------------------------------- /src/eventHandlers/continuousIntegrationEnd/index.ts: -------------------------------------------------------------------------------- 1 | import { getInput } from '@actions/core'; 2 | import { context, getOctokit } from '@actions/github'; 3 | import { isMatch } from 'micromatch'; 4 | 5 | import { computeRequiresStrictStatusChecksForRefs as computeRequiresStrictStatusChecksForReferences } from '../../common/computeRequiresStrictStatusChecksForRefs'; 6 | import { 7 | delay, 8 | EXPONENTIAL_BACKOFF, 9 | MINIMUM_WAIT_TIME, 10 | } from '../../common/delay'; 11 | import { getMergeablePullRequestInformationByPullRequestNumber } from '../../common/getPullRequestInformation'; 12 | import { listBranchProtectionRules } from '../../common/listBranchProtectionRules'; 13 | import { tryMerge } from '../../common/merge'; 14 | import { PullRequest, PullRequestInformation } from '../../types'; 15 | import { logDebug, logInfo, logWarning } from '../../utilities/log'; 16 | 17 | const getMergeablePullRequestInformationWithRetry = async ( 18 | octokit: ReturnType, 19 | query: { 20 | pullRequestNumber: number; 21 | repositoryName: string; 22 | repositoryOwner: string; 23 | }, 24 | retries: { 25 | count?: number; 26 | maximum: number; 27 | }, 28 | ): Promise => { 29 | const githubPreviewApiEnabled = 30 | getInput('ENABLE_GITHUB_API_PREVIEW') === 'true'; 31 | 32 | const retryCount = retries.count ?? 1; 33 | 34 | const nextRetryIn = retryCount ** EXPONENTIAL_BACKOFF * MINIMUM_WAIT_TIME; 35 | 36 | try { 37 | return await getMergeablePullRequestInformationByPullRequestNumber( 38 | octokit, 39 | query, 40 | { githubPreviewApiEnabled }, 41 | ); 42 | } catch (error: unknown) { 43 | logDebug( 44 | `Failed to get pull request #${query.pullRequestNumber.toString()} information: ${ 45 | (error as Error).message 46 | }.`, 47 | ); 48 | 49 | if (retryCount < retries.maximum) { 50 | logDebug( 51 | `Retrying get pull request #${query.pullRequestNumber.toString()} information in ${nextRetryIn.toString()}...`, 52 | ); 53 | 54 | await delay(nextRetryIn); 55 | 56 | return await getMergeablePullRequestInformationWithRetry(octokit, query, { 57 | ...retries, 58 | count: retryCount + 1, 59 | }); 60 | } 61 | 62 | logDebug( 63 | `Failed to get pull request #${query.pullRequestNumber.toString()} information after ${retryCount.toString()} attempts. Retries exhausted.`, 64 | ); 65 | 66 | return Promise.reject(error); 67 | } 68 | }; 69 | 70 | export const continuousIntegrationEndHandle = async ( 71 | octokit: ReturnType, 72 | gitHubLogin: string, 73 | maximumRetries: number, 74 | ): Promise => { 75 | const pullRequests = ( 76 | context.eventName === 'workflow_run' 77 | ? context.payload.workflow_run 78 | : context.payload.check_suite 79 | ).pull_requests as PullRequest[]; 80 | 81 | const branchProtectionRules = await listBranchProtectionRules( 82 | octokit, 83 | context.repo.owner, 84 | context.repo.repo, 85 | ); 86 | 87 | const requiresStrictStatusChecksArray = 88 | computeRequiresStrictStatusChecksForReferences( 89 | branchProtectionRules, 90 | pullRequests.map(({ base }: PullRequest): string => base.ref), 91 | ); 92 | 93 | const pullRequestsInformation = await Promise.all( 94 | pullRequests.map( 95 | async ( 96 | pullRequest: PullRequest, 97 | ): Promise => 98 | getMergeablePullRequestInformationWithRetry( 99 | octokit, 100 | { 101 | pullRequestNumber: pullRequest.number, 102 | repositoryName: context.repo.repo, 103 | repositoryOwner: context.repo.owner, 104 | }, 105 | { maximum: maximumRetries }, 106 | ).catch((): undefined => undefined), 107 | ), 108 | ); 109 | 110 | const mergePromises: Array> = []; 111 | 112 | for (const [ 113 | index, 114 | pullRequestInformation, 115 | ] of pullRequestsInformation.entries()) { 116 | if (pullRequestInformation === undefined) { 117 | logWarning('Unable to fetch pull request information.'); 118 | } else if (isMatch(pullRequestInformation.authorLogin, gitHubLogin)) { 119 | logInfo( 120 | `Found pull request information: ${JSON.stringify( 121 | pullRequestInformation, 122 | )}.`, 123 | ); 124 | 125 | // eslint-disable-next-line functional/immutable-data 126 | mergePromises.push( 127 | tryMerge( 128 | octokit, 129 | { 130 | maximumRetries, 131 | requiresStrictStatusChecks: requiresStrictStatusChecksArray[index], 132 | }, 133 | pullRequestInformation, 134 | ), 135 | ); 136 | } else { 137 | logInfo( 138 | `Pull request #${pullRequestInformation.pullRequestNumber.toString()} created by ${ 139 | pullRequestInformation.authorLogin 140 | }, not ${gitHubLogin}, skipping.`, 141 | ); 142 | } 143 | } 144 | 145 | await Promise.all(mergePromises); 146 | }; 147 | -------------------------------------------------------------------------------- /src/common/getPullRequestInformation.spec.ts: -------------------------------------------------------------------------------- 1 | /* cspell:ignore reqheaders */ 2 | 3 | import { getOctokit } from '@actions/github'; 4 | import { StatusCodes } from 'http-status-codes'; 5 | import * as nock from 'nock'; 6 | 7 | import { 8 | MergeableState, 9 | MergeStateStatus, 10 | PullRequestState, 11 | ReviewEdges, 12 | } from '../types'; 13 | import { getMergeablePullRequestInformationByPullRequestNumber } from './getPullRequestInformation'; 14 | 15 | /** 16 | * Test utilities 17 | */ 18 | const octokit = getOctokit('SECRET_GITHUB_TOKEN'); 19 | const repositoryName = 'test-repository'; 20 | const repositoryOwner = 'test-owner'; 21 | const pullRequestNumber = 1; 22 | 23 | const pullRequestFields = (githubPreviewApiEnabled: boolean): string => { 24 | const fields = [ 25 | `author { 26 | login 27 | }`, 28 | `commits(last: 1) { 29 | edges { 30 | node { 31 | commit { 32 | author { 33 | name 34 | } 35 | messageHeadline 36 | message 37 | } 38 | } 39 | } 40 | }`, 41 | 'id', 42 | 'mergeable', 43 | 'merged', 44 | ...(githubPreviewApiEnabled ? ['mergeStateStatus'] : []), 45 | 'number', 46 | `reviews(last: 1, states: APPROVED) { 47 | edges { 48 | node { 49 | state 50 | } 51 | } 52 | }`, 53 | 'state', 54 | 'title', 55 | ]; 56 | 57 | return `{ 58 | ${fields.join('\n')} 59 | }`; 60 | }; 61 | 62 | const findPullRequestInfoByNumberQuery = ( 63 | githubPreviewApiEnabled: boolean, 64 | ): string => ` 65 | query FindPullRequestInfoByNumber( 66 | $repositoryOwner: String!, 67 | $repositoryName: String!, 68 | $pullRequestNumber: Int! 69 | ) { 70 | repository(owner: $repositoryOwner, name: $repositoryName) { 71 | pullRequest(number: $pullRequestNumber) ${pullRequestFields( 72 | githubPreviewApiEnabled, 73 | )} 74 | } 75 | } 76 | `; 77 | 78 | interface GraphQLResponse { 79 | repository: { 80 | pullRequest: { 81 | author: { 82 | login: string; 83 | }; 84 | commits: { 85 | edges: Array<{ 86 | node: { 87 | commit: { 88 | author: { 89 | name: string; 90 | }; 91 | message: string; 92 | messageHeadline: string; 93 | }; 94 | }; 95 | }>; 96 | }; 97 | id: string; 98 | mergeStateStatus?: MergeStateStatus; 99 | mergeable: MergeableState; 100 | merged: boolean; 101 | number: number; 102 | reviews: { 103 | edges: ReviewEdges[]; 104 | }; 105 | state: PullRequestState; 106 | title: string; 107 | }; 108 | }; 109 | } 110 | 111 | const makeGraphQLResponse = ( 112 | includeMergeStateStatus: boolean, 113 | ): GraphQLResponse => ({ 114 | repository: { 115 | pullRequest: { 116 | author: { 117 | login: 'test-author', 118 | }, 119 | commits: { 120 | edges: [ 121 | { 122 | node: { 123 | commit: { 124 | author: { 125 | name: 'Test Author', 126 | }, 127 | message: 'test message', 128 | messageHeadline: 'test message headline', 129 | }, 130 | }, 131 | }, 132 | ], 133 | }, 134 | id: '123', 135 | ...(includeMergeStateStatus ? { mergeStateStatus: 'CLEAN' } : {}), 136 | mergeable: 'MERGEABLE', 137 | merged: false, 138 | number: pullRequestNumber, 139 | reviews: { 140 | edges: [], 141 | }, 142 | state: 'OPEN', 143 | title: 'test', 144 | }, 145 | }, 146 | }); 147 | 148 | /** 149 | * Tests 150 | */ 151 | describe('getPullRequestInformation', (): void => { 152 | it.each<[string, boolean]>([ 153 | ['without mergeStateStatus field', false], 154 | ['with mergeStateStatus field', true], 155 | ])( 156 | 'returns pull request information %s', 157 | async (_: string, githubPreviewApiEnabled: boolean): Promise => { 158 | expect.assertions(1); 159 | 160 | nock('https://api.github.com', { 161 | reqheaders: { 162 | // eslint-disable-next-line jest/no-conditional-in-test 163 | accept: githubPreviewApiEnabled 164 | ? 'application/vnd.github.merge-info-preview+json' 165 | : 'application/vnd.github.v3+json', 166 | }, 167 | }) 168 | .post('/graphql', { 169 | query: findPullRequestInfoByNumberQuery(githubPreviewApiEnabled), 170 | variables: { 171 | pullRequestNumber, 172 | repositoryName, 173 | repositoryOwner, 174 | }, 175 | }) 176 | .reply(StatusCodes.OK, { 177 | data: makeGraphQLResponse(githubPreviewApiEnabled), 178 | }); 179 | 180 | const result = 181 | await getMergeablePullRequestInformationByPullRequestNumber( 182 | octokit, 183 | { 184 | pullRequestNumber, 185 | repositoryName, 186 | repositoryOwner, 187 | }, 188 | { githubPreviewApiEnabled }, 189 | ); 190 | 191 | expect(result).toMatchSnapshot(); 192 | }, 193 | ); 194 | }); 195 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to {package_name} 2 | 3 | We'd love for you to contribute to our source code and to make our project even 4 | better than it is today! Here are the guidelines we'd like you to follow: 5 | 6 | - [Question or Problem?](#question) 7 | - [Issues and Bugs](#issue) 8 | - [Feature Requests](#feature) 9 | - [Submission Guidelines](#submit) 10 | - [Coding Rules](#rules) 11 | - [Commit Message Guidelines](#commit) 12 | 13 | ## Got a Question or Problem? 14 | 15 | If you have questions about how to use this project, please open an issue on 16 | GitHub. 17 | 18 | ## Found an Issue? 19 | 20 | If you find a bug in the source code or a mistake in the documentation, you can 21 | help us by submitting an issue to respective GitHub repository. Even better you 22 | can submit a Pull Request with a fix. 23 | 24 | ## Want a Feature? 25 | 26 | You can request a new feature by submitting an issue to our GitHub repository. 27 | If you would like to implement a new feature then consider what kind of change 28 | it is: 29 | 30 | - **Major Changes** that you wish to contribute to the project should be 31 | discussed first with (at least some of) core team members, in order to prevent 32 | duplication of work, and help you to craft the change so that it is 33 | successfully accepted into the project. 34 | - **Small Changes** can be crafted and submitted to the GitHub repository as a 35 | Pull Request. 36 | 37 | ## Submission Guidelines 38 | 39 | ### Submitting an Issue 40 | 41 | Before you submit your issue search the archive, maybe your question was already 42 | answered. 43 | 44 | If your issue appears to be a bug, and hasn't been reported, open a new issue. 45 | Help us to maximize the effort we can spend fixing issues and adding new 46 | features, by not reporting duplicate issues. 47 | 48 | ### Submitting a Pull Request 49 | 50 | Before you submit your merge request consider the following guidelines: 51 | 52 | - Search [GitHub repository](https://github.com/ridedott/merge-me-action/issues) 53 | for an open or closed Pull Request that relates to your submission. You don't 54 | want to duplicate effort. 55 | - Make your changes in a new branch: 56 | 57 | ```shell 58 | git checkout -b my-branch master 59 | ``` 60 | 61 | - Follow our [Coding Rules](#rules). 62 | - Add an entry in a [decision log](./decisions/README.md) for major changes. 63 | - Commit your changes using a descriptive commit message that follows our 64 | [commit message conventions](#commit). 65 | - Push your branch to GitHub: 66 | 67 | ```shell 68 | git push origin my-fix-branch 69 | ``` 70 | 71 | In GitHub, send a Pull Request to a `master` branch. If we suggest changes, 72 | then: 73 | 74 | - Make the required updates. 75 | - Re-run the test suite to ensure tests are still passing. 76 | - Commit your changes to your branch (e.g. `my-branch`). 77 | - Push the changes to GitHub repository (this will update your Pull Request). 78 | 79 | If the PR gets too outdated we may ask you to merge and push to update the PR: 80 | 81 | ```shell 82 | git fetch upstream 83 | git merge upstream/master 84 | git push origin my-fix-branch 85 | ``` 86 | 87 | That's it! Thank you for your contribution! 88 | 89 | ## Coding Rules 90 | 91 | To ensure consistency throughout the source code, keep these rules in mind as 92 | you are working: 93 | 94 | - This repository contains `.editorconfig` file, which configures IDE code 95 | formatting. **Do not override these settings.** 96 | 97 | ## Git Commit Guidelines 98 | 99 | We have very precise rules over how our git commit messages can be formatted. 100 | This leads to **more readable messages** that are easy to follow when looking 101 | through the **project history**. 102 | 103 | The commit message formatting can be added using a typical git workflow or 104 | through the use of a CLI wizard 105 | ([Commitizen](https://github.com/commitizen/cz-cli)). To use the wizard, run 106 | `npm run cz` in your terminal after staging your changes in git. 107 | 108 | ### Revert 109 | 110 | If the commit reverts a previous commit, it should begin with `revert:`, 111 | followed by the header of the reverted commit. In the body it should say: 112 | `This reverts commit .`, where the hash is the SHA of the commit being 113 | reverted. 114 | 115 | ### Type 116 | 117 | Must be one of the following: 118 | 119 | - **feat**: A new feature 120 | - **fix**: A bug fix 121 | - **docs**: Documentation only changes 122 | - **style**: Changes that do not affect the meaning of the code (white-space, 123 | formatting, missing semi-colons, etc) 124 | - **refactor**: A code change that neither fixes a bug nor adds a feature 125 | - **perf**: A code change that improves performance 126 | - **test**: Adding missing or correcting existing tests 127 | - **chore**: Changes to the build process or auxiliary tools and libraries such 128 | as documentation generation 129 | 130 | ### Subject 131 | 132 | The subject contains succinct description of the change: 133 | 134 | - use the imperative, present tense: "change" not "changed" nor "changes" 135 | - don't capitalize first letter 136 | - no dot (.) at the end 137 | 138 | ### Body 139 | 140 | Just as in the **subject**, use the imperative, present tense: "change" not 141 | "changed" nor "changes". The body should include the motivation for the change 142 | and contrast this with previous behavior. 143 | 144 | ### Footer 145 | 146 | The footer should contain any information about **Breaking Changes** and is also 147 | the place to reference GitHub issues that this commit closes. 148 | 149 | **Breaking Changes** should start with the word `BREAKING CHANGE:` with a space 150 | or two newlines. The rest of the commit message is then used for this. 151 | -------------------------------------------------------------------------------- /src/utilities/prTitleParsers.spec.ts: -------------------------------------------------------------------------------- 1 | import * as inputParsers from './inputParsers'; 2 | import { checkPullRequestTitleForMergePreset } from './prTitleParsers'; 3 | 4 | const parseInputMergePresetSpy = jest.spyOn( 5 | inputParsers, 6 | 'parseInputMergePreset', 7 | ); 8 | 9 | describe('checkPullRequestTitleForMergePreset', (): void => { 10 | it('returns true if category is undefined', (): void => { 11 | expect.assertions(1); 12 | 13 | parseInputMergePresetSpy.mockReturnValueOnce(undefined); 14 | 15 | expect(checkPullRequestTitleForMergePreset('')).toBe(true); 16 | }); 17 | 18 | describe('given containing major bump', (): void => { 19 | const title = 'bump @types/jest from 26.0.12 to 27.0.13'; 20 | 21 | it.each(Object.values(inputParsers.AllowedMergePresets))( 22 | 'returns false', 23 | (mergeCategory: inputParsers.AllowedMergePresets): void => { 24 | expect.assertions(1); 25 | 26 | parseInputMergePresetSpy.mockReturnValueOnce(mergeCategory); 27 | 28 | expect(checkPullRequestTitleForMergePreset(title)).toBe(false); 29 | }, 30 | ); 31 | }); 32 | 33 | describe('given containing major bump and directory path', (): void => { 34 | const title = 'bump @types/jest from 26.0.12 to 27.0.13 in /directory'; 35 | 36 | it.each(Object.values(inputParsers.AllowedMergePresets))( 37 | 'returns false', 38 | (mergeCategory: inputParsers.AllowedMergePresets): void => { 39 | expect.assertions(1); 40 | 41 | parseInputMergePresetSpy.mockReturnValueOnce(mergeCategory); 42 | 43 | expect(checkPullRequestTitleForMergePreset(title)).toBe(false); 44 | }, 45 | ); 46 | }); 47 | 48 | describe('given title containing minor bump', (): void => { 49 | const title = 'bump @types/jest from 26.0.12 to 26.1.0'; 50 | 51 | it('returns true for DEPENDABOT_MINOR', (): void => { 52 | expect.assertions(1); 53 | 54 | parseInputMergePresetSpy.mockReturnValueOnce( 55 | inputParsers.AllowedMergePresets.DEPENDABOT_MINOR, 56 | ); 57 | 58 | expect(checkPullRequestTitleForMergePreset(title)).toBe(true); 59 | }); 60 | 61 | it('returns false for DEPENDABOT_PATCH', (): void => { 62 | expect.assertions(1); 63 | 64 | parseInputMergePresetSpy.mockReturnValueOnce( 65 | inputParsers.AllowedMergePresets.DEPENDABOT_PATCH, 66 | ); 67 | 68 | expect(checkPullRequestTitleForMergePreset(title)).toBe(false); 69 | }); 70 | }); 71 | 72 | describe('given title containing minor bump and directory path', (): void => { 73 | const title = 'bump @types/jest from 26.0.12 to 26.1.0 in /directory'; 74 | 75 | it('returns true for DEPENDABOT_MINOR', (): void => { 76 | expect.assertions(1); 77 | 78 | parseInputMergePresetSpy.mockReturnValueOnce( 79 | inputParsers.AllowedMergePresets.DEPENDABOT_MINOR, 80 | ); 81 | 82 | expect(checkPullRequestTitleForMergePreset(title)).toBe(true); 83 | }); 84 | 85 | it('returns false for DEPENDABOT_PATCH', (): void => { 86 | expect.assertions(1); 87 | 88 | parseInputMergePresetSpy.mockReturnValueOnce( 89 | inputParsers.AllowedMergePresets.DEPENDABOT_PATCH, 90 | ); 91 | 92 | expect(checkPullRequestTitleForMergePreset(title)).toBe(false); 93 | }); 94 | }); 95 | 96 | describe('given title containing patch bump', (): void => { 97 | const title = 'bump @types/jest from 26.0.12 to 26.0.13'; 98 | 99 | it.each(Object.values(inputParsers.AllowedMergePresets))( 100 | 'returns true', 101 | (mergeCategory: inputParsers.AllowedMergePresets): void => { 102 | expect.assertions(1); 103 | 104 | parseInputMergePresetSpy.mockReturnValueOnce(mergeCategory); 105 | 106 | expect(checkPullRequestTitleForMergePreset(title)).toBe(true); 107 | }, 108 | ); 109 | }); 110 | 111 | describe('given title containing patch bump and directory path', (): void => { 112 | const title = 'bump @types/jest from 26.0.12 to 26.0.13 in /directory'; 113 | 114 | it.each(Object.values(inputParsers.AllowedMergePresets))( 115 | 'returns true', 116 | (mergeCategory: inputParsers.AllowedMergePresets): void => { 117 | expect.assertions(1); 118 | 119 | parseInputMergePresetSpy.mockReturnValueOnce(mergeCategory); 120 | 121 | expect(checkPullRequestTitleForMergePreset(title)).toBe(true); 122 | }, 123 | ); 124 | }); 125 | 126 | describe('given title containing malformed version bump', (): void => { 127 | const title = 'bump @types/jest from car to house'; 128 | 129 | it.each(Object.values(inputParsers.AllowedMergePresets))( 130 | 'returns true', 131 | (mergeCategory: inputParsers.AllowedMergePresets): void => { 132 | expect.assertions(1); 133 | 134 | parseInputMergePresetSpy.mockReturnValueOnce(mergeCategory); 135 | 136 | expect(checkPullRequestTitleForMergePreset(title)).toBe(true); 137 | }, 138 | ); 139 | }); 140 | 141 | describe('given title does not contain a version bump', (): void => { 142 | const title = 'chore: format'; 143 | 144 | it.each(Object.values(inputParsers.AllowedMergePresets))( 145 | 'returns true', 146 | (mergeCategory: inputParsers.AllowedMergePresets): void => { 147 | expect.assertions(1); 148 | 149 | parseInputMergePresetSpy.mockReturnValueOnce(mergeCategory); 150 | 151 | expect(checkPullRequestTitleForMergePreset(title)).toBe(true); 152 | }, 153 | ); 154 | }); 155 | 156 | describe('given title is capitalized', (): void => { 157 | const title = 'Bump @types/jest from 26.0.12 to 27.0.13'; 158 | 159 | it.each(Object.values(inputParsers.AllowedMergePresets))( 160 | 'returns false', 161 | (mergeCategory: inputParsers.AllowedMergePresets): void => { 162 | expect.assertions(1); 163 | 164 | parseInputMergePresetSpy.mockReturnValueOnce(mergeCategory); 165 | 166 | expect(checkPullRequestTitleForMergePreset(title)).toBe(false); 167 | }, 168 | ); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /src/common/merge.ts: -------------------------------------------------------------------------------- 1 | import { getInput } from '@actions/core'; 2 | import { getOctokit } from '@actions/github'; 3 | 4 | import { PullRequestCommitNode, PullRequestInformation } from '../types'; 5 | import { 6 | AllowedMergeMethods, 7 | parseInputMergeMethod, 8 | } from '../utilities/inputParsers'; 9 | import { logDebug, logInfo, logWarning } from '../utilities/log'; 10 | import { checkPullRequestTitleForMergePreset } from '../utilities/prTitleParsers'; 11 | import { delay, EXPONENTIAL_BACKOFF, MINIMUM_WAIT_TIME } from './delay'; 12 | import { getPullRequestCommitsIterator } from './getPullRequestCommits'; 13 | 14 | export interface PullRequestDetails { 15 | commitHeadline: string; 16 | pullRequestId: string; 17 | reviewEdge: { node: { state: string } } | undefined; 18 | } 19 | 20 | const approveAndMergePullRequestMutation = ( 21 | mergeMethod: AllowedMergeMethods, 22 | ): string => ` 23 | mutation ($commitHeadline: String!, $pullRequestId: ID!) { 24 | addPullRequestReview(input: {event: APPROVE, pullRequestId: $pullRequestId}) { 25 | clientMutationId 26 | } 27 | mergePullRequest(input: {commitBody: " ", commitHeadline: $commitHeadline, mergeMethod: ${mergeMethod}, pullRequestId: $pullRequestId}) { 28 | clientMutationId 29 | } 30 | } 31 | `; 32 | 33 | const mergePullRequestMutation = (mergeMethod: AllowedMergeMethods): string => ` 34 | mutation ($commitHeadline: String!, $pullRequestId: ID!) { 35 | mergePullRequest(input: {commitBody: " ", commitHeadline: $commitHeadline, mergeMethod: ${mergeMethod}, pullRequestId: $pullRequestId}) { 36 | clientMutationId 37 | } 38 | } 39 | `; 40 | 41 | const getIsModified = async ( 42 | octokit: ReturnType, 43 | query: { 44 | pullRequestNumber: number; 45 | repositoryName: string; 46 | repositoryOwner: string; 47 | }, 48 | ): Promise => { 49 | const iterator = getPullRequestCommitsIterator(octokit, query); 50 | 51 | const firstResult: IteratorResult = 52 | await iterator.next(); 53 | 54 | if (firstResult.done === true) { 55 | logWarning('Could not find PR commits, aborting.'); 56 | 57 | return true; 58 | } 59 | 60 | for await (const commitNode of iterator) { 61 | const { author, signature } = commitNode.commit; 62 | 63 | if (signature === null || signature.isValid !== true) { 64 | logWarning( 65 | 'Commit signature not present or invalid, regarding PR as modified.', 66 | ); 67 | 68 | return true; 69 | } 70 | 71 | if (author.user.login !== firstResult.value.commit.author.user.login) { 72 | return true; 73 | } 74 | } 75 | 76 | return false; 77 | }; 78 | 79 | /** 80 | * Approves and merges a given Pull Request. 81 | */ 82 | const merge = async ( 83 | octokit: ReturnType, 84 | pullRequestDetails: PullRequestDetails, 85 | ): Promise => { 86 | const mergeMethod = parseInputMergeMethod(); 87 | 88 | const { commitHeadline, pullRequestId, reviewEdge } = pullRequestDetails; 89 | 90 | const mutation = 91 | reviewEdge === undefined 92 | ? approveAndMergePullRequestMutation(mergeMethod) 93 | : mergePullRequestMutation(mergeMethod); 94 | 95 | await octokit.graphql(mutation, { commitHeadline, pullRequestId }); 96 | }; 97 | 98 | const shouldRetry = ( 99 | error: Error, 100 | retryCount: number, 101 | maximumRetries: number, 102 | ): boolean => { 103 | const isRetryableError = error.message.includes('Base branch was modified.'); 104 | 105 | if (isRetryableError && retryCount > maximumRetries) { 106 | logInfo( 107 | `Unable to merge after ${retryCount.toString()} attempts. Retries exhausted.`, 108 | ); 109 | 110 | return false; 111 | } 112 | 113 | return isRetryableError; 114 | }; 115 | 116 | const mergeWithRetry = async ( 117 | octokit: ReturnType, 118 | details: PullRequestDetails & { 119 | maximumRetries: number; 120 | retryCount: number; 121 | }, 122 | ): Promise => { 123 | const { retryCount, maximumRetries } = details; 124 | 125 | try { 126 | await merge(octokit, details); 127 | } catch (error: unknown) { 128 | if (shouldRetry(error as Error, retryCount, maximumRetries)) { 129 | const nextRetryIn = retryCount ** EXPONENTIAL_BACKOFF * MINIMUM_WAIT_TIME; 130 | 131 | logInfo(`Retrying in ${nextRetryIn.toString()}...`); 132 | 133 | await delay(nextRetryIn); 134 | 135 | await mergeWithRetry(octokit, { 136 | ...details, 137 | maximumRetries, 138 | retryCount: retryCount + 1, 139 | }); 140 | 141 | return; 142 | } 143 | 144 | logInfo( 145 | 'An error occurred while merging the Pull Request. This is usually ' + 146 | 'caused by the base branch being out of sync with the target ' + 147 | 'branch. In this case, the base branch must be rebased. Some ' + 148 | 'tools, such as Dependabot, do that automatically.', 149 | ); 150 | logDebug(`Original error: ${(error as Error).toString()}.`); 151 | } 152 | }; 153 | 154 | export const tryMerge = async ( 155 | octokit: ReturnType, 156 | { 157 | maximumRetries, 158 | requiresStrictStatusChecks, 159 | }: { 160 | maximumRetries: number; 161 | requiresStrictStatusChecks: boolean; 162 | }, 163 | { 164 | commitMessageHeadline, 165 | mergeableState, 166 | mergeStateStatus, 167 | merged, 168 | pullRequestId, 169 | pullRequestNumber, 170 | pullRequestState, 171 | pullRequestTitle, 172 | reviewEdges, 173 | repositoryName, 174 | repositoryOwner, 175 | }: PullRequestInformation, 176 | ): Promise => { 177 | const allowedAuthorName = getInput('GITHUB_LOGIN'); 178 | const enabledForManualChanges = 179 | getInput('ENABLED_FOR_MANUAL_CHANGES') === 'true'; 180 | 181 | if (mergeableState !== 'MERGEABLE') { 182 | logInfo(`Pull request is not in a mergeable state: ${mergeableState}.`); 183 | } else if (merged) { 184 | logInfo(`Pull request is already merged.`); 185 | } else if ( 186 | requiresStrictStatusChecks === true && 187 | mergeStateStatus !== undefined && 188 | mergeStateStatus !== 'CLEAN' 189 | ) { 190 | logInfo( 191 | `Pull request cannot be merged cleanly. Current state: ${ 192 | mergeStateStatus as string 193 | }.`, 194 | ); 195 | } else if (pullRequestState !== 'OPEN') { 196 | logInfo(`Pull request is not open: ${pullRequestState}.`); 197 | } else if (checkPullRequestTitleForMergePreset(pullRequestTitle) === false) { 198 | logInfo(`Pull request version bump is not allowed by PRESET.`); 199 | } else if ( 200 | enabledForManualChanges === false && 201 | (await getIsModified(octokit, { 202 | pullRequestNumber, 203 | repositoryName, 204 | repositoryOwner, 205 | })) === true 206 | ) { 207 | logInfo(`Pull request changes were not made by ${allowedAuthorName}.`); 208 | } else { 209 | await mergeWithRetry(octokit, { 210 | commitHeadline: commitMessageHeadline, 211 | maximumRetries, 212 | pullRequestId, 213 | retryCount: 1, 214 | reviewEdge: reviewEdges[0], 215 | }); 216 | } 217 | }; 218 | -------------------------------------------------------------------------------- /src/eventHandlers/pullRequest/index.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @webhook-pragma pull_request 3 | */ 4 | 5 | import * as core from '@actions/core'; 6 | import { context, getOctokit } from '@actions/github'; 7 | import { StatusCodes } from 'http-status-codes'; 8 | import * as nock from 'nock'; 9 | 10 | import { 11 | approveAndMergePullRequestMutation, 12 | useSetTimeoutImmediateInvocation, 13 | } from '../../../test/utilities'; 14 | import { 15 | FindPullRequestCommitsResponse, 16 | FindPullRequestInfoByNumberResponse, 17 | } from '../../types'; 18 | import { AllowedMergeMethods } from '../../utilities/inputParsers'; 19 | import { pullRequestHandle } from '.'; 20 | 21 | /* cspell:disable-next-line */ 22 | const PULL_REQUEST_ID = 'MDExOlB1bGxSZXF1ZXN0MzE3MDI5MjU4'; 23 | const PULL_REQUEST_NUMBER = 1234; 24 | const COMMIT_HEADLINE = 'Update test'; 25 | const COMMIT_MESSAGE = 26 | 'Update test\n\nSigned-off-by:dependabot[bot]'; 27 | const DEPENDABOT_GITHUB_LOGIN = 'dependabot'; 28 | 29 | const octokit = getOctokit('SECRET_GITHUB_TOKEN'); 30 | const infoSpy = jest.spyOn(core, 'info').mockImplementation(); 31 | const warningSpy = jest.spyOn(core, 'warning').mockImplementation(); 32 | const getInputSpy = jest.spyOn(core, 'getInput').mockImplementation(); 33 | 34 | jest.spyOn(core, 'info').mockImplementation(); 35 | 36 | interface Response { 37 | data: FindPullRequestInfoByNumberResponse; 38 | } 39 | 40 | interface CommitsResponse { 41 | data: FindPullRequestCommitsResponse; 42 | } 43 | 44 | const branchProtectionRulesResponse = { 45 | data: { 46 | repository: { 47 | branchProtectionRules: { 48 | edges: [], 49 | pageInfo: { endCursor: '', hasNextPage: false }, 50 | }, 51 | }, 52 | }, 53 | }; 54 | 55 | const validCommitResponse: CommitsResponse = { 56 | data: { 57 | repository: { 58 | pullRequest: { 59 | commits: { 60 | edges: [ 61 | { 62 | node: { 63 | commit: { 64 | author: { 65 | user: { 66 | login: 'dependabot', 67 | }, 68 | }, 69 | signature: { 70 | isValid: true, 71 | }, 72 | }, 73 | }, 74 | }, 75 | ], 76 | pageInfo: { 77 | endCursor: '', 78 | hasNextPage: false, 79 | }, 80 | }, 81 | }, 82 | }, 83 | }, 84 | }; 85 | 86 | beforeEach((): void => { 87 | getInputSpy.mockImplementation((name: string): string => { 88 | if (name === 'GITHUB_LOGIN') { 89 | return DEPENDABOT_GITHUB_LOGIN; 90 | } 91 | 92 | if (name === 'MERGE_METHOD') { 93 | return 'SQUASH'; 94 | } 95 | 96 | if (name === 'PRESET') { 97 | return 'DEPENDABOT_MINOR'; 98 | } 99 | 100 | return ''; 101 | }); 102 | }); 103 | 104 | describe('pull request event handler', (): void => { 105 | it('does nothing if pullRequest is undefined', async (): Promise => { 106 | expect.assertions(0); 107 | 108 | const { pull_request: pullRequest } = context.payload; 109 | // eslint-disable-next-line functional/immutable-data 110 | delete context.payload.pull_request; 111 | 112 | await pullRequestHandle(octokit, DEPENDABOT_GITHUB_LOGIN, 2); 113 | 114 | /* eslint-disable require-atomic-updates */ 115 | /* eslint-disable functional/immutable-data */ 116 | context.payload.pull_request = pullRequest; 117 | /* eslint-enable require-atomic-updates */ 118 | /* eslint-enable functional/immutable-data */ 119 | }); 120 | 121 | it('logs a warning when it cannot find pull request ID by pull request number (null)', async (): Promise => { 122 | expect.assertions(1); 123 | 124 | nock('https://api.github.com') 125 | .post('/graphql') 126 | .reply(StatusCodes.OK, branchProtectionRulesResponse) 127 | .post('/graphql') 128 | .reply(StatusCodes.OK, { data: null }); 129 | 130 | await pullRequestHandle(octokit, DEPENDABOT_GITHUB_LOGIN, 3); 131 | 132 | expect(warningSpy).toHaveBeenCalledWith( 133 | 'Unable to fetch pull request information.', 134 | ); 135 | }); 136 | 137 | it('logs a warning when it cannot find pull request ID by pull request number', async (): Promise => { 138 | expect.assertions(1); 139 | 140 | nock('https://api.github.com') 141 | .post('/graphql') 142 | .reply(StatusCodes.OK, branchProtectionRulesResponse) 143 | .post('/graphql') 144 | .reply(StatusCodes.OK, { data: { repository: { pullRequest: null } } }); 145 | 146 | await pullRequestHandle(octokit, DEPENDABOT_GITHUB_LOGIN, 3); 147 | 148 | expect(warningSpy).toHaveBeenCalledWith( 149 | 'Unable to fetch pull request information.', 150 | ); 151 | }); 152 | 153 | it('does not merge if request not created by the selected GITHUB_LOGIN and logs it', async (): Promise => { 154 | expect.assertions(1); 155 | 156 | const response: Response = { 157 | data: { 158 | repository: { 159 | pullRequest: { 160 | author: { login: 'dependabot' }, 161 | base: { 162 | // eslint-disable-next-line unicorn/prevent-abbreviations 163 | ref: 'master', 164 | }, 165 | commits: { 166 | edges: [ 167 | { 168 | node: { 169 | commit: { 170 | message: COMMIT_MESSAGE, 171 | messageHeadline: COMMIT_HEADLINE, 172 | }, 173 | }, 174 | }, 175 | ], 176 | }, 177 | id: PULL_REQUEST_ID, 178 | mergeable: 'MERGEABLE', 179 | merged: false, 180 | number: PULL_REQUEST_NUMBER, 181 | reviews: { edges: [{ node: { state: 'APPROVED' } }] }, 182 | state: 'CLOSED', 183 | title: 'bump @types/jest from 26.0.12 to 26.1.0', 184 | }, 185 | }, 186 | }, 187 | }; 188 | 189 | nock('https://api.github.com') 190 | .post('/graphql') 191 | .reply(StatusCodes.OK, branchProtectionRulesResponse) 192 | .post('/graphql') 193 | .reply(StatusCodes.OK, response); 194 | 195 | await pullRequestHandle(octokit, 'some-other-login', 3); 196 | 197 | expect(infoSpy).toHaveBeenCalledWith( 198 | 'Pull request #1234 created by dependabot, not some-other-login, skipping.', 199 | ); 200 | }); 201 | 202 | it('retries, approves and merges a pull request', async (): Promise => { 203 | expect.assertions(0); 204 | 205 | const response: Response = { 206 | data: { 207 | repository: { 208 | pullRequest: { 209 | author: { login: 'dependabot' }, 210 | base: { 211 | // eslint-disable-next-line unicorn/prevent-abbreviations 212 | ref: 'master', 213 | }, 214 | commits: { 215 | edges: [ 216 | { 217 | node: { 218 | commit: { 219 | message: COMMIT_MESSAGE, 220 | messageHeadline: COMMIT_HEADLINE, 221 | }, 222 | }, 223 | }, 224 | ], 225 | }, 226 | id: PULL_REQUEST_ID, 227 | mergeable: 'MERGEABLE', 228 | merged: false, 229 | number: PULL_REQUEST_NUMBER, 230 | reviews: { edges: [] }, 231 | state: 'OPEN', 232 | title: 'bump @types/jest from 26.0.12 to 26.1.0', 233 | }, 234 | }, 235 | }, 236 | }; 237 | 238 | nock('https://api.github.com') 239 | .post('/graphql') 240 | .reply(StatusCodes.OK, branchProtectionRulesResponse) 241 | .post('/graphql') 242 | .reply(StatusCodes.OK, response) 243 | .post('/graphql') 244 | .reply(StatusCodes.OK, validCommitResponse) 245 | .post('/graphql') 246 | .times(2) 247 | .reply( 248 | 403, 249 | '##[error]GraphqlError: Base branch was modified. Review and try the merge again.', 250 | ) 251 | .post('/graphql', { 252 | query: approveAndMergePullRequestMutation(AllowedMergeMethods.SQUASH), 253 | variables: { 254 | commitHeadline: COMMIT_HEADLINE, 255 | pullRequestId: PULL_REQUEST_ID, 256 | }, 257 | }) 258 | .reply(StatusCodes.OK); 259 | 260 | useSetTimeoutImmediateInvocation(); 261 | 262 | await pullRequestHandle(octokit, DEPENDABOT_GITHUB_LOGIN, 3); 263 | }); 264 | }); 265 | -------------------------------------------------------------------------------- /test/fixtures/ctx.push.json: -------------------------------------------------------------------------------- 1 | { 2 | "after": "9d00ff713d0987cc1f4d3d5c00e6fa8ff4066e1f", 3 | "base_ref": null, 4 | "before": "bc2b4afb6ee407c1d1cd87b511834e2d02edfe37", 5 | "commits": [ 6 | { 7 | "added": [], 8 | "author": { 9 | "email": "dependabot@gmail.com", 10 | "name": "dependabot", 11 | "username": "dependabot[bot]" 12 | }, 13 | "committer": { 14 | "email": "41898282+github-actions[bot]@users.noreply.github.com", 15 | "name": "github-actions[bot]", 16 | "username": "github-actions[bot]" 17 | }, 18 | "distinct": false, 19 | "id": "d22d3e84651700bce87ca81f445b4d4778490d48", 20 | "message": "Update test", 21 | "modified": ["test"], 22 | "removed": [], 23 | "timestamp": "2019-10-10T12:30:14Z", 24 | "tree_id": "c60c7be69076c352e6b464e8eb17d658bc95e1ec", 25 | "url": "https://github.com/ridedott/test-workflows/commit/d22d3e84651700bce87ca81f445b4d4778490d48" 26 | }, 27 | { 28 | "added": [], 29 | "author": { 30 | "email": "dependabot@gmail.com", 31 | "name": "dependabot", 32 | "username": "dependabot[bot]" 33 | }, 34 | "committer": { 35 | "email": "dependabot@gmail.com", 36 | "name": "dependabot", 37 | "username": "dependabot[bot]" 38 | }, 39 | "distinct": true, 40 | "id": "9d00ff713d0987cc1f4d3d5c00e6fa8ff4066e1f", 41 | "message": "Merge branch 'master' of github.com:ridedott/test-workflows into dependabot[bot]/test-branch", 42 | "modified": ["test"], 43 | "removed": [], 44 | "timestamp": "2019-10-10T15:00:15+02:00", 45 | "tree_id": "80e1b533ef4302db30352b39ea6fcf65965f51b5", 46 | "url": "https://github.com/ridedott/test-workflows/commit/9d00ff713d0987cc1f4d3d5c00e6fa8ff4066e1f" 47 | } 48 | ], 49 | "compare": "https://github.com/ridedott/test-workflows/compare/bc2b4afb6ee4...9d00ff713d09", 50 | "created": false, 51 | "deleted": false, 52 | "forced": false, 53 | "head_commit": { 54 | "added": [], 55 | "author": { 56 | "email": "dependabot@gmail.com", 57 | "name": "dependabot", 58 | "username": "dependabot[bot]" 59 | }, 60 | "committer": { 61 | "email": "dependabot@gmail.com", 62 | "name": "dependabot", 63 | "username": "dependabot[bot]" 64 | }, 65 | "distinct": true, 66 | "id": "9d00ff713d0987cc1f4d3d5c00e6fa8ff4066e1f", 67 | "message": "Merge branch 'master' of github.com:ridedott/test-workflows into dependabot[bot]/test-branch", 68 | "modified": ["test"], 69 | "removed": [], 70 | "timestamp": "2019-10-10T15:00:15+02:00", 71 | "tree_id": "80e1b533ef4302db30352b39ea6fcf65965f51b5", 72 | "url": "https://github.com/ridedott/test-workflows/commit/9d00ff713d0987cc1f4d3d5c00e6fa8ff4066e1f" 73 | }, 74 | "organization": { 75 | "avatar_url": "https://avatars1.githubusercontent.com/u/45282822?v=4", 76 | "description": "", 77 | "events_url": "https://api.github.com/orgs/ridedott/events", 78 | "hooks_url": "https://api.github.com/orgs/ridedott/hooks", 79 | "id": 45282822, 80 | "issues_url": "https://api.github.com/orgs/ridedott/issues", 81 | "login": "ridedott", 82 | "members_url": "https://api.github.com/orgs/ridedott/members{/member}", 83 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ1MjgyODIy", 84 | "public_members_url": "https://api.github.com/orgs/ridedott/public_members{/member}", 85 | "repos_url": "https://api.github.com/orgs/ridedott/repos", 86 | "url": "https://api.github.com/orgs/ridedott" 87 | }, 88 | "pusher": { 89 | "email": "dependabot@gmail.com", 90 | "name": "dependabot[bot]" 91 | }, 92 | "ref": "refs/heads/dependabot[bot]/test-branch", 93 | "repository": { 94 | "archive_url": "https://api.github.com/repos/ridedott/test-workflows/{archive_format}{/ref}", 95 | "archived": false, 96 | "assignees_url": "https://api.github.com/repos/ridedott/test-workflows/assignees{/user}", 97 | "blobs_url": "https://api.github.com/repos/ridedott/test-workflows/git/blobs{/sha}", 98 | "branches_url": "https://api.github.com/repos/ridedott/test-workflows/branches{/branch}", 99 | "clone_url": "https://github.com/ridedott/test-workflows.git", 100 | "collaborators_url": "https://api.github.com/repos/ridedott/test-workflows/collaborators{/collaborator}", 101 | "comments_url": "https://api.github.com/repos/ridedott/test-workflows/comments{/number}", 102 | "commits_url": "https://api.github.com/repos/ridedott/test-workflows/commits{/sha}", 103 | "compare_url": "https://api.github.com/repos/ridedott/test-workflows/compare/{base}...{head}", 104 | "contents_url": "https://api.github.com/repos/ridedott/test-workflows/contents/{+path}", 105 | "contributors_url": "https://api.github.com/repos/ridedott/test-workflows/contributors", 106 | "created_at": 1570025929, 107 | "default_branch": "master", 108 | "deployments_url": "https://api.github.com/repos/ridedott/test-workflows/deployments", 109 | "description": null, 110 | "disabled": false, 111 | "downloads_url": "https://api.github.com/repos/ridedott/test-workflows/downloads", 112 | "events_url": "https://api.github.com/repos/ridedott/test-workflows/events", 113 | "fork": false, 114 | "forks": 0, 115 | "forks_count": 0, 116 | "forks_url": "https://api.github.com/repos/ridedott/test-workflows/forks", 117 | "full_name": "ridedott/test-workflows", 118 | "git_commits_url": "https://api.github.com/repos/ridedott/test-workflows/git/commits{/sha}", 119 | "git_refs_url": "https://api.github.com/repos/ridedott/test-workflows/git/refs{/sha}", 120 | "git_tags_url": "https://api.github.com/repos/ridedott/test-workflows/git/tags{/sha}", 121 | "git_url": "git://github.com/ridedott/test-workflows.git", 122 | "has_downloads": true, 123 | "has_issues": true, 124 | "has_pages": false, 125 | "has_projects": false, 126 | "has_wiki": true, 127 | "homepage": null, 128 | "hooks_url": "https://api.github.com/repos/ridedott/test-workflows/hooks", 129 | "html_url": "https://github.com/ridedott/test-workflows", 130 | "id": 212360168, 131 | "issue_comment_url": "https://api.github.com/repos/ridedott/test-workflows/issues/comments{/number}", 132 | "issue_events_url": "https://api.github.com/repos/ridedott/test-workflows/issues/events{/number}", 133 | "issues_url": "https://api.github.com/repos/ridedott/test-workflows/issues{/number}", 134 | "keys_url": "https://api.github.com/repos/ridedott/test-workflows/keys{/key_id}", 135 | "labels_url": "https://api.github.com/repos/ridedott/test-workflows/labels{/name}", 136 | "language": null, 137 | "languages_url": "https://api.github.com/repos/ridedott/test-workflows/languages", 138 | "license": null, 139 | "master_branch": "master", 140 | "merges_url": "https://api.github.com/repos/ridedott/test-workflows/merges", 141 | "milestones_url": "https://api.github.com/repos/ridedott/test-workflows/milestones{/number}", 142 | "mirror_url": null, 143 | "name": "test-workflows", 144 | "node_id": "MDEwOlJlcG9zaXRvcnkyMTIzNjAxNjg=", 145 | "notifications_url": "https://api.github.com/repos/ridedott/test-workflows/notifications{?since,all,participating}", 146 | "open_issues": 1, 147 | "open_issues_count": 1, 148 | "organization": "ridedott", 149 | "owner": { 150 | "avatar_url": "https://avatars1.githubusercontent.com/u/45282822?v=4", 151 | "email": null, 152 | "events_url": "https://api.github.com/users/ridedott/events{/privacy}", 153 | "followers_url": "https://api.github.com/users/ridedott/followers", 154 | "following_url": "https://api.github.com/users/ridedott/following{/other_user}", 155 | "gists_url": "https://api.github.com/users/ridedott/gists{/gist_id}", 156 | "gravatar_id": "", 157 | "html_url": "https://github.com/ridedott", 158 | "id": 45282822, 159 | "login": "ridedott", 160 | "name": "ridedott", 161 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ1MjgyODIy", 162 | "organizations_url": "https://api.github.com/users/ridedott/orgs", 163 | "received_events_url": "https://api.github.com/users/ridedott/received_events", 164 | "repos_url": "https://api.github.com/users/ridedott/repos", 165 | "site_admin": false, 166 | "starred_url": "https://api.github.com/users/ridedott/starred{/owner}{/repo}", 167 | "subscriptions_url": "https://api.github.com/users/ridedott/subscriptions", 168 | "type": "Organization", 169 | "url": "https://api.github.com/users/ridedott" 170 | }, 171 | "private": true, 172 | "pulls_url": "https://api.github.com/repos/ridedott/test-workflows/pulls{/number}", 173 | "pushed_at": 1570712426, 174 | "releases_url": "https://api.github.com/repos/ridedott/test-workflows/releases{/id}", 175 | "size": 34, 176 | "ssh_url": "git@github.com:ridedott/test-workflows.git", 177 | "stargazers": 0, 178 | "stargazers_count": 0, 179 | "stargazers_url": "https://api.github.com/repos/ridedott/test-workflows/stargazers", 180 | "statuses_url": "https://api.github.com/repos/ridedott/test-workflows/statuses/{sha}", 181 | "subscribers_url": "https://api.github.com/repos/ridedott/test-workflows/subscribers", 182 | "subscription_url": "https://api.github.com/repos/ridedott/test-workflows/subscription", 183 | "svn_url": "https://github.com/ridedott/test-workflows", 184 | "tags_url": "https://api.github.com/repos/ridedott/test-workflows/tags", 185 | "teams_url": "https://api.github.com/repos/ridedott/test-workflows/teams", 186 | "trees_url": "https://api.github.com/repos/ridedott/test-workflows/git/trees{/sha}", 187 | "updated_at": "2019-10-10T12:30:17Z", 188 | "url": "https://github.com/ridedott/test-workflows", 189 | "watchers": 0, 190 | "watchers_count": 0 191 | }, 192 | "sender": { 193 | "avatar_url": "https://avatars1.githubusercontent.com/u/9158996?v=4", 194 | "events_url": "https://api.github.com/users/dependabot[bot]/events{/privacy}", 195 | "followers_url": "https://api.github.com/users/dependabot[bot]/followers", 196 | "following_url": "https://api.github.com/users/dependabot[bot]/following{/other_user}", 197 | "gists_url": "https://api.github.com/users/dependabot[bot]/gists{/gist_id}", 198 | "gravatar_id": "", 199 | "html_url": "https://github.com/dependabot[bot]", 200 | "id": 9158996, 201 | "login": "dependabot[bot]", 202 | "node_id": "MDQ6VXNlcjkxNTg5OTY=", 203 | "organizations_url": "https://api.github.com/users/dependabot[bot]/orgs", 204 | "received_events_url": "https://api.github.com/users/dependabot[bot]/received_events", 205 | "repos_url": "https://api.github.com/users/dependabot[bot]/repos", 206 | "site_admin": false, 207 | "starred_url": "https://api.github.com/users/dependabot[bot]/starred{/owner}{/repo}", 208 | "subscriptions_url": "https://api.github.com/users/dependabot[bot]/subscriptions", 209 | "type": "User", 210 | "url": "https://api.github.com/users/dependabot[bot]" 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /test/fixtures/ctx.check-suite.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "completed", 3 | "check_suite": { 4 | "after": "9d00ff713d0987cc1f4d3d5c00e6fa8ff4066e1f", 5 | "app": { 6 | "created_at": "2018-03-29T13:50:34Z", 7 | "description": "Google Cloud Build lets you create fast, consistent, reliable builds across all languages. Automatically build containers or non-container artifacts on commits to your GitHub repository. \r\n\r\nGet complete control over defining custom workflows for building, testing, and deploying across multiple environments such as VMs, Serverless, Kubernetes, or Firebase.", 8 | "events": [ 9 | "check_run", 10 | "check_suite", 11 | "commit_comment", 12 | "issue_comment", 13 | "label", 14 | "pull_request", 15 | "push" 16 | ], 17 | "external_url": "https://cloud.google.com/cloud-build/", 18 | "html_url": "https://github.com/apps/google-cloud-build", 19 | "id": 10529, 20 | "name": "Google Cloud Build", 21 | "node_id": "MDM6QXBwMTA1Mjk=", 22 | "owner": { 23 | "avatar_url": "https://avatars2.githubusercontent.com/u/38220399?v=4", 24 | "events_url": "https://api.github.com/users/GoogleCloudBuild/events{/privacy}", 25 | "followers_url": "https://api.github.com/users/GoogleCloudBuild/followers", 26 | "following_url": "https://api.github.com/users/GoogleCloudBuild/following{/other_user}", 27 | "gists_url": "https://api.github.com/users/GoogleCloudBuild/gists{/gist_id}", 28 | "gravatar_id": "", 29 | "html_url": "https://github.com/GoogleCloudBuild", 30 | "id": 38220399, 31 | "login": "GoogleCloudBuild", 32 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjM4MjIwMzk5", 33 | "organizations_url": "https://api.github.com/users/GoogleCloudBuild/orgs", 34 | "received_events_url": "https://api.github.com/users/GoogleCloudBuild/received_events", 35 | "repos_url": "https://api.github.com/users/GoogleCloudBuild/repos", 36 | "site_admin": false, 37 | "starred_url": "https://api.github.com/users/GoogleCloudBuild/starred{/owner}{/repo}", 38 | "subscriptions_url": "https://api.github.com/users/GoogleCloudBuild/subscriptions", 39 | "type": "Organization", 40 | "url": "https://api.github.com/users/GoogleCloudBuild" 41 | }, 42 | "permissions": { 43 | "checks": "write", 44 | "contents": "read", 45 | "issues": "read", 46 | "metadata": "read", 47 | "pull_requests": "read", 48 | "statuses": "write" 49 | }, 50 | "slug": "google-cloud-build", 51 | "updated_at": "2019-03-11T19:46:50Z" 52 | }, 53 | "before": "bc2b4afb6ee407c1d1cd87b511834e2d02edfe37", 54 | "check_runs_url": "https://api.github.com/repos/ridedott/test-workflows/check-suites/259620466/check-runs", 55 | "conclusion": "success", 56 | "created_at": "2019-10-10T13:00:27Z", 57 | "head_branch": "dependabot[bot]/test-branch", 58 | "head_commit": { 59 | "author": { 60 | "email": "dependabot@gmail.com", 61 | "name": "dependabot[bot]" 62 | }, 63 | "committer": { 64 | "email": "dependabot@gmail.com", 65 | "name": "dependabot[bot]" 66 | }, 67 | "id": "9d00ff713d0987cc1f4d3d5c00e6fa8ff4066e1f", 68 | "message": "Merge branch 'master' of github.com:ridedott/test-workflows into dependabot[bot]/test-branch", 69 | "timestamp": "2019-10-10T13:00:15Z", 70 | "tree_id": "80e1b533ef4302db30352b39ea6fcf65965f51b5" 71 | }, 72 | "head_sha": "9d00ff713d0987cc1f4d3d5c00e6fa8ff4066e1f", 73 | "id": 259620466, 74 | "latest_check_runs_count": 1, 75 | "node_id": "MDEwOkNoZWNrU3VpdGUyNTk2MjA0NjY=", 76 | "pull_requests": [ 77 | { 78 | "base": { 79 | "ref": "master", 80 | "repo": { 81 | "id": 212360168, 82 | "name": "test-workflows", 83 | "url": "https://api.github.com/repos/ridedott/test-workflows" 84 | }, 85 | "sha": "d22d3e84651700bce87ca81f445b4d4778490d48" 86 | }, 87 | "head": { 88 | "ref": "dependabot[bot]/test-branch", 89 | "repo": { 90 | "id": 212360168, 91 | "name": "test-workflows", 92 | "url": "https://api.github.com/repos/ridedott/test-workflows" 93 | }, 94 | "sha": "9d00ff713d0987cc1f4d3d5c00e6fa8ff4066e1f" 95 | }, 96 | "id": 326723716, 97 | "number": 7, 98 | "url": "https://api.github.com/repos/ridedott/test-workflows/pulls/7" 99 | } 100 | ], 101 | "status": "completed", 102 | "updated_at": "2019-10-10T13:00:47Z", 103 | "url": "https://api.github.com/repos/ridedott/test-workflows/check-suites/259620466" 104 | }, 105 | "organization": { 106 | "avatar_url": "https://avatars1.githubusercontent.com/u/45282822?v=4", 107 | "description": "", 108 | "events_url": "https://api.github.com/orgs/ridedott/events", 109 | "hooks_url": "https://api.github.com/orgs/ridedott/hooks", 110 | "id": 45282822, 111 | "issues_url": "https://api.github.com/orgs/ridedott/issues", 112 | "login": "ridedott", 113 | "members_url": "https://api.github.com/orgs/ridedott/members{/member}", 114 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ1MjgyODIy", 115 | "public_members_url": "https://api.github.com/orgs/ridedott/public_members{/member}", 116 | "repos_url": "https://api.github.com/orgs/ridedott/repos", 117 | "url": "https://api.github.com/orgs/ridedott" 118 | }, 119 | "repository": { 120 | "archive_url": "https://api.github.com/repos/ridedott/test-workflows/{archive_format}{/ref}", 121 | "archived": false, 122 | "assignees_url": "https://api.github.com/repos/ridedott/test-workflows/assignees{/user}", 123 | "blobs_url": "https://api.github.com/repos/ridedott/test-workflows/git/blobs{/sha}", 124 | "branches_url": "https://api.github.com/repos/ridedott/test-workflows/branches{/branch}", 125 | "clone_url": "https://github.com/ridedott/test-workflows.git", 126 | "collaborators_url": "https://api.github.com/repos/ridedott/test-workflows/collaborators{/collaborator}", 127 | "comments_url": "https://api.github.com/repos/ridedott/test-workflows/comments{/number}", 128 | "commits_url": "https://api.github.com/repos/ridedott/test-workflows/commits{/sha}", 129 | "compare_url": "https://api.github.com/repos/ridedott/test-workflows/compare/{base}...{head}", 130 | "contents_url": "https://api.github.com/repos/ridedott/test-workflows/contents/{+path}", 131 | "contributors_url": "https://api.github.com/repos/ridedott/test-workflows/contributors", 132 | "created_at": "2019-10-02T14:18:49Z", 133 | "default_branch": "master", 134 | "deployments_url": "https://api.github.com/repos/ridedott/test-workflows/deployments", 135 | "description": null, 136 | "disabled": false, 137 | "downloads_url": "https://api.github.com/repos/ridedott/test-workflows/downloads", 138 | "events_url": "https://api.github.com/repos/ridedott/test-workflows/events", 139 | "fork": false, 140 | "forks": 0, 141 | "forks_count": 0, 142 | "forks_url": "https://api.github.com/repos/ridedott/test-workflows/forks", 143 | "full_name": "ridedott/test-workflows", 144 | "git_commits_url": "https://api.github.com/repos/ridedott/test-workflows/git/commits{/sha}", 145 | "git_refs_url": "https://api.github.com/repos/ridedott/test-workflows/git/refs{/sha}", 146 | "git_tags_url": "https://api.github.com/repos/ridedott/test-workflows/git/tags{/sha}", 147 | "git_url": "git://github.com/ridedott/test-workflows.git", 148 | "has_downloads": true, 149 | "has_issues": true, 150 | "has_pages": false, 151 | "has_projects": false, 152 | "has_wiki": true, 153 | "homepage": null, 154 | "hooks_url": "https://api.github.com/repos/ridedott/test-workflows/hooks", 155 | "html_url": "https://github.com/ridedott/test-workflows", 156 | "id": 212360168, 157 | "issue_comment_url": "https://api.github.com/repos/ridedott/test-workflows/issues/comments{/number}", 158 | "issue_events_url": "https://api.github.com/repos/ridedott/test-workflows/issues/events{/number}", 159 | "issues_url": "https://api.github.com/repos/ridedott/test-workflows/issues{/number}", 160 | "keys_url": "https://api.github.com/repos/ridedott/test-workflows/keys{/key_id}", 161 | "labels_url": "https://api.github.com/repos/ridedott/test-workflows/labels{/name}", 162 | "language": null, 163 | "languages_url": "https://api.github.com/repos/ridedott/test-workflows/languages", 164 | "license": null, 165 | "merges_url": "https://api.github.com/repos/ridedott/test-workflows/merges", 166 | "milestones_url": "https://api.github.com/repos/ridedott/test-workflows/milestones{/number}", 167 | "mirror_url": null, 168 | "name": "test-workflows", 169 | "node_id": "MDEwOlJlcG9zaXRvcnkyMTIzNjAxNjg=", 170 | "notifications_url": "https://api.github.com/repos/ridedott/test-workflows/notifications{?since,all,participating}", 171 | "open_issues": 2, 172 | "open_issues_count": 2, 173 | "owner": { 174 | "avatar_url": "https://avatars1.githubusercontent.com/u/45282822?v=4", 175 | "events_url": "https://api.github.com/users/ridedott/events{/privacy}", 176 | "followers_url": "https://api.github.com/users/ridedott/followers", 177 | "following_url": "https://api.github.com/users/ridedott/following{/other_user}", 178 | "gists_url": "https://api.github.com/users/ridedott/gists{/gist_id}", 179 | "gravatar_id": "", 180 | "html_url": "https://github.com/ridedott", 181 | "id": 45282822, 182 | "login": "ridedott", 183 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ1MjgyODIy", 184 | "organizations_url": "https://api.github.com/users/ridedott/orgs", 185 | "received_events_url": "https://api.github.com/users/ridedott/received_events", 186 | "repos_url": "https://api.github.com/users/ridedott/repos", 187 | "site_admin": false, 188 | "starred_url": "https://api.github.com/users/ridedott/starred{/owner}{/repo}", 189 | "subscriptions_url": "https://api.github.com/users/ridedott/subscriptions", 190 | "type": "Organization", 191 | "url": "https://api.github.com/users/ridedott" 192 | }, 193 | "private": true, 194 | "pulls_url": "https://api.github.com/repos/ridedott/test-workflows/pulls{/number}", 195 | "pushed_at": "2019-10-10T13:00:45Z", 196 | "releases_url": "https://api.github.com/repos/ridedott/test-workflows/releases{/id}", 197 | "size": 34, 198 | "ssh_url": "git@github.com:ridedott/test-workflows.git", 199 | "stargazers_count": 0, 200 | "stargazers_url": "https://api.github.com/repos/ridedott/test-workflows/stargazers", 201 | "statuses_url": "https://api.github.com/repos/ridedott/test-workflows/statuses/{sha}", 202 | "subscribers_url": "https://api.github.com/repos/ridedott/test-workflows/subscribers", 203 | "subscription_url": "https://api.github.com/repos/ridedott/test-workflows/subscription", 204 | "svn_url": "https://github.com/ridedott/test-workflows", 205 | "tags_url": "https://api.github.com/repos/ridedott/test-workflows/tags", 206 | "teams_url": "https://api.github.com/repos/ridedott/test-workflows/teams", 207 | "trees_url": "https://api.github.com/repos/ridedott/test-workflows/git/trees{/sha}", 208 | "updated_at": "2019-10-10T12:30:17Z", 209 | "url": "https://api.github.com/repos/ridedott/test-workflows", 210 | "watchers": 0, 211 | "watchers_count": 0 212 | }, 213 | "sender": { 214 | "avatar_url": "https://avatars1.githubusercontent.com/u/9158996?v=4", 215 | "events_url": "https://api.github.com/users/dependabot[bot]/events{/privacy}", 216 | "followers_url": "https://api.github.com/users/dependabot[bot]/followers", 217 | "following_url": "https://api.github.com/users/dependabot[bot]/following{/other_user}", 218 | "gists_url": "https://api.github.com/users/dependabot[bot]/gists{/gist_id}", 219 | "gravatar_id": "", 220 | "html_url": "https://github.com/dependabot[bot]", 221 | "id": 9158996, 222 | "login": "dependabot[bot]", 223 | "node_id": "MDQ6VXNlcjkxNTg5OTY=", 224 | "organizations_url": "https://api.github.com/users/dependabot[bot]/orgs", 225 | "received_events_url": "https://api.github.com/users/dependabot[bot]/received_events", 226 | "repos_url": "https://api.github.com/users/dependabot[bot]/repos", 227 | "site_admin": false, 228 | "starred_url": "https://api.github.com/users/dependabot[bot]/starred{/owner}{/repo}", 229 | "subscriptions_url": "https://api.github.com/users/dependabot[bot]/subscriptions", 230 | "type": "User", 231 | "url": "https://api.github.com/users/dependabot[bot]" 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/eventHandlers/continuousIntegrationEnd/index.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @webhook-pragma check_suite 3 | */ 4 | 5 | import * as core from '@actions/core'; 6 | import { context, getOctokit } from '@actions/github'; 7 | import { StatusCodes } from 'http-status-codes'; 8 | import * as nock from 'nock'; 9 | 10 | import { 11 | approveAndMergePullRequestMutation, 12 | useSetTimeoutImmediateInvocation, 13 | } from '../../../test/utilities'; 14 | import { 15 | FindPullRequestCommitsResponse, 16 | FindPullRequestInfoByNumberResponse, 17 | } from '../../types'; 18 | import { AllowedMergeMethods } from '../../utilities/inputParsers'; 19 | import { continuousIntegrationEndHandle } from '.'; 20 | 21 | /* cspell:disable-next-line */ 22 | const PULL_REQUEST_ID = 'MDExOlB1bGxSZXF1ZXN0MzE3MDI5MjU4'; 23 | const PULL_REQUEST_NUMBER = 1234; 24 | const COMMIT_HEADLINE = 'Update test'; 25 | const COMMIT_MESSAGE = 26 | 'Update test\n\nSigned-off-by:dependabot[bot]'; 27 | const DEPENDABOT_GITHUB_LOGIN = 'dependabot'; 28 | 29 | const octokit = getOctokit('SECRET_GITHUB_TOKEN'); 30 | const infoSpy = jest.spyOn(core, 'info').mockImplementation(); 31 | const warningSpy = jest.spyOn(core, 'warning').mockImplementation(); 32 | const debugSpy = jest.spyOn(core, 'debug').mockImplementation(); 33 | const getInputSpy = jest.spyOn(core, 'getInput').mockImplementation(); 34 | 35 | interface Response { 36 | data: FindPullRequestInfoByNumberResponse; 37 | } 38 | 39 | interface CommitsResponse { 40 | data: FindPullRequestCommitsResponse; 41 | } 42 | 43 | const branchProtectionRulesResponse = { 44 | data: { 45 | repository: { 46 | branchProtectionRules: { 47 | edges: [], 48 | pageInfo: { endCursor: '', hasNextPage: false }, 49 | }, 50 | }, 51 | }, 52 | }; 53 | 54 | const validCommitResponse: CommitsResponse = { 55 | data: { 56 | repository: { 57 | pullRequest: { 58 | commits: { 59 | edges: [ 60 | { 61 | node: { 62 | commit: { 63 | author: { 64 | user: { 65 | login: 'dependabot', 66 | }, 67 | }, 68 | signature: { 69 | isValid: true, 70 | }, 71 | }, 72 | }, 73 | }, 74 | ], 75 | pageInfo: { 76 | endCursor: '', 77 | hasNextPage: false, 78 | }, 79 | }, 80 | }, 81 | }, 82 | }, 83 | }; 84 | 85 | beforeEach((): void => { 86 | getInputSpy.mockImplementation((name: string): string => { 87 | if (name === 'GITHUB_LOGIN') { 88 | return DEPENDABOT_GITHUB_LOGIN; 89 | } 90 | 91 | if (name === 'MERGE_METHOD') { 92 | return 'SQUASH'; 93 | } 94 | 95 | if (name === 'PRESET') { 96 | return 'DEPENDABOT_MINOR'; 97 | } 98 | 99 | return ''; 100 | }); 101 | }); 102 | 103 | describe('continuous integration end event handler', (): void => { 104 | it('logs a warning when it cannot find pull request ID by pull request number (null)', async (): Promise => { 105 | expect.assertions(1); 106 | 107 | nock('https://api.github.com') 108 | .post('/graphql') 109 | .reply(StatusCodes.OK, branchProtectionRulesResponse) 110 | .post('/graphql') 111 | .reply(StatusCodes.OK, { data: null }); 112 | 113 | await continuousIntegrationEndHandle(octokit, DEPENDABOT_GITHUB_LOGIN, 3); 114 | 115 | expect(warningSpy).toHaveBeenCalledWith( 116 | 'Unable to fetch pull request information.', 117 | ); 118 | }); 119 | 120 | it('logs a warning when it cannot find pull request ID by pull request number', async (): Promise => { 121 | expect.assertions(1); 122 | 123 | nock('https://api.github.com') 124 | .post('/graphql') 125 | .reply(StatusCodes.OK, branchProtectionRulesResponse) 126 | .post('/graphql') 127 | .reply(StatusCodes.OK, { data: { repository: { pullRequest: null } } }); 128 | 129 | await continuousIntegrationEndHandle(octokit, DEPENDABOT_GITHUB_LOGIN, 3); 130 | 131 | expect(warningSpy).toHaveBeenCalledWith( 132 | 'Unable to fetch pull request information.', 133 | ); 134 | }); 135 | 136 | it('logs a warning when the event is workflow_run and it cannot find pull request ID by pull request number', async (): Promise => { 137 | expect.assertions(1); 138 | 139 | const { check_suite: checkSuite, eventName } = context.payload; 140 | 141 | /* eslint-disable functional/immutable-data */ 142 | context.eventName = 'workflow_run'; 143 | context.payload.workflow_run = checkSuite; 144 | delete context.payload.check_suite; 145 | /* eslint-enable functional/immutable-data */ 146 | 147 | nock('https://api.github.com') 148 | .post('/graphql') 149 | .reply(StatusCodes.OK, branchProtectionRulesResponse) 150 | .post('/graphql') 151 | .reply(StatusCodes.OK, { data: { repository: { pullRequest: null } } }); 152 | 153 | await continuousIntegrationEndHandle(octokit, DEPENDABOT_GITHUB_LOGIN, 3); 154 | 155 | /* eslint-disable require-atomic-updates */ 156 | /* eslint-disable functional/immutable-data */ 157 | context.eventName = eventName; 158 | context.payload.check_suite = checkSuite; 159 | delete context.payload.workflow_run; 160 | /* eslint-enable require-atomic-updates */ 161 | /* eslint-enable functional/immutable-data */ 162 | 163 | expect(warningSpy).toHaveBeenCalledWith( 164 | 'Unable to fetch pull request information.', 165 | ); 166 | }); 167 | 168 | it('retries fetching pull request information for which mergeable status is unknown until retries are exceeded', async (): Promise => { 169 | expect.assertions(1); 170 | 171 | const firstResponse: Response = { 172 | data: { 173 | repository: { 174 | pullRequest: { 175 | author: { login: 'dependabot' }, 176 | base: { 177 | // eslint-disable-next-line unicorn/prevent-abbreviations 178 | ref: 'master', 179 | }, 180 | commits: { 181 | edges: [ 182 | { 183 | node: { 184 | commit: { 185 | message: COMMIT_MESSAGE, 186 | messageHeadline: COMMIT_HEADLINE, 187 | }, 188 | }, 189 | }, 190 | ], 191 | }, 192 | id: PULL_REQUEST_ID, 193 | mergeStateStatus: 'UNKNOWN', 194 | mergeable: 'UNKNOWN', 195 | merged: false, 196 | number: PULL_REQUEST_NUMBER, 197 | reviews: { edges: [{ node: { state: 'APPROVED' } }] }, 198 | state: 'OPEN', 199 | title: 'bump @types/jest from 26.0.12 to 26.1.0', 200 | }, 201 | }, 202 | }, 203 | }; 204 | 205 | nock('https://api.github.com') 206 | .post('/graphql') 207 | .reply(StatusCodes.OK, branchProtectionRulesResponse) 208 | .post('/graphql') 209 | .times(3) 210 | .reply(StatusCodes.OK, firstResponse); 211 | 212 | useSetTimeoutImmediateInvocation(); 213 | 214 | await continuousIntegrationEndHandle(octokit, DEPENDABOT_GITHUB_LOGIN, 3); 215 | 216 | expect(debugSpy).toHaveBeenLastCalledWith( 217 | 'Failed to get pull request #7 information after 3 attempts. Retries exhausted.', 218 | ); 219 | }); 220 | 221 | it('retries fetching pull request information for which mergeable status is unknown', async (): Promise => { 222 | expect.assertions(1); 223 | 224 | const firstResponse: Response = { 225 | data: { 226 | repository: { 227 | pullRequest: { 228 | author: { login: 'dependabot' }, 229 | base: { 230 | // eslint-disable-next-line unicorn/prevent-abbreviations 231 | ref: 'master', 232 | }, 233 | commits: { 234 | edges: [ 235 | { 236 | node: { 237 | commit: { 238 | message: COMMIT_MESSAGE, 239 | messageHeadline: COMMIT_HEADLINE, 240 | }, 241 | }, 242 | }, 243 | ], 244 | }, 245 | id: PULL_REQUEST_ID, 246 | mergeStateStatus: 'UNKNOWN', 247 | mergeable: 'UNKNOWN', 248 | merged: false, 249 | number: PULL_REQUEST_NUMBER, 250 | reviews: { edges: [{ node: { state: 'APPROVED' } }] }, 251 | state: 'OPEN', 252 | title: 'bump @types/jest from 26.0.12 to 26.1.0', 253 | }, 254 | }, 255 | }, 256 | }; 257 | 258 | const secondResponse: Response = { 259 | data: { 260 | repository: { 261 | pullRequest: { 262 | author: { login: 'dependabot' }, 263 | base: { 264 | // eslint-disable-next-line unicorn/prevent-abbreviations 265 | ref: 'master', 266 | }, 267 | commits: { 268 | edges: [ 269 | { 270 | node: { 271 | commit: { 272 | message: COMMIT_MESSAGE, 273 | messageHeadline: COMMIT_HEADLINE, 274 | }, 275 | }, 276 | }, 277 | ], 278 | }, 279 | id: PULL_REQUEST_ID, 280 | mergeStateStatus: 'CLEAN', 281 | mergeable: 'MERGEABLE', 282 | merged: true, 283 | number: PULL_REQUEST_NUMBER, 284 | reviews: { edges: [{ node: { state: 'APPROVED' } }] }, 285 | state: 'OPEN', 286 | title: 'bump @types/jest from 26.0.12 to 26.1.0', 287 | }, 288 | }, 289 | }, 290 | }; 291 | 292 | nock('https://api.github.com') 293 | .post('/graphql') 294 | .reply(StatusCodes.OK, branchProtectionRulesResponse) 295 | .post('/graphql') 296 | .reply(StatusCodes.OK, firstResponse); 297 | 298 | nock('https://api.github.com') 299 | .post('/graphql') 300 | .reply(StatusCodes.OK, secondResponse); 301 | 302 | useSetTimeoutImmediateInvocation(); 303 | 304 | await continuousIntegrationEndHandle(octokit, DEPENDABOT_GITHUB_LOGIN, 3); 305 | 306 | expect(infoSpy).toHaveBeenLastCalledWith('Pull request is already merged.'); 307 | }); 308 | 309 | it('does not merge if request not created by the selected GITHUB_LOGIN and logs it', async (): Promise => { 310 | expect.assertions(1); 311 | 312 | const response: Response = { 313 | data: { 314 | repository: { 315 | pullRequest: { 316 | author: { login: 'dependabot' }, 317 | base: { 318 | // eslint-disable-next-line unicorn/prevent-abbreviations 319 | ref: 'master', 320 | }, 321 | commits: { 322 | edges: [ 323 | { 324 | node: { 325 | commit: { 326 | message: COMMIT_MESSAGE, 327 | messageHeadline: COMMIT_HEADLINE, 328 | }, 329 | }, 330 | }, 331 | ], 332 | }, 333 | id: PULL_REQUEST_ID, 334 | mergeable: 'MERGEABLE', 335 | merged: false, 336 | number: PULL_REQUEST_NUMBER, 337 | reviews: { edges: [{ node: { state: 'APPROVED' } }] }, 338 | state: 'CLOSED', 339 | title: 'bump @types/jest from 26.0.12 to 26.1.0', 340 | }, 341 | }, 342 | }, 343 | }; 344 | 345 | nock('https://api.github.com') 346 | .post('/graphql') 347 | .reply(StatusCodes.OK, branchProtectionRulesResponse) 348 | .post('/graphql') 349 | .reply(StatusCodes.OK, response); 350 | 351 | await continuousIntegrationEndHandle(octokit, 'some-other-login', 3); 352 | 353 | expect(infoSpy).toHaveBeenCalledWith( 354 | 'Pull request #1234 created by dependabot, not some-other-login, skipping.', 355 | ); 356 | }); 357 | 358 | it('retries, approves and merges a pull request', async (): Promise => { 359 | expect.assertions(0); 360 | 361 | const response: Response = { 362 | data: { 363 | repository: { 364 | pullRequest: { 365 | author: { login: 'dependabot' }, 366 | base: { 367 | // eslint-disable-next-line unicorn/prevent-abbreviations 368 | ref: 'master', 369 | }, 370 | commits: { 371 | edges: [ 372 | { 373 | node: { 374 | commit: { 375 | message: COMMIT_MESSAGE, 376 | messageHeadline: COMMIT_HEADLINE, 377 | }, 378 | }, 379 | }, 380 | ], 381 | }, 382 | id: PULL_REQUEST_ID, 383 | mergeable: 'MERGEABLE', 384 | merged: false, 385 | number: PULL_REQUEST_NUMBER, 386 | reviews: { edges: [] }, 387 | state: 'OPEN', 388 | title: 'bump @types/jest from 26.0.12 to 26.1.0', 389 | }, 390 | }, 391 | }, 392 | }; 393 | 394 | nock('https://api.github.com') 395 | .post('/graphql') 396 | .reply(StatusCodes.OK, branchProtectionRulesResponse) 397 | .post('/graphql') 398 | .reply(StatusCodes.OK, response) 399 | .post('/graphql') 400 | .reply(StatusCodes.OK, validCommitResponse) 401 | .post('/graphql') 402 | .times(2) 403 | .reply( 404 | 403, 405 | '##[error]GraphqlError: Base branch was modified. Review and try the merge again.', 406 | ) 407 | .post('/graphql', { 408 | query: approveAndMergePullRequestMutation(AllowedMergeMethods.SQUASH), 409 | variables: { 410 | commitHeadline: COMMIT_HEADLINE, 411 | pullRequestId: PULL_REQUEST_ID, 412 | }, 413 | }) 414 | .reply(StatusCodes.OK); 415 | 416 | useSetTimeoutImmediateInvocation(); 417 | 418 | await continuousIntegrationEndHandle(octokit, DEPENDABOT_GITHUB_LOGIN, 3); 419 | }); 420 | }); 421 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # merge-me-action 2 | 3 | [![license: MIT](https://img.shields.io/github/license/ridedott/merge-me-action)](https://github.com/ridedott/merge-me-action/blob/master/LICENSE) 4 | [![Continuous Integration](https://github.com/ridedott/merge-me-action/workflows/Continuous%20Integration/badge.svg)](https://github.com/ridedott/merge-me-action/actions) 5 | [![Continuous Delivery](https://github.com/ridedott/merge-me-action/workflows/Continuous%20Delivery/badge.svg)](https://github.com/ridedott/merge-me-action/actions) 6 | [![Coveralls](https://coveralls.io/repos/github/ridedott/merge-me-action/badge.svg)](https://coveralls.io/github/ridedott/merge-me-action) 7 | [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 8 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) 9 | 10 | This Action approves and attempts to merge Pull Requests when triggered. 11 | 12 | By using 13 | [branch protection](https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/about-protected-branches) 14 | rules, it can be specified what the requirements are for a PR to be merged (e.g. 15 | require branches to be up to date, require status checks to pass). 16 | 17 | ## Usage 18 | 19 | The Action supports three run triggers: 20 | 21 | - `check_suite` (works only on the default branch). 22 | - `pull_request_target` for all branches. 23 | - `workflow_run` for all branches. 24 | 25 | When using the Merge Me! Action, ensure security of your workflows. GitHub 26 | Security Lab provides more 27 | [detailed](https://securitylab.github.com/research/github-actions-preventing-pwn-requests/) 28 | overview of these risks involved in using `pull_request_target` and 29 | `workflow_run` triggers, as well as recommendations on how to avoid these risks. 30 | 31 | Recommended setup differs between public and private repositories, however the 32 | Action can be used in other combinations as well. 33 | 34 | ### Public repositories 35 | 36 | Using a `workflow_run` trigger allows to provide the Merge Me! Action with 37 | necessary credentials, while allowing the CI to keep using `pull_request` 38 | trigger, which is safer than `pull_request_target`. 39 | 40 | Create a new `.github/workflows/merge-me.yaml` file: 41 | 42 | ```yaml 43 | name: Merge me! 44 | 45 | on: 46 | workflow_run: 47 | types: 48 | - completed 49 | workflows: 50 | # List all required workflow names here. 51 | - 'Continuous Integration' 52 | 53 | jobs: 54 | merge-me: 55 | name: Merge me! 56 | runs-on: ubuntu-latest 57 | steps: 58 | - # It is often a desired behavior to merge only when a workflow execution 59 | # succeeds. This can be changed as needed. 60 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 61 | name: Merge me! 62 | uses: ridedott/merge-me-action@v2 63 | with: 64 | # Depending on branch protection rules, a manually populated 65 | # `GITHUB_TOKEN_WORKAROUND` secret with permissions to push to 66 | # a protected branch must be used. This secret can have an arbitrary 67 | # name, as an example, this repository uses `DOTTBOTT_TOKEN`. 68 | # 69 | # When using a custom token, it is recommended to leave the following 70 | # comment for other developers to be aware of the reasoning behind it: 71 | # 72 | # This must be used as GitHub Actions token does not support pushing 73 | # to protected branches. 74 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 75 | ``` 76 | 77 | Triggering on `check_suite` is similar: 78 | 79 | ```yaml 80 | name: Merge me! 81 | 82 | on: 83 | check_suite: 84 | types: 85 | - completed 86 | 87 | jobs: 88 | merge-me: 89 | name: Merge me! 90 | runs-on: ubuntu-latest 91 | steps: 92 | - name: Merge me! 93 | uses: ridedott/merge-me-action@v2 94 | with: 95 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 96 | ``` 97 | 98 | ### Private repositories 99 | 100 | Private repositories are less prone attacks, as only a restricted set of 101 | accounts has access to them. At the same time, CIs in private repositories often 102 | require access to secrets for other purposes as well, such as installing private 103 | dependencies. For these reasons, it is recommended to use `pull_request_target` 104 | trigger, which allows to combine regular CI checks and the Merge Me! Action into 105 | one workflow: 106 | 107 | ```yaml 108 | name: Continuous Integration 109 | 110 | on: 111 | # Trigger on Pull Requests against the master branch. 112 | pull_request_target: 113 | branches: 114 | - master 115 | types: 116 | - opened 117 | - synchronize 118 | # Trigger on Pull Requests to the master branch. 119 | push: 120 | branches: 121 | - master 122 | 123 | jobs: 124 | # Add other CI jobs, such as testing and linting. The example test job 125 | # showcases checkout settings which support `pull_request_target` and `push` 126 | # triggers at the same time. 127 | test: 128 | name: Test 129 | runs-on: ubuntu-latest 130 | steps: 131 | - name: Checkout 132 | uses: actions/checkout@v2 133 | with: 134 | # This adds support for both `pull_request_target` and `push` events. 135 | ref: ${{ github.event.pull_request.head.sha || github.sha }} 136 | - name: Setup Node.js 137 | uses: actions/setup-node@v2 138 | with: 139 | node-version: 20 140 | registry-url: https://npm.pkg.github.com 141 | - # This allows private dependencies from GitHub Packages to be installed. 142 | # Depending on the setup, it might be required to use a personal access 143 | # token instead. 144 | env: 145 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 146 | name: Install dependencies 147 | run: npm ci --ignore-scripts --no-audit --no-progress 148 | - name: Test 149 | run: npm run test 150 | merge-me: 151 | name: Merge me! 152 | needs: 153 | # List all required job names here. 154 | - test 155 | runs-on: ubuntu-latest 156 | steps: 157 | - name: Merge me! 158 | uses: ridedott/merge-me-action@v2 159 | with: 160 | # Depending on branch protection rules, a manually populated 161 | # `GITHUB_TOKEN_WORKAROUND` secret with permissions to push to 162 | # a protected branch must be used. This secret can have an arbitrary 163 | # name, as an example, this repository uses `DOTTBOTT_TOKEN`. 164 | # 165 | # When using a custom token, it is recommended to leave the following 166 | # comment for other developers to be aware of the reasoning behind it: 167 | # 168 | # This must be used as GitHub Actions token does not support pushing 169 | # to protected branches. 170 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 171 | timeout-minutes: 5 172 | ``` 173 | 174 | ## Configuration 175 | 176 | ### Enable auto-merge for a different bot 177 | 178 | You may have another bot that also creates PRs against your repository and you 179 | want to automatically merge those. By default, this GitHub Action assumes the 180 | bot is [`dependabot`](https://github.com/dependabot). You can override the bot 181 | name by changing the value of `GITHUB_LOGIN` parameter: 182 | 183 | ```yaml 184 | jobs: 185 | merge-me: 186 | steps: 187 | - name: Merge me! 188 | uses: ridedott/merge-me-action@v2 189 | with: 190 | GITHUB_LOGIN: my-awesome-bot-r2d2 191 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 192 | ``` 193 | 194 | A common scenario is to use Dependabot Preview (consider updating instead): 195 | 196 | ```yaml 197 | jobs: 198 | merge-me: 199 | steps: 200 | - name: Merge me! 201 | uses: ridedott/merge-me-action@v2 202 | with: 203 | GITHUB_LOGIN: dependabot-preview 204 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 205 | ``` 206 | 207 | `GITHUB_LOGIN` option supports 208 | [micromatch](https://github.com/micromatch/micromatch). 209 | 210 | ### Opting in for using GitHub preview APIs 211 | 212 | You may opt-in for using GitHub preview APIs, which enables the action to 213 | respect strict branch protection rules configured for the repository 214 | (`Require status checks to pass before merging` and 215 | `Require branches to be up to date before merging` options). 216 | 217 | ```yaml 218 | jobs: 219 | merge-me: 220 | steps: 221 | - name: Merge me! 222 | uses: ridedott/merge-me-action@v2 223 | with: 224 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 225 | ENABLE_GITHUB_API_PREVIEW: true 226 | ``` 227 | 228 | ### Use of configurable pull request merge method 229 | 230 | By default, this GitHub Action assumes merge method is `SQUASH`. You can 231 | override the merge method by changing the value of `MERGE_METHOD` parameter (one 232 | of `MERGE`, `SQUASH` or `REBASE`): 233 | 234 | ```yaml 235 | jobs: 236 | merge-me: 237 | steps: 238 | - name: Merge me! 239 | uses: ridedott/merge-me-action@v2 240 | with: 241 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 242 | MERGE_METHOD: MERGE 243 | ``` 244 | 245 | ### Presets 246 | 247 | Presets enable additional functionality which can be used to better personalize 248 | default behavior of the Merge me! Action. 249 | 250 | Available presets are: 251 | 252 | - `DEPENDABOT_MINOR` - Merge only minor and patch dependency updates for pull 253 | requests created by Dependabot if the dependency version follows 254 | [Semantic Versioning v2](https://semver.org/). 255 | - `DEPENDABOT_PATCH` - Merge only patch dependency updates for pull requests 256 | created by Dependabot if the dependency version follows 257 | [Semantic Versioning v2](https://semver.org/). 258 | 259 | ```yaml 260 | jobs: 261 | merge-me: 262 | steps: 263 | - name: Merge me! 264 | uses: ridedott/merge-me-action@v2 265 | with: 266 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 267 | PRESET: DEPENDABOT_PATCH 268 | ``` 269 | 270 | ### Number of retries 271 | 272 | In case the merge action fails, by default it will automatically be retried up 273 | to three times using an exponential backoff strategy. This means, the first 274 | retry will happen 1 second after the first failure, while the second will happen 275 | 4 seconds after the previous, the third 9 seconds, and so on. 276 | 277 | It's possible to configure the number of retries by providing a value for 278 | `MAXIMUM_RETRIES` (by default, the value is `3`). 279 | 280 | ```yaml 281 | jobs: 282 | merge-me: 283 | steps: 284 | - name: Merge me! 285 | uses: ridedott/merge-me-action@v2 286 | with: 287 | MAXIMUM_RETRIES: 2 288 | ``` 289 | 290 | ### Enable for manual changes 291 | 292 | There are cases in which manual changes are needed, for instance, in order to 293 | make the CI pass or to solve some conflicts that Dependabot (or the bot you are 294 | using) cannot handle. By default, this GitHub action will skip this case where 295 | the author is not [`dependabot`](https://github.com/dependabot) (or the bot you 296 | are using). This is often desirable as the author might prefer to get a code 297 | review before merging the changes. For this, it checks whether all commits were 298 | made by the original author and that the commit signature is valid. 299 | 300 | It is possible to override this default behavior by setting the value of 301 | `ENABLED_FOR_MANUAL_CHANGES` to `'true'`. 302 | 303 | ```yaml 304 | jobs: 305 | merge-me: 306 | steps: 307 | - name: Merge me! 308 | uses: ridedott/merge-me-action@v2 309 | with: 310 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 311 | ENABLED_FOR_MANUAL_CHANGES: 'true' 312 | ``` 313 | 314 | > Important: Please note the single quotes around `true`. 315 | 316 | ## Getting Started 317 | 318 | These instructions will get you a copy of the project up and running on your 319 | local machine for development and testing purposes. See usage notes on how to 320 | consume this package in your project. 321 | 322 | ### Prerequisites 323 | 324 | Minimal requirements to set up the project: 325 | 326 | - [Node.js](https://nodejs.org/en) v14, installation instructions can be found 327 | on the official website, a recommended installation option is to use 328 | [Node Version Manager](https://github.com/creationix/nvm#readme). It can be 329 | installed in a 330 | [few commands](https://nodejs.org/en/download/package-manager/#nvm). 331 | - A package manager [npm](https://www.npmjs.com). All instructions in the 332 | documentation will follow the npm syntax. 333 | - Optionally a [Git](https://git-scm.com) client. 334 | 335 | ### Installing 336 | 337 | Start by cloning the repository: 338 | 339 | ```bash 340 | git clone git@github.com:ridedott/merge-me-action.git 341 | ``` 342 | 343 | In case you don't have a git client, you can get the latest version directly by 344 | using 345 | [this link](https://github.com/ridedott/merge-me-action/archive/master.zip) and 346 | extracting the downloaded archive. 347 | 348 | Go the the right directory and install dependencies: 349 | 350 | ```bash 351 | cd merge-me-action 352 | npm install 353 | ``` 354 | 355 | That's it! You can now go to the next step. 356 | 357 | ## Testing 358 | 359 | All tests are being executed using [Jest](https://jestjs.io). All tests files 360 | live side-to-side with a source code and have a common suffix: `.spec.ts`. Some 361 | helper methods are being stored in the `test` directory. 362 | 363 | There are three helper scripts to run tests in the most common scenarios: 364 | 365 | ```bash 366 | npm run test 367 | npm run test:watch 368 | npm run test:coverage 369 | ``` 370 | 371 | ## Formatting 372 | 373 | This project uses [Prettier](https://prettier.io) to automate formatting. All 374 | supported files are being reformatted in a pre-commit hook. You can also use one 375 | of the two scripts to validate and optionally fix all of the files: 376 | 377 | ```bash 378 | npm run format 379 | npm run format:fix 380 | ``` 381 | 382 | ## Linting 383 | 384 | This project uses [ESLint](https://eslint.org) to enable static analysis. 385 | TypeScript files are linted using a [custom configuration](./.eslintrc). You can 386 | use one of the following scripts to validate and optionally fix all of the 387 | files: 388 | 389 | ```bash 390 | npm run lint 391 | npm run lint:fix 392 | ``` 393 | 394 | ## Publishing 395 | 396 | Publishing is handled in an automated way and must not be performed manually. 397 | 398 | Each commit to the master branch is automatically tagged using 399 | [`semantic-release`](https://github.com/semantic-release/semantic-release). 400 | 401 | ## Contributing 402 | 403 | See [CONTRIBUTING.md](./CONTRIBUTING.md). 404 | 405 | ## Built with 406 | 407 | ### Automation 408 | 409 | - [Dependabot](https://dependabot.com/) 410 | - [GitHub Actions](https://github.com/features/actions) 411 | 412 | ### Source 413 | 414 | - [TypeScript](https://www.typescriptlang.org) 415 | 416 | ## Versioning 417 | 418 | This project adheres to [Semantic Versioning](http://semver.org) v2. 419 | -------------------------------------------------------------------------------- /test/fixtures/ctx.pull-request.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "number": 7, 4 | "organization": { 5 | "avatar_url": "https://avatars1.githubusercontent.com/u/45282822?v=4", 6 | "description": "", 7 | "events_url": "https://api.github.com/orgs/ridedott/events", 8 | "hooks_url": "https://api.github.com/orgs/ridedott/hooks", 9 | "id": 45282822, 10 | "issues_url": "https://api.github.com/orgs/ridedott/issues", 11 | "login": "ridedott", 12 | "members_url": "https://api.github.com/orgs/ridedott/members{/member}", 13 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ1MjgyODIy", 14 | "public_members_url": "https://api.github.com/orgs/ridedott/public_members{/member}", 15 | "repos_url": "https://api.github.com/orgs/ridedott/repos", 16 | "url": "https://api.github.com/orgs/ridedott" 17 | }, 18 | "pull_request": { 19 | "_links": { 20 | "comments": { 21 | "href": "https://api.github.com/repos/ridedott/test-workflows/issues/7/comments" 22 | }, 23 | "commits": { 24 | "href": "https://api.github.com/repos/ridedott/test-workflows/pulls/7/commits" 25 | }, 26 | "html": { 27 | "href": "https://github.com/ridedott/test-workflows/pull/7" 28 | }, 29 | "issue": { 30 | "href": "https://api.github.com/repos/ridedott/test-workflows/issues/7" 31 | }, 32 | "review_comment": { 33 | "href": "https://api.github.com/repos/ridedott/test-workflows/pulls/comments{/number}" 34 | }, 35 | "review_comments": { 36 | "href": "https://api.github.com/repos/ridedott/test-workflows/pulls/7/comments" 37 | }, 38 | "self": { 39 | "href": "https://api.github.com/repos/ridedott/test-workflows/pulls/7" 40 | }, 41 | "statuses": { 42 | "href": "https://api.github.com/repos/ridedott/test-workflows/statuses/9d00ff713d0987cc1f4d3d5c00e6fa8ff4066e1f" 43 | } 44 | }, 45 | "additions": 2, 46 | "assignee": null, 47 | "assignees": [], 48 | "author_association": "COLLABORATOR", 49 | "base": { 50 | "label": "ridedott:master", 51 | "ref": "master", 52 | "repo": { 53 | "archive_url": "https://api.github.com/repos/ridedott/test-workflows/{archive_format}{/ref}", 54 | "archived": false, 55 | "assignees_url": "https://api.github.com/repos/ridedott/test-workflows/assignees{/user}", 56 | "blobs_url": "https://api.github.com/repos/ridedott/test-workflows/git/blobs{/sha}", 57 | "branches_url": "https://api.github.com/repos/ridedott/test-workflows/branches{/branch}", 58 | "clone_url": "https://github.com/ridedott/test-workflows.git", 59 | "collaborators_url": "https://api.github.com/repos/ridedott/test-workflows/collaborators{/collaborator}", 60 | "comments_url": "https://api.github.com/repos/ridedott/test-workflows/comments{/number}", 61 | "commits_url": "https://api.github.com/repos/ridedott/test-workflows/commits{/sha}", 62 | "compare_url": "https://api.github.com/repos/ridedott/test-workflows/compare/{base}...{head}", 63 | "contents_url": "https://api.github.com/repos/ridedott/test-workflows/contents/{+path}", 64 | "contributors_url": "https://api.github.com/repos/ridedott/test-workflows/contributors", 65 | "created_at": "2019-10-02T14:18:49Z", 66 | "default_branch": "master", 67 | "deployments_url": "https://api.github.com/repos/ridedott/test-workflows/deployments", 68 | "description": null, 69 | "disabled": false, 70 | "downloads_url": "https://api.github.com/repos/ridedott/test-workflows/downloads", 71 | "events_url": "https://api.github.com/repos/ridedott/test-workflows/events", 72 | "fork": false, 73 | "forks": 0, 74 | "forks_count": 0, 75 | "forks_url": "https://api.github.com/repos/ridedott/test-workflows/forks", 76 | "full_name": "ridedott/test-workflows", 77 | "git_commits_url": "https://api.github.com/repos/ridedott/test-workflows/git/commits{/sha}", 78 | "git_refs_url": "https://api.github.com/repos/ridedott/test-workflows/git/refs{/sha}", 79 | "git_tags_url": "https://api.github.com/repos/ridedott/test-workflows/git/tags{/sha}", 80 | "git_url": "git://github.com/ridedott/test-workflows.git", 81 | "has_downloads": true, 82 | "has_issues": true, 83 | "has_pages": false, 84 | "has_projects": false, 85 | "has_wiki": true, 86 | "homepage": null, 87 | "hooks_url": "https://api.github.com/repos/ridedott/test-workflows/hooks", 88 | "html_url": "https://github.com/ridedott/test-workflows", 89 | "id": 212360168, 90 | "issue_comment_url": "https://api.github.com/repos/ridedott/test-workflows/issues/comments{/number}", 91 | "issue_events_url": "https://api.github.com/repos/ridedott/test-workflows/issues/events{/number}", 92 | "issues_url": "https://api.github.com/repos/ridedott/test-workflows/issues{/number}", 93 | "keys_url": "https://api.github.com/repos/ridedott/test-workflows/keys{/key_id}", 94 | "labels_url": "https://api.github.com/repos/ridedott/test-workflows/labels{/name}", 95 | "language": null, 96 | "languages_url": "https://api.github.com/repos/ridedott/test-workflows/languages", 97 | "license": null, 98 | "merges_url": "https://api.github.com/repos/ridedott/test-workflows/merges", 99 | "milestones_url": "https://api.github.com/repos/ridedott/test-workflows/milestones{/number}", 100 | "mirror_url": null, 101 | "name": "test-workflows", 102 | "node_id": "MDEwOlJlcG9zaXRvcnkyMTIzNjAxNjg=", 103 | "notifications_url": "https://api.github.com/repos/ridedott/test-workflows/notifications{?since,all,participating}", 104 | "open_issues": 2, 105 | "open_issues_count": 2, 106 | "owner": { 107 | "avatar_url": "https://avatars1.githubusercontent.com/u/45282822?v=4", 108 | "events_url": "https://api.github.com/users/ridedott/events{/privacy}", 109 | "followers_url": "https://api.github.com/users/ridedott/followers", 110 | "following_url": "https://api.github.com/users/ridedott/following{/other_user}", 111 | "gists_url": "https://api.github.com/users/ridedott/gists{/gist_id}", 112 | "gravatar_id": "", 113 | "html_url": "https://github.com/ridedott", 114 | "id": 45282822, 115 | "login": "ridedott", 116 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ1MjgyODIy", 117 | "organizations_url": "https://api.github.com/users/ridedott/orgs", 118 | "received_events_url": "https://api.github.com/users/ridedott/received_events", 119 | "repos_url": "https://api.github.com/users/ridedott/repos", 120 | "site_admin": false, 121 | "starred_url": "https://api.github.com/users/ridedott/starred{/owner}{/repo}", 122 | "subscriptions_url": "https://api.github.com/users/ridedott/subscriptions", 123 | "type": "Organization", 124 | "url": "https://api.github.com/users/ridedott" 125 | }, 126 | "private": true, 127 | "pulls_url": "https://api.github.com/repos/ridedott/test-workflows/pulls{/number}", 128 | "pushed_at": "2019-10-10T13:00:26Z", 129 | "releases_url": "https://api.github.com/repos/ridedott/test-workflows/releases{/id}", 130 | "size": 34, 131 | "ssh_url": "git@github.com:ridedott/test-workflows.git", 132 | "stargazers_count": 0, 133 | "stargazers_url": "https://api.github.com/repos/ridedott/test-workflows/stargazers", 134 | "statuses_url": "https://api.github.com/repos/ridedott/test-workflows/statuses/{sha}", 135 | "subscribers_url": "https://api.github.com/repos/ridedott/test-workflows/subscribers", 136 | "subscription_url": "https://api.github.com/repos/ridedott/test-workflows/subscription", 137 | "svn_url": "https://github.com/ridedott/test-workflows", 138 | "tags_url": "https://api.github.com/repos/ridedott/test-workflows/tags", 139 | "teams_url": "https://api.github.com/repos/ridedott/test-workflows/teams", 140 | "trees_url": "https://api.github.com/repos/ridedott/test-workflows/git/trees{/sha}", 141 | "updated_at": "2019-10-10T12:30:17Z", 142 | "url": "https://api.github.com/repos/ridedott/test-workflows", 143 | "watchers": 0, 144 | "watchers_count": 0 145 | }, 146 | "sha": "d22d3e84651700bce87ca81f445b4d4778490d48", 147 | "user": { 148 | "avatar_url": "https://avatars1.githubusercontent.com/u/45282822?v=4", 149 | "events_url": "https://api.github.com/users/ridedott/events{/privacy}", 150 | "followers_url": "https://api.github.com/users/ridedott/followers", 151 | "following_url": "https://api.github.com/users/ridedott/following{/other_user}", 152 | "gists_url": "https://api.github.com/users/ridedott/gists{/gist_id}", 153 | "gravatar_id": "", 154 | "html_url": "https://github.com/ridedott", 155 | "id": 45282822, 156 | "login": "ridedott", 157 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ1MjgyODIy", 158 | "organizations_url": "https://api.github.com/users/ridedott/orgs", 159 | "received_events_url": "https://api.github.com/users/ridedott/received_events", 160 | "repos_url": "https://api.github.com/users/ridedott/repos", 161 | "site_admin": false, 162 | "starred_url": "https://api.github.com/users/ridedott/starred{/owner}{/repo}", 163 | "subscriptions_url": "https://api.github.com/users/ridedott/subscriptions", 164 | "type": "Organization", 165 | "url": "https://api.github.com/users/ridedott" 166 | } 167 | }, 168 | "body": "", 169 | "changed_files": 1, 170 | "closed_at": null, 171 | "comments": 0, 172 | "comments_url": "https://api.github.com/repos/ridedott/test-workflows/issues/7/comments", 173 | "commits": 12, 174 | "commits_url": "https://api.github.com/repos/ridedott/test-workflows/pulls/7/commits", 175 | "created_at": "2019-10-10T13:00:44Z", 176 | "deletions": 1, 177 | "diff_url": "https://github.com/ridedott/test-workflows/pull/7.diff", 178 | "draft": false, 179 | "head": { 180 | "label": "ridedott:dependabot[bot]/test-branch", 181 | "ref": "dependabot[bot]/test-branch", 182 | "repo": { 183 | "archive_url": "https://api.github.com/repos/ridedott/test-workflows/{archive_format}{/ref}", 184 | "archived": false, 185 | "assignees_url": "https://api.github.com/repos/ridedott/test-workflows/assignees{/user}", 186 | "blobs_url": "https://api.github.com/repos/ridedott/test-workflows/git/blobs{/sha}", 187 | "branches_url": "https://api.github.com/repos/ridedott/test-workflows/branches{/branch}", 188 | "clone_url": "https://github.com/ridedott/test-workflows.git", 189 | "collaborators_url": "https://api.github.com/repos/ridedott/test-workflows/collaborators{/collaborator}", 190 | "comments_url": "https://api.github.com/repos/ridedott/test-workflows/comments{/number}", 191 | "commits_url": "https://api.github.com/repos/ridedott/test-workflows/commits{/sha}", 192 | "compare_url": "https://api.github.com/repos/ridedott/test-workflows/compare/{base}...{head}", 193 | "contents_url": "https://api.github.com/repos/ridedott/test-workflows/contents/{+path}", 194 | "contributors_url": "https://api.github.com/repos/ridedott/test-workflows/contributors", 195 | "created_at": "2019-10-02T14:18:49Z", 196 | "default_branch": "master", 197 | "deployments_url": "https://api.github.com/repos/ridedott/test-workflows/deployments", 198 | "description": null, 199 | "disabled": false, 200 | "downloads_url": "https://api.github.com/repos/ridedott/test-workflows/downloads", 201 | "events_url": "https://api.github.com/repos/ridedott/test-workflows/events", 202 | "fork": false, 203 | "forks": 0, 204 | "forks_count": 0, 205 | "forks_url": "https://api.github.com/repos/ridedott/test-workflows/forks", 206 | "full_name": "ridedott/test-workflows", 207 | "git_commits_url": "https://api.github.com/repos/ridedott/test-workflows/git/commits{/sha}", 208 | "git_refs_url": "https://api.github.com/repos/ridedott/test-workflows/git/refs{/sha}", 209 | "git_tags_url": "https://api.github.com/repos/ridedott/test-workflows/git/tags{/sha}", 210 | "git_url": "git://github.com/ridedott/test-workflows.git", 211 | "has_downloads": true, 212 | "has_issues": true, 213 | "has_pages": false, 214 | "has_projects": false, 215 | "has_wiki": true, 216 | "homepage": null, 217 | "hooks_url": "https://api.github.com/repos/ridedott/test-workflows/hooks", 218 | "html_url": "https://github.com/ridedott/test-workflows", 219 | "id": 212360168, 220 | "issue_comment_url": "https://api.github.com/repos/ridedott/test-workflows/issues/comments{/number}", 221 | "issue_events_url": "https://api.github.com/repos/ridedott/test-workflows/issues/events{/number}", 222 | "issues_url": "https://api.github.com/repos/ridedott/test-workflows/issues{/number}", 223 | "keys_url": "https://api.github.com/repos/ridedott/test-workflows/keys{/key_id}", 224 | "labels_url": "https://api.github.com/repos/ridedott/test-workflows/labels{/name}", 225 | "language": null, 226 | "languages_url": "https://api.github.com/repos/ridedott/test-workflows/languages", 227 | "license": null, 228 | "merges_url": "https://api.github.com/repos/ridedott/test-workflows/merges", 229 | "milestones_url": "https://api.github.com/repos/ridedott/test-workflows/milestones{/number}", 230 | "mirror_url": null, 231 | "name": "test-workflows", 232 | "node_id": "MDEwOlJlcG9zaXRvcnkyMTIzNjAxNjg=", 233 | "notifications_url": "https://api.github.com/repos/ridedott/test-workflows/notifications{?since,all,participating}", 234 | "open_issues": 2, 235 | "open_issues_count": 2, 236 | "owner": { 237 | "avatar_url": "https://avatars1.githubusercontent.com/u/45282822?v=4", 238 | "events_url": "https://api.github.com/users/ridedott/events{/privacy}", 239 | "followers_url": "https://api.github.com/users/ridedott/followers", 240 | "following_url": "https://api.github.com/users/ridedott/following{/other_user}", 241 | "gists_url": "https://api.github.com/users/ridedott/gists{/gist_id}", 242 | "gravatar_id": "", 243 | "html_url": "https://github.com/ridedott", 244 | "id": 45282822, 245 | "login": "ridedott", 246 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ1MjgyODIy", 247 | "organizations_url": "https://api.github.com/users/ridedott/orgs", 248 | "received_events_url": "https://api.github.com/users/ridedott/received_events", 249 | "repos_url": "https://api.github.com/users/ridedott/repos", 250 | "site_admin": false, 251 | "starred_url": "https://api.github.com/users/ridedott/starred{/owner}{/repo}", 252 | "subscriptions_url": "https://api.github.com/users/ridedott/subscriptions", 253 | "type": "Organization", 254 | "url": "https://api.github.com/users/ridedott" 255 | }, 256 | "private": true, 257 | "pulls_url": "https://api.github.com/repos/ridedott/test-workflows/pulls{/number}", 258 | "pushed_at": "2019-10-10T13:00:26Z", 259 | "releases_url": "https://api.github.com/repos/ridedott/test-workflows/releases{/id}", 260 | "size": 34, 261 | "ssh_url": "git@github.com:ridedott/test-workflows.git", 262 | "stargazers_count": 0, 263 | "stargazers_url": "https://api.github.com/repos/ridedott/test-workflows/stargazers", 264 | "statuses_url": "https://api.github.com/repos/ridedott/test-workflows/statuses/{sha}", 265 | "subscribers_url": "https://api.github.com/repos/ridedott/test-workflows/subscribers", 266 | "subscription_url": "https://api.github.com/repos/ridedott/test-workflows/subscription", 267 | "svn_url": "https://github.com/ridedott/test-workflows", 268 | "tags_url": "https://api.github.com/repos/ridedott/test-workflows/tags", 269 | "teams_url": "https://api.github.com/repos/ridedott/test-workflows/teams", 270 | "trees_url": "https://api.github.com/repos/ridedott/test-workflows/git/trees{/sha}", 271 | "updated_at": "2019-10-10T12:30:17Z", 272 | "url": "https://api.github.com/repos/ridedott/test-workflows", 273 | "watchers": 0, 274 | "watchers_count": 0 275 | }, 276 | "sha": "9d00ff713d0987cc1f4d3d5c00e6fa8ff4066e1f", 277 | "user": { 278 | "avatar_url": "https://avatars1.githubusercontent.com/u/45282822?v=4", 279 | "events_url": "https://api.github.com/users/ridedott/events{/privacy}", 280 | "followers_url": "https://api.github.com/users/ridedott/followers", 281 | "following_url": "https://api.github.com/users/ridedott/following{/other_user}", 282 | "gists_url": "https://api.github.com/users/ridedott/gists{/gist_id}", 283 | "gravatar_id": "", 284 | "html_url": "https://github.com/ridedott", 285 | "id": 45282822, 286 | "login": "ridedott", 287 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ1MjgyODIy", 288 | "organizations_url": "https://api.github.com/users/ridedott/orgs", 289 | "received_events_url": "https://api.github.com/users/ridedott/received_events", 290 | "repos_url": "https://api.github.com/users/ridedott/repos", 291 | "site_admin": false, 292 | "starred_url": "https://api.github.com/users/ridedott/starred{/owner}{/repo}", 293 | "subscriptions_url": "https://api.github.com/users/ridedott/subscriptions", 294 | "type": "Organization", 295 | "url": "https://api.github.com/users/ridedott" 296 | } 297 | }, 298 | "html_url": "https://github.com/ridedott/test-workflows/pull/7", 299 | "id": 326723716, 300 | "issue_url": "https://api.github.com/repos/ridedott/test-workflows/issues/7", 301 | "labels": [], 302 | "locked": false, 303 | "maintainer_can_modify": false, 304 | "merge_commit_sha": null, 305 | "mergeable": null, 306 | "mergeable_state": "unknown", 307 | "merged": false, 308 | "merged_at": null, 309 | "merged_by": null, 310 | "milestone": null, 311 | "node_id": "MDExOlB1bGxSZXF1ZXN0MzE3MDI5MjU4", 312 | "number": 7, 313 | "patch_url": "https://github.com/ridedott/test-workflows/pull/7.patch", 314 | "rebaseable": null, 315 | "requested_reviewers": [], 316 | "requested_teams": [], 317 | "review_comment_url": "https://api.github.com/repos/ridedott/test-workflows/pulls/comments{/number}", 318 | "review_comments": 0, 319 | "review_comments_url": "https://api.github.com/repos/ridedott/test-workflows/pulls/7/comments", 320 | "state": "open", 321 | "statuses_url": "https://api.github.com/repos/ridedott/test-workflows/statuses/9d00ff713d0987cc1f4d3d5c00e6fa8ff4066e1f", 322 | "title": "Update test", 323 | "updated_at": "2019-10-10T13:00:44Z", 324 | "url": "https://api.github.com/repos/ridedott/test-workflows/pulls/7", 325 | "user": { 326 | "avatar_url": "https://avatars1.githubusercontent.com/u/9158996?v=4", 327 | "events_url": "https://api.github.com/users/dependabot[bot]/events{/privacy}", 328 | "followers_url": "https://api.github.com/users/dependabot[bot]/followers", 329 | "following_url": "https://api.github.com/users/dependabot[bot]/following{/other_user}", 330 | "gists_url": "https://api.github.com/users/dependabot[bot]/gists{/gist_id}", 331 | "gravatar_id": "", 332 | "html_url": "https://github.com/dependabot[bot]", 333 | "id": 9158996, 334 | "login": "dependabot[bot]", 335 | "node_id": "MDQ6VXNlcjkxNTg5OTY=", 336 | "organizations_url": "https://api.github.com/users/dependabot[bot]/orgs", 337 | "received_events_url": "https://api.github.com/users/dependabot[bot]/received_events", 338 | "repos_url": "https://api.github.com/users/dependabot[bot]/repos", 339 | "site_admin": false, 340 | "starred_url": "https://api.github.com/users/dependabot[bot]/starred{/owner}{/repo}", 341 | "subscriptions_url": "https://api.github.com/users/dependabot[bot]/subscriptions", 342 | "type": "User", 343 | "url": "https://api.github.com/users/dependabot[bot]" 344 | } 345 | }, 346 | "repository": { 347 | "archive_url": "https://api.github.com/repos/ridedott/test-workflows/{archive_format}{/ref}", 348 | "archived": false, 349 | "assignees_url": "https://api.github.com/repos/ridedott/test-workflows/assignees{/user}", 350 | "blobs_url": "https://api.github.com/repos/ridedott/test-workflows/git/blobs{/sha}", 351 | "branches_url": "https://api.github.com/repos/ridedott/test-workflows/branches{/branch}", 352 | "clone_url": "https://github.com/ridedott/test-workflows.git", 353 | "collaborators_url": "https://api.github.com/repos/ridedott/test-workflows/collaborators{/collaborator}", 354 | "comments_url": "https://api.github.com/repos/ridedott/test-workflows/comments{/number}", 355 | "commits_url": "https://api.github.com/repos/ridedott/test-workflows/commits{/sha}", 356 | "compare_url": "https://api.github.com/repos/ridedott/test-workflows/compare/{base}...{head}", 357 | "contents_url": "https://api.github.com/repos/ridedott/test-workflows/contents/{+path}", 358 | "contributors_url": "https://api.github.com/repos/ridedott/test-workflows/contributors", 359 | "created_at": "2019-10-02T14:18:49Z", 360 | "default_branch": "master", 361 | "deployments_url": "https://api.github.com/repos/ridedott/test-workflows/deployments", 362 | "description": null, 363 | "disabled": false, 364 | "downloads_url": "https://api.github.com/repos/ridedott/test-workflows/downloads", 365 | "events_url": "https://api.github.com/repos/ridedott/test-workflows/events", 366 | "fork": false, 367 | "forks": 0, 368 | "forks_count": 0, 369 | "forks_url": "https://api.github.com/repos/ridedott/test-workflows/forks", 370 | "full_name": "ridedott/test-workflows", 371 | "git_commits_url": "https://api.github.com/repos/ridedott/test-workflows/git/commits{/sha}", 372 | "git_refs_url": "https://api.github.com/repos/ridedott/test-workflows/git/refs{/sha}", 373 | "git_tags_url": "https://api.github.com/repos/ridedott/test-workflows/git/tags{/sha}", 374 | "git_url": "git://github.com/ridedott/test-workflows.git", 375 | "has_downloads": true, 376 | "has_issues": true, 377 | "has_pages": false, 378 | "has_projects": false, 379 | "has_wiki": true, 380 | "homepage": null, 381 | "hooks_url": "https://api.github.com/repos/ridedott/test-workflows/hooks", 382 | "html_url": "https://github.com/ridedott/test-workflows", 383 | "id": 212360168, 384 | "issue_comment_url": "https://api.github.com/repos/ridedott/test-workflows/issues/comments{/number}", 385 | "issue_events_url": "https://api.github.com/repos/ridedott/test-workflows/issues/events{/number}", 386 | "issues_url": "https://api.github.com/repos/ridedott/test-workflows/issues{/number}", 387 | "keys_url": "https://api.github.com/repos/ridedott/test-workflows/keys{/key_id}", 388 | "labels_url": "https://api.github.com/repos/ridedott/test-workflows/labels{/name}", 389 | "language": null, 390 | "languages_url": "https://api.github.com/repos/ridedott/test-workflows/languages", 391 | "license": null, 392 | "merges_url": "https://api.github.com/repos/ridedott/test-workflows/merges", 393 | "milestones_url": "https://api.github.com/repos/ridedott/test-workflows/milestones{/number}", 394 | "mirror_url": null, 395 | "name": "test-workflows", 396 | "node_id": "MDEwOlJlcG9zaXRvcnkyMTIzNjAxNjg=", 397 | "notifications_url": "https://api.github.com/repos/ridedott/test-workflows/notifications{?since,all,participating}", 398 | "open_issues": 2, 399 | "open_issues_count": 2, 400 | "owner": { 401 | "avatar_url": "https://avatars1.githubusercontent.com/u/45282822?v=4", 402 | "events_url": "https://api.github.com/users/ridedott/events{/privacy}", 403 | "followers_url": "https://api.github.com/users/ridedott/followers", 404 | "following_url": "https://api.github.com/users/ridedott/following{/other_user}", 405 | "gists_url": "https://api.github.com/users/ridedott/gists{/gist_id}", 406 | "gravatar_id": "", 407 | "html_url": "https://github.com/ridedott", 408 | "id": 45282822, 409 | "login": "ridedott", 410 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjQ1MjgyODIy", 411 | "organizations_url": "https://api.github.com/users/ridedott/orgs", 412 | "received_events_url": "https://api.github.com/users/ridedott/received_events", 413 | "repos_url": "https://api.github.com/users/ridedott/repos", 414 | "site_admin": false, 415 | "starred_url": "https://api.github.com/users/ridedott/starred{/owner}{/repo}", 416 | "subscriptions_url": "https://api.github.com/users/ridedott/subscriptions", 417 | "type": "Organization", 418 | "url": "https://api.github.com/users/ridedott" 419 | }, 420 | "private": true, 421 | "pulls_url": "https://api.github.com/repos/ridedott/test-workflows/pulls{/number}", 422 | "pushed_at": "2019-10-10T13:00:26Z", 423 | "releases_url": "https://api.github.com/repos/ridedott/test-workflows/releases{/id}", 424 | "size": 34, 425 | "ssh_url": "git@github.com:ridedott/test-workflows.git", 426 | "stargazers_count": 0, 427 | "stargazers_url": "https://api.github.com/repos/ridedott/test-workflows/stargazers", 428 | "statuses_url": "https://api.github.com/repos/ridedott/test-workflows/statuses/{sha}", 429 | "subscribers_url": "https://api.github.com/repos/ridedott/test-workflows/subscribers", 430 | "subscription_url": "https://api.github.com/repos/ridedott/test-workflows/subscription", 431 | "svn_url": "https://github.com/ridedott/test-workflows", 432 | "tags_url": "https://api.github.com/repos/ridedott/test-workflows/tags", 433 | "teams_url": "https://api.github.com/repos/ridedott/test-workflows/teams", 434 | "trees_url": "https://api.github.com/repos/ridedott/test-workflows/git/trees{/sha}", 435 | "updated_at": "2019-10-10T12:30:17Z", 436 | "url": "https://api.github.com/repos/ridedott/test-workflows", 437 | "watchers": 0, 438 | "watchers_count": 0 439 | }, 440 | "sender": { 441 | "avatar_url": "https://avatars1.githubusercontent.com/u/9158996?v=4", 442 | "events_url": "https://api.github.com/users/dependabot[bot]/events{/privacy}", 443 | "followers_url": "https://api.github.com/users/dependabot[bot]/followers", 444 | "following_url": "https://api.github.com/users/dependabot[bot]/following{/other_user}", 445 | "gists_url": "https://api.github.com/users/dependabot[bot]/gists{/gist_id}", 446 | "gravatar_id": "", 447 | "html_url": "https://github.com/dependabot[bot]", 448 | "id": 9158996, 449 | "login": "dependabot[bot]", 450 | "node_id": "MDQ6VXNlcjkxNTg5OTY=", 451 | "organizations_url": "https://api.github.com/users/dependabot[bot]/orgs", 452 | "received_events_url": "https://api.github.com/users/dependabot[bot]/received_events", 453 | "repos_url": "https://api.github.com/users/dependabot[bot]/repos", 454 | "site_admin": false, 455 | "starred_url": "https://api.github.com/users/dependabot[bot]/starred{/owner}{/repo}", 456 | "subscriptions_url": "https://api.github.com/users/dependabot[bot]/subscriptions", 457 | "type": "User", 458 | "url": "https://api.github.com/users/dependabot[bot]" 459 | } 460 | } 461 | --------------------------------------------------------------------------------