├── .nvmrc ├── .husky ├── .gitignore └── pre-commit ├── Aptfile ├── design └── logo.png ├── config.yml ├── .github ├── CODEOWNERS └── workflows │ ├── semantic.yml │ └── test.yml ├── typings └── ambient.d.ts ├── .prettierrc ├── .gitignore ├── vitest.config.ts ├── src ├── utils │ ├── prom.ts │ ├── log-util.ts │ ├── token-util.ts │ ├── env-util.ts │ ├── label-utils.ts │ ├── branch-util.ts │ └── checks-util.ts ├── enums.ts ├── types.ts ├── interfaces.ts ├── operations │ ├── setup-remotes.ts │ ├── backport-to-location.ts │ ├── init-repo.ts │ ├── update-manual-backport.ts │ └── backport-commits.ts ├── constants.ts ├── Queue.ts ├── index.ts └── utils.ts ├── .yarnrc.yml ├── .eslintrc.json ├── Dockerfile ├── docs ├── .example.env ├── manual-backports.md ├── local-setup.md └── usage.md ├── app.json ├── spec ├── commands.spec.ts ├── fixtures │ ├── pull_request.closed.json │ ├── pull_request.opened.json │ ├── pull_request.labeled.json │ ├── pull_request.unlabeled.json │ ├── backport_pull_request.opened.json │ ├── backport_pull_request.labeled.json │ ├── backport_pull_request.unlabeled.json │ ├── backport_pull_request.closed.bot.json │ ├── backport_pull_request.closed.json │ ├── backport_pull_request.merged.bot.json │ ├── backport_pull_request.merged.json │ ├── issue_comment_backport.created.json │ ├── issue_comment_backport_to.created.json │ ├── issue_comment_backport_to_multiple.created.json │ └── issue_comment_backport_to_multiple_spaces.created.json ├── branch-util.spec.ts ├── utils.spec.ts ├── queue.spec.ts ├── operations.spec.ts └── index.spec.ts ├── tsconfig.json ├── README.md ├── LICENSE ├── package.json └── CONTRIBUTING.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /Aptfile: -------------------------------------------------------------------------------- 1 | # Heroku-24 stack doesn't include git by default. 2 | git-all -------------------------------------------------------------------------------- /design/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/electron/trop/HEAD/design/logo.png -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | tropName: trop 2 | tropEmail: 37223003+trop[bot]@users.noreply.github.com 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Fall back to authors for all PR review 2 | * @electron/wg-releases 3 | -------------------------------------------------------------------------------- /typings/ambient.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'promise-events' { 2 | export class EventEmitter {} 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "endOfLine": "lf" 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | lib 3 | node_modules 4 | working 5 | *.pem 6 | package-lock.json 7 | yarn-error.log 8 | .DS_Store 9 | .envrc 10 | .yarn/install-state.gz 11 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | test: { 6 | clearMocks: true, 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /src/utils/prom.ts: -------------------------------------------------------------------------------- 1 | import * as pClient from 'prom-client'; 2 | 3 | export const client = pClient; 4 | export const register = new client.Registry(); 5 | 6 | register.setDefaultLabels({ 7 | app: 'trop', 8 | }); 9 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableScripts: false 2 | 3 | nodeLinker: node-modules 4 | 5 | npmMinimalAgeGate: 10080 6 | 7 | npmPreapprovedPackages: 8 | - "@electron/*" 9 | 10 | yarnPath: .yarn/releases/yarn-4.10.3.cjs 11 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2021": true 4 | }, 5 | "extends": "standard-with-typescript", 6 | "parserOptions": { 7 | "ecmaVersion": "latest", 8 | "sourceType": "module" 9 | }, 10 | "rules": {} 11 | } 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.9.4-slim 2 | 3 | WORKDIR /app 4 | COPY . /app 5 | 6 | RUN yarn && yarn build 7 | 8 | CMD ["node", "/app/node_modules/probot/bin/probot-run.js", "/app/lib/index.js", "--private-key=private.pem"] 9 | 10 | EXPOSE 3000 11 | -------------------------------------------------------------------------------- /docs/.example.env: -------------------------------------------------------------------------------- 1 | `# The ID of your GitHub App 2 | APP_ID=YOUR_APP_ID 3 | WEBHOOK_SECRET=development 4 | 5 | # Uncomment this to get verbose logging; use `info` to show less 6 | #LOG_LEVEL=trace 7 | 8 | # Go to https://smee.io/new set this to the URL that you are redirected to. 9 | WEBHOOK_PROXY_URL=https://smee.io/ 10 | ` -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trop", 3 | "description": "a bot that does backports for Electron", 4 | "scripts": { 5 | }, 6 | "env": { 7 | "APP_ID": { 8 | "required": true 9 | }, 10 | "PRIVATE_KEY": { 11 | "required": true 12 | } 13 | }, 14 | "formation": {}, 15 | "addons": [], 16 | "buildpacks": [ 17 | { 18 | "url": "heroku-community/apt" 19 | }, 20 | { 21 | "url": "heroku/nodejs" 22 | } 23 | ], 24 | "stack": "heroku-24" 25 | } 26 | -------------------------------------------------------------------------------- /spec/commands.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import * as commands from '../src/constants'; 4 | 5 | describe('commands', () => { 6 | it('should all be unique', () => { 7 | const commandsRecord: Record = commands as any; 8 | const allCommands = Object.keys(commandsRecord) 9 | .map((key) => commandsRecord[key]) 10 | .sort(); 11 | const uniqueCommands = Array.from(new Set(allCommands)).sort(); 12 | expect(allCommands).toStrictEqual(uniqueCommands); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2020", 5 | "outDir": "lib", 6 | "lib": [ 7 | "ES2022", 8 | "ESNext", 9 | "dom", 10 | ], 11 | "sourceMap": true, 12 | "rootDir": "src", 13 | "experimentalDecorators": true, 14 | "allowJs": true, 15 | "strict": true, 16 | "allowSyntheticDefaultImports": true, 17 | "strictNullChecks": true 18 | }, 19 | "exclude": [ 20 | "node_modules", 21 | "spec", 22 | "lib", 23 | "working", 24 | "vitest.config.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/enums.ts: -------------------------------------------------------------------------------- 1 | export enum PRChange { 2 | OPEN, 3 | MERGE, 4 | CLOSE, 5 | } 6 | 7 | export enum LogLevel { 8 | LOG, 9 | INFO, 10 | WARN, 11 | ERROR, 12 | } 13 | 14 | export enum BackportPurpose { 15 | ExecuteBackport, 16 | Check, 17 | } 18 | 19 | // trop comment labeling prefixes 20 | export enum PRStatus { 21 | TARGET = 'target/', 22 | MERGED = 'merged/', 23 | IN_FLIGHT = 'in-flight/', 24 | NEEDS_MANUAL = 'needs-manual-bp/', 25 | } 26 | 27 | export enum CheckRunStatus { 28 | NEUTRAL = 'neutral', 29 | FAILURE = 'failure', 30 | SUCCESS = 'success', 31 | } 32 | -------------------------------------------------------------------------------- /spec/fixtures/pull_request.closed.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pull_request", 3 | "payload": { 4 | "action": "closed", 5 | "number": 7, 6 | "pull_request": { 7 | "url": "my_cool_url", 8 | "number": 7, 9 | "title": "CHANGE README", 10 | "merged": true, 11 | "user": { 12 | "login": "codebytere" 13 | }, 14 | "body": "Backport of #12345", 15 | "labels": [{ 16 | "name": "todo", 17 | "color": "8cb728" 18 | }] 19 | }, 20 | "label": { 21 | "name": "todo", 22 | "color": "8cb728" 23 | }, 24 | "repository": { 25 | "name": "probot-test", 26 | "owner": { 27 | "login": "codebytere" 28 | } 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /.github/workflows/semantic.yml: -------------------------------------------------------------------------------- 1 | name: "Check Semantic Commit" 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | main: 15 | permissions: 16 | pull-requests: read # for amannn/action-semantic-pull-request to analyze PRs 17 | statuses: write # for amannn/action-semantic-pull-request to mark status of analyzed PR 18 | name: Validate PR Title 19 | runs-on: ubuntu-latest 20 | steps: 21 | - name: semantic-pull-request 22 | uses: amannn/action-semantic-pull-request@cfb60706e18bc85e8aec535e3c577abe8f70378e # v5.5.2 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | with: 26 | validateSingleCommit: false 27 | -------------------------------------------------------------------------------- /src/utils/log-util.ts: -------------------------------------------------------------------------------- 1 | import { LogLevel } from '../enums'; 2 | 3 | /** 4 | * Logs information about different actions taking place to console. 5 | * 6 | * @param functionName - the name of the function where the logging is happening 7 | * @param logLevel - the severity level of the log 8 | * @param message - the message to write to console 9 | */ 10 | export const log = ( 11 | functionName: string, 12 | logLevel: LogLevel, 13 | ...message: unknown[] 14 | ) => { 15 | const output = `${functionName}: ${message}`; 16 | 17 | if (logLevel === LogLevel.INFO) { 18 | console.info(output); 19 | } else if (logLevel === LogLevel.WARN) { 20 | console.warn(output); 21 | } else if (logLevel === LogLevel.ERROR) { 22 | console.error(output); 23 | } else { 24 | console.log(output); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: '0 19 * * 1-5' 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | test: 19 | name: Test 20 | runs-on: ubuntu-latest 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 24 | - name: Setup Node.js 25 | uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 26 | with: 27 | node-version-file: '.nvmrc' 28 | cache: 'yarn' 29 | - name: Install dependencies 30 | run: yarn install --immutable 31 | - name: Lint 32 | run: yarn lint 33 | - name: Test 34 | run: yarn test 35 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Context } from 'probot'; 2 | 3 | export type WebHookPRContext = Context< 4 | | 'pull_request.opened' 5 | | 'pull_request.closed' 6 | | 'pull_request.reopened' 7 | | 'pull_request.edited' 8 | | 'pull_request.synchronize' 9 | | 'pull_request.labeled' 10 | | 'pull_request.unlabeled' 11 | >; 12 | 13 | export type WebHookIssueContext = Context<'issue_comment.created'>; 14 | 15 | export type SimpleWebHookRepoContext = Pick< 16 | WebHookRepoContext, 17 | 'octokit' | 'repo' | 'payload' 18 | >; 19 | 20 | export type WebHookRepoContext = Omit & { 21 | payload: Omit< 22 | WebHookPRContext['payload'], 23 | 'pull_request' | 'number' | 'action' 24 | >; 25 | }; 26 | 27 | export type WebHookPR = WebHookPRContext['payload']['pull_request']; 28 | 29 | export type WebHookIssueComment = WebHookIssueContext['payload']['comment']; 30 | -------------------------------------------------------------------------------- /src/utils/token-util.ts: -------------------------------------------------------------------------------- 1 | import { Probot } from 'probot'; 2 | import { log } from './log-util'; 3 | import { LogLevel } from '../enums'; 4 | import { SimpleWebHookRepoContext } from '../types'; 5 | 6 | /** 7 | * Creates and returns an installation token for a GitHub App. 8 | * 9 | * @param robot - an instance of Probot 10 | * @param context - the context of the event that was triggered 11 | * @returns a string representing a GitHub App installation token 12 | */ 13 | export const getRepoToken = async ( 14 | robot: Probot, 15 | context: SimpleWebHookRepoContext, 16 | ): Promise => { 17 | log('getRepoToken', LogLevel.INFO, 'Creating GitHub App token'); 18 | 19 | const hub = await robot.auth(); 20 | const response = await hub.apps.createInstallationAccessToken({ 21 | installation_id: context.payload.installation!.id, 22 | }); 23 | return response.data.token; 24 | }; 25 | -------------------------------------------------------------------------------- /spec/fixtures/pull_request.opened.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pull_request", 3 | "payload": { 4 | "action": "opened", 5 | "number": 7, 6 | "pull_request": { 7 | "url": "my_cool_url", 8 | "number": 7, 9 | "title": "CHANGE README", 10 | "merged": true, 11 | "user": { 12 | "login": "codebytere" 13 | }, 14 | "body": "New cool stuff", 15 | "labels": [], 16 | "head": { 17 | "sha": "ABC" 18 | }, 19 | "base": { 20 | "ref": "main", 21 | "repo": { 22 | "default_branch": "main" 23 | } 24 | } 25 | }, 26 | "label": { 27 | "name": "todo", 28 | "color": "8cb728" 29 | }, 30 | "repository": { 31 | "name": "probot-test", 32 | "owner": { 33 | "login": "codebytere" 34 | } 35 | }, 36 | "installation": { 37 | "id": 103619 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /spec/fixtures/pull_request.labeled.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pull_request", 3 | "payload": { 4 | "action": "labeled", 5 | "number": 7, 6 | "pull_request": { 7 | "url": "my_cool_url", 8 | "number": 7, 9 | "title": "CHANGE README", 10 | "merged": true, 11 | "user": { 12 | "login": "codebytere" 13 | }, 14 | "body": "New cool stuff", 15 | "labels": [{ 16 | "name": "todo", 17 | "color": "8cb728" 18 | }], 19 | "head": { 20 | "sha": "ABC" 21 | }, 22 | "base": { 23 | "ref": "main", 24 | "repo": { 25 | "default_branch": "main" 26 | } 27 | } 28 | }, 29 | "label": { 30 | "name": "todo", 31 | "color": "8cb728" 32 | }, 33 | "repository": { 34 | "name": "probot-test", 35 | "owner": { 36 | "login": "codebytere" 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /spec/fixtures/pull_request.unlabeled.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pull_request", 3 | "payload": { 4 | "action": "unlabeled", 5 | "number": 7, 6 | "pull_request": { 7 | "url": "my_cool_url", 8 | "number": 7, 9 | "title": "CHANGE README", 10 | "merged": true, 11 | "user": { 12 | "login": "codebytere" 13 | }, 14 | "body": "New cool stuff", 15 | "labels": [{ 16 | "name": "todo", 17 | "color": "8cb728" 18 | }], 19 | "head": { 20 | "sha": "ABC" 21 | }, 22 | "base": { 23 | "ref": "main", 24 | "repo": { 25 | "default_branch": "main" 26 | } 27 | } 28 | }, 29 | "label": { 30 | "name": "todo", 31 | "color": "8cb728" 32 | }, 33 | "repository": { 34 | "name": "probot-test", 35 | "owner": { 36 | "login": "codebytere" 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/env-util.ts: -------------------------------------------------------------------------------- 1 | import { log } from './log-util'; 2 | import { LogLevel } from '../enums'; 3 | 4 | /** 5 | * Checks that a given environment variable exists, and returns 6 | * its value if it does. Conditionally throws an error on failure. 7 | * 8 | * @param envVar - the environment variable to retrieve 9 | * @param defaultValue - default value to use if no environment var is found 10 | * @returns the value of the env var being checked, or the default value if one is passed 11 | */ 12 | export function getEnvVar(envVar: string, defaultValue?: string): string { 13 | log('getEnvVar', LogLevel.INFO, `Fetching env var '${envVar}'`); 14 | 15 | const value = process.env[envVar] || defaultValue; 16 | if (!value && value !== '') { 17 | log( 18 | 'getEnvVar', 19 | LogLevel.ERROR, 20 | `Missing environment variable '${envVar}'`, 21 | ); 22 | throw new Error(`Missing environment variable '${envVar}'`); 23 | } 24 | return value; 25 | } 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # trop 2 | 3 | [![Test](https://github.com/electron/trop/actions/workflows/test.yml/badge.svg)](https://github.com/electron/trop/actions/workflows/test.yml) 4 | 5 | trop-logo 6 | 7 | Trop is a GitHub App built with [probot](https://github.com/probot/probot) that automates the process of backporting features and bugfixes. 8 | 9 | ```js 10 | [...'backport'.slice(4)].reverse().join`` 11 | // => trop 12 | ``` 13 | 14 | ## Setup 15 | 16 | ```sh 17 | # Clone the trop repository locally 18 | git clone https://github.com/electron/trop.git 19 | 20 | # Change directory to where trop has been cloned 21 | cd trop 22 | 23 | # Install dependencies 24 | npm install 25 | 26 | # Run the bot 27 | npm start 28 | ``` 29 | 30 | ## Documentation 31 | 32 | To learn how to use `trop`, see [usage](docs/usage.md). 33 | 34 | For information on setting up a local version of this GitHub App, see [local-setup](docs/local-setup.md). 35 | -------------------------------------------------------------------------------- /spec/fixtures/backport_pull_request.opened.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pull_request", 3 | "payload": { 4 | "action": "opened", 5 | "number": 7, 6 | "pull_request": { 7 | "number": 7, 8 | "url": "my_cool_url", 9 | "title": "CHANGE README", 10 | "merged": true, 11 | "user": { 12 | "login": "codebytere" 13 | }, 14 | "body": "Backport of #12345", 15 | "labels": [{ 16 | "name": "todo", 17 | "color": "8cb728" 18 | }], 19 | "head": { 20 | "sha": "ABC" 21 | }, 22 | "base": { 23 | "ref": "36-x-y", 24 | "repo": { 25 | "default_branch": "main" 26 | } 27 | } 28 | }, 29 | "label": { 30 | "name": "todo", 31 | "color": "8cb728" 32 | }, 33 | "repository": { 34 | "name": "probot-test", 35 | "owner": { 36 | "login": "codebytere" 37 | } 38 | }, 39 | "installation": { 40 | "id": 103619 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { BackportPurpose } from './enums'; 2 | import { 3 | SimpleWebHookRepoContext, 4 | WebHookPR, 5 | WebHookRepoContext, 6 | } from './types'; 7 | 8 | export interface RemotesOptions { 9 | dir: string; 10 | remotes: { 11 | name: string; 12 | value: string; 13 | }[]; 14 | } 15 | 16 | export interface InitRepoOptions { 17 | slug: string; 18 | accessToken: string; 19 | } 20 | 21 | export interface BackportOptions { 22 | dir: string; 23 | slug: string; 24 | targetRemote: string; 25 | targetBranch: string; 26 | tempBranch: string; 27 | patches: string[]; 28 | shouldPush: boolean; 29 | github: WebHookRepoContext['octokit']; 30 | context: SimpleWebHookRepoContext; 31 | } 32 | 33 | export interface TryBackportOptions { 34 | context: SimpleWebHookRepoContext; 35 | repoAccessToken: string; 36 | purpose: BackportPurpose; 37 | pr: WebHookPR; 38 | dir: string; 39 | slug: string; 40 | targetBranch: string; 41 | tempBranch: string; 42 | } 43 | -------------------------------------------------------------------------------- /spec/fixtures/backport_pull_request.labeled.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pull_request", 3 | "payload": { 4 | "action": "labeled", 5 | "number": 7, 6 | "pull_request": { 7 | "number": 7, 8 | "url": "my_cool_url", 9 | "title": "CHANGE README", 10 | "merged": true, 11 | "user": { 12 | "login": "codebytere" 13 | }, 14 | "body": "Backport of #12345", 15 | "labels": [{ 16 | "name": "todo", 17 | "color": "8cb728" 18 | }], 19 | "head": { 20 | "sha": "ABC" 21 | }, 22 | "base": { 23 | "ref": "36-x-y", 24 | "repo": { 25 | "default_branch": "main" 26 | } 27 | } 28 | }, 29 | "label": { 30 | "name": "todo", 31 | "color": "8cb728" 32 | }, 33 | "repository": { 34 | "name": "probot-test", 35 | "owner": { 36 | "login": "codebytere" 37 | } 38 | }, 39 | "installation": { 40 | "id": 103619 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /spec/fixtures/backport_pull_request.unlabeled.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pull_request", 3 | "payload": { 4 | "action": "unlabeled", 5 | "number": 7, 6 | "pull_request": { 7 | "number": 7, 8 | "url": "my_cool_url", 9 | "title": "CHANGE README", 10 | "merged": true, 11 | "user": { 12 | "login": "codebytere" 13 | }, 14 | "body": "Backport of #12345", 15 | "labels": [{ 16 | "name": "todo", 17 | "color": "8cb728" 18 | }], 19 | "head": { 20 | "sha": "ABC" 21 | }, 22 | "base": { 23 | "ref": "36-x-y", 24 | "repo": { 25 | "default_branch": "main" 26 | } 27 | } 28 | }, 29 | "label": { 30 | "name": "todo", 31 | "color": "8cb728" 32 | }, 33 | "repository": { 34 | "name": "probot-test", 35 | "owner": { 36 | "login": "codebytere" 37 | } 38 | }, 39 | "installation": { 40 | "id": 103619 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/operations/setup-remotes.ts: -------------------------------------------------------------------------------- 1 | import simpleGit from 'simple-git'; 2 | import { RemotesOptions } from '../interfaces'; 3 | import { log } from '../utils/log-util'; 4 | import { LogLevel } from '../enums'; 5 | 6 | /** 7 | * Sets up remotes that trop will run backports with. 8 | * 9 | * @param options - an object containing: 10 | * 1) dir - the repo directory 11 | * 2) remotes - the list of remotes to set on the initialized git repository 12 | * @returns an object containing the repo initialization directory 13 | */ 14 | export const setupRemotes = async (options: RemotesOptions) => { 15 | log('setupRemotes', LogLevel.INFO, 'Setting up git remotes'); 16 | 17 | const git = simpleGit(options.dir); 18 | 19 | // Add git remotes. 20 | for (const remote of options.remotes) { 21 | await git.addRemote(remote.name, remote.value); 22 | } 23 | 24 | // Fetch git remotes. 25 | for (const remote of options.remotes) { 26 | await git.raw(['fetch', remote.name]); 27 | } 28 | return { dir: options.dir }; 29 | }; 30 | -------------------------------------------------------------------------------- /spec/fixtures/backport_pull_request.closed.bot.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pull_request", 3 | "payload": { 4 | "action": "closed", 5 | "number": 15, 6 | "pull_request": { 7 | "number": 15, 8 | "state": "closed", 9 | "title": "mirror", 10 | "user": { 11 | "login": "trop[bot]" 12 | }, 13 | "head": { 14 | "ref": "123456789iuytdxcvbnjhfdriuyfedfgy54escghjnbg" 15 | }, 16 | "base": { 17 | "ref": "36-x-y", 18 | "repo": { 19 | "default_branch": "main" 20 | } 21 | }, 22 | "body": "Backport of #14\nSee that PR for details.\nNotes: ", 23 | "created_at": "2018-11-01T17:29:51Z", 24 | "merged_at": "2018-11-01T17:30:11Z", 25 | "labels": [ 26 | { 27 | "name": "5-0-x", 28 | "color": "ededed" 29 | }, 30 | { 31 | "name": "backport", 32 | "color": "ededed" 33 | } 34 | ], 35 | "merged": false 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /spec/fixtures/backport_pull_request.closed.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pull_request", 3 | "payload": { 4 | "action": "closed", 5 | "number": 15, 6 | "pull_request": { 7 | "number": 15, 8 | "state": "closed", 9 | "title": "mirror", 10 | "user": { 11 | "login": "codebytere" 12 | }, 13 | "head": { 14 | "ref": "123456789iuytdxcvbnjhfdriuyfedfgy54escghjnbg" 15 | }, 16 | "base": { 17 | "ref": "36-x-y", 18 | "repo": { 19 | "default_branch": "main" 20 | } 21 | }, 22 | "body": "Backport of #14\nSee that PR for details.\nNotes: ", 23 | "created_at": "2018-11-01T17:29:51Z", 24 | "merged_at": "2018-11-01T17:30:11Z", 25 | "labels": [ 26 | { 27 | "name": "4-0-x", 28 | "color": "ededed" 29 | }, 30 | { 31 | "name": "backport", 32 | "color": "ededed" 33 | } 34 | ], 35 | "merged": false 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /spec/fixtures/backport_pull_request.merged.bot.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pull_request", 3 | "payload": { 4 | "action": "closed", 5 | "number": 15, 6 | "pull_request": { 7 | "number": 15, 8 | "state": "closed", 9 | "title": "mirror", 10 | "user": { 11 | "login": "trop[bot]" 12 | }, 13 | "head": { 14 | "ref": "123456789iuytdxcvbnjhfdriuyfedfgy54escghjnbg" 15 | }, 16 | "base": { 17 | "ref": "36-x-y", 18 | "repo": { 19 | "default_branch": "main" 20 | } 21 | }, 22 | "body": "Backport of #14\nSee that PR for details.\nNotes: ", 23 | "created_at": "2018-11-01T17:29:51Z", 24 | "merged_at": "2018-11-01T17:30:11Z", 25 | "labels": [ 26 | { 27 | "name": "4-0-x", 28 | "color": "ededed" 29 | }, 30 | { 31 | "name": "backport", 32 | "color": "ededed" 33 | } 34 | ], 35 | "merged": true 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /spec/fixtures/backport_pull_request.merged.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pull_request", 3 | "payload": { 4 | "action": "closed", 5 | "number": 15, 6 | "pull_request": { 7 | "number": 15, 8 | "state": "closed", 9 | "title": "mirror", 10 | "user": { 11 | "login": "codebytere" 12 | }, 13 | "head": { 14 | "ref": "123456789iuytdxcvbnjhfdriuyfedfgy54escghjnbg" 15 | }, 16 | "base": { 17 | "ref": "36-x-y", 18 | "repo": { 19 | "default_branch": "main" 20 | } 21 | }, 22 | "body": "Backport of #14\nSee that PR for details.\nNotes: ", 23 | "created_at": "2018-11-01T17:29:51Z", 24 | "merged_at": "2018-11-01T17:30:11Z", 25 | "labels": [ 26 | { 27 | "name": "4-0-x", 28 | "color": "ededed" 29 | }, 30 | { 31 | "name": "backport", 32 | "color": "ededed" 33 | } 34 | ], 35 | "merged": true 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Shelley Vohr 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const CHECK_PREFIX = 'Backportable? - '; 2 | 3 | export const BACKPORT_APPROVAL_CHECK = 'Backport Approval Enforcement'; 4 | 5 | export const BACKPORT_INFORMATION_CHECK = 'Backport Labels Added'; 6 | 7 | export const NUM_SUPPORTED_VERSIONS = parseInt( 8 | process.env.NUM_SUPPORTED_VERSIONS || '3', 9 | 10, 10 | ); 11 | 12 | export const BACKPORT_LABEL = 'backport'; 13 | 14 | export const NO_BACKPORT_LABEL = 'no-backport'; 15 | 16 | export const SEMVER_PREFIX = 'semver/'; 17 | 18 | export const SEMVER_LABELS = { 19 | PATCH: 'semver/patch', 20 | MINOR: 'semver/minor', 21 | MAJOR: 'semver/major', 22 | }; 23 | 24 | export const SKIP_CHECK_LABEL = 25 | process.env.SKIP_CHECK_LABEL || 'backport-check-skip'; 26 | 27 | export const BACKPORT_APPROVED_LABEL = 28 | process.env.BACKPORT_APPROVED_LABEL || 'backport/approved ✅'; 29 | 30 | export const BACKPORT_REQUESTED_LABEL = 31 | process.env.BACKPORT_REQUESTED_LABEL || 'backport/requested 🗳'; 32 | 33 | export const DEFAULT_BACKPORT_REVIEW_TEAM = 34 | process.env.DEFAULT_BACKPORT_REVIEW_TEAM; 35 | 36 | export const VALID_BACKPORT_CHECK_NAME = 37 | process.env.BACKPORT_REQUESTED_LABEL || 'Valid Backport'; 38 | -------------------------------------------------------------------------------- /spec/fixtures/issue_comment_backport.created.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "issue_comment", 3 | "payload": { 4 | "action": "created", 5 | "issue": { 6 | "url": "https://api.github.com/repos/codebytere/public-repo/pull/1234", 7 | "html_url": "https://github.com/codebytere/public-repo/pull/1234", 8 | "number": 1234, 9 | "title": "Spelling error in the README file", 10 | "user": { 11 | "login": "codebytere" 12 | }, 13 | "labels": [ 14 | { 15 | "url": "https://api.github.com/repos/codebytere/public-repo/labels/target/X-X-X", 16 | "name": "target/X-X-X", 17 | "color": "fc2929" 18 | } 19 | ], 20 | "body": "It looks like you accidently spelled 'commit' with two 't's." 21 | }, 22 | "comment": { 23 | "url": "https://api.github.com/repos/codebytere/public-repo/pulls/comments/123456789", 24 | "html_url": "https://github.com/codebytere/public-repo/pulls/2#issuecomment-123456789", 25 | "id": 99262140, 26 | "user": { 27 | "login": "codebytere" 28 | }, 29 | "body": "/trop run backport" 30 | }, 31 | "repository": { 32 | "name": "public-repo", 33 | "full_name": "codebytere/public-repo", 34 | "owner": { 35 | "login": "codebytere" 36 | } 37 | }, 38 | "installation": { 39 | "id": 103619 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /spec/fixtures/issue_comment_backport_to.created.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "issue_comment", 3 | "payload": { 4 | "action": "created", 5 | "issue": { 6 | "url": "https://api.github.com/repos/codebytere/public-repo/pull/1234", 7 | "html_url": "https://github.com/codebytere/public-repo/pull/1234", 8 | "number": 1234, 9 | "title": "Spelling error in the README file", 10 | "user": { 11 | "login": "codebytere" 12 | }, 13 | "labels": [ 14 | { 15 | "url": "https://api.github.com/repos/codebytere/public-repo/labels/target/X-X-X", 16 | "name": "target/X-X-X", 17 | "color": "fc2929" 18 | } 19 | ], 20 | "body": "It looks like you accidently spelled 'commit' with two 't's." 21 | }, 22 | "comment": { 23 | "url": "https://api.github.com/repos/codebytere/public-repo/pulls/comments/123456789", 24 | "html_url": "https://github.com/codebytere/public-repo/pulls/2#issuecomment-123456789", 25 | "id": 99262140, 26 | "user": { 27 | "login": "codebytere" 28 | }, 29 | "body": "/trop run backport-to thingy" 30 | }, 31 | "repository": { 32 | "name": "public-repo", 33 | "full_name": "codebytere/public-repo", 34 | "owner": { 35 | "login": "codebytere" 36 | } 37 | }, 38 | "installation": { 39 | "id": 103619 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /spec/fixtures/issue_comment_backport_to_multiple.created.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "issue_comment", 3 | "payload": { 4 | "action": "created", 5 | "issue": { 6 | "url": "https://api.github.com/repos/codebytere/public-repo/pull/1234", 7 | "html_url": "https://github.com/codebytere/public-repo/pull/1234", 8 | "number": 1234, 9 | "title": "Spelling error in the README file", 10 | "user": { 11 | "login": "codebytere" 12 | }, 13 | "labels": [ 14 | { 15 | "url": "https://api.github.com/repos/codebytere/public-repo/labels/target/X-X-X", 16 | "name": "target/X-X-X", 17 | "color": "fc2929" 18 | } 19 | ], 20 | "body": "It looks like you accidently spelled 'commit' with two 't's." 21 | }, 22 | "comment": { 23 | "url": "https://api.github.com/repos/codebytere/public-repo/pulls/comments/123456789", 24 | "html_url": "https://github.com/codebytere/public-repo/pulls/2#issuecomment-123456789", 25 | "id": 99262140, 26 | "user": { 27 | "login": "codebytere" 28 | }, 29 | "body": "/trop run backport-to thingy1,thingy2" 30 | }, 31 | "repository": { 32 | "name": "public-repo", 33 | "full_name": "codebytere/public-repo", 34 | "owner": { 35 | "login": "codebytere" 36 | } 37 | }, 38 | "installation": { 39 | "id": 103619 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /spec/fixtures/issue_comment_backport_to_multiple_spaces.created.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "issue_comment", 3 | "payload": { 4 | "action": "created", 5 | "issue": { 6 | "url": "https://api.github.com/repos/codebytere/public-repo/pull/1234", 7 | "html_url": "https://github.com/codebytere/public-repo/pull/1234", 8 | "number": 1234, 9 | "title": "Spelling error in the README file", 10 | "user": { 11 | "login": "codebytere" 12 | }, 13 | "labels": [ 14 | { 15 | "url": "https://api.github.com/repos/codebytere/public-repo/labels/target/X-X-X", 16 | "name": "target/X-X-X", 17 | "color": "fc2929" 18 | } 19 | ], 20 | "body": "It looks like you accidently spelled 'commit' with two 't's." 21 | }, 22 | "comment": { 23 | "url": "https://api.github.com/repos/codebytere/public-repo/pulls/comments/123456789", 24 | "html_url": "https://github.com/codebytere/public-repo/pulls/2#issuecomment-123456789", 25 | "id": 99262140, 26 | "user": { 27 | "login": "codebytere" 28 | }, 29 | "body": "/trop run backport-to thingy1, thingy2, thingy3" 30 | }, 31 | "repository": { 32 | "name": "public-repo", 33 | "full_name": "codebytere/public-repo", 34 | "owner": { 35 | "login": "codebytere" 36 | } 37 | }, 38 | "installation": { 39 | "id": 103619 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /docs/manual-backports.md: -------------------------------------------------------------------------------- 1 | # Manual Backports 2 | 3 | When `trop` fails to backport your PR (trust us it tried its best) you need to backport the PR manually. You can do this by cherry-picking the commits in the PR yourself locally and pushing up a new branch or using a [`build-tools` command](#build-tools-backport-command). 4 | 5 | When you create PR for a manual backport, the body of the backport PR must contain: 6 | 7 | ```markdown 8 | Backport of #[Original PR Number] 9 | ``` 10 | 11 | where `Original PR Number` can be either a smart link: 12 | 13 | ```markdown 14 | Backport of #21813 15 | ``` 16 | 17 | or a full link to the original PR: 18 | 19 | ```markdown 20 | Backport of https://github.com/electron/electron/pull/21813 21 | ``` 22 | 23 | If you raise a PR to a branch that isn't `main` or a release branch without including a valid reference as above, `trop` will create a 24 | "failed" check on that PR to prevent it being merged. 25 | 26 | ## Build Tools Backport Command 27 | 28 | You can use the `e backport ` command to backport PRs. [This command](https://github.com/electron/build-tools?tab=readme-ov-file#e-backport-pr) manually backports PRs by automating the steps above. 29 | 30 | ## Skipping Backport Checks 31 | 32 | Sometimes development flows will necessitate a PR train, or several linked PRs to be merged into one another successively where none is a backport. To account for this case, `trop` allows for a label to be set on the non-backport PR: `SKIP_CHECK_LABEL`. 33 | 34 | You can set this variable as an environment variable with `process.env.SKIP_CHECK_LABEL`. If no label is set, it will default to 'backport-check-skip'. 35 | -------------------------------------------------------------------------------- /docs/local-setup.md: -------------------------------------------------------------------------------- 1 | ## Local setup 2 | 3 | ### Getting Code Locally 4 | 5 | ```sh 6 | $ git clone https://github.com/electron/trop 7 | $ cd trop 8 | $ npm install 9 | ``` 10 | 11 | ### Configuring the GitHub App 12 | 13 | To run your app in development, you will need to configure a GitHub App to deliver webhooks to your local machine. 14 | 15 | 1. Go to [smee.io](https://smee.io/) and click **Start a new channel**. 16 | 2. Create a `.env` file (example found [here](.example.env)) 17 | 2. Set `WEBHOOK_PROXY_URL` in your `.env` file to the URL that you are redirected to. 18 | 3. [Create a new GitHub App](https://github.com/settings/apps/new) 19 | - **Webhook URL:** `Use your WEBHOOK_PROXY_URL` from the previous step. 20 | - **Webhook Secret:** `development` 21 | - **Permissions:** Dependent on your use case 22 | - If you enable excess permissions during development, remember to remove them in production. 23 | 4. Download the private key as `private-key.pem` into the repository’s directory 24 | 5. Set your `APP_ID` in your `.env` file 25 | 6. Update your GitHub App’s Webhook URL to your [smee.io](https://smee.io/) URL. 26 | 7. Run `$ npm start` to start the server. 27 | 28 | ### Testing 29 | 30 | ```sh 31 | # run the test suite 32 | $ npm test 33 | ``` 34 | 35 | ### Debugging 36 | 37 | 1. Always run `$ npm install` and restart the server if package.json has changed. 38 | - To turn on verbose logging, start server by running: $ LOG_LEVEL=trace npm start 39 | 40 | 2. `robot.log('some text')` is your friend. 41 | 42 | 3. To test changes without triggering events on a real repository, see [simulating webhooks](https://probot.github.io/docs/simulating-webhooks/) 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trop", 3 | "version": "1.0.0", 4 | "description": "", 5 | "author": "Shelley Vohr ", 6 | "license": "MIT", 7 | "repository": "https://github.com/electron/trop.git", 8 | "private": true, 9 | "scripts": { 10 | "build": "tsc", 11 | "start": "probot run ./lib/index.js", 12 | "prettier:write": "prettier --write \"{src,spec}/**/*.ts\"", 13 | "lint": "prettier --check \"{src,spec}/**/*.ts\"", 14 | "test": "vitest run", 15 | "prepare": "husky" 16 | }, 17 | "dependencies": { 18 | "async-mutex": "^0.5.0", 19 | "global-agent": "^3.0.0", 20 | "node-fetch": "^2.6.7", 21 | "probot": "^12.3.3", 22 | "prom-client": "^14.2.0", 23 | "queue": "^6.0.0", 24 | "simple-git": "3.19.1", 25 | "what-the-diff": "^0.6.0", 26 | "yaml": "^2.3.1" 27 | }, 28 | "devDependencies": { 29 | "@types/node": "^22.9.0", 30 | "@types/node-fetch": "^2.6.11", 31 | "@types/pino-std-serializers": "^4.0.0", 32 | "@typescript-eslint/eslint-plugin": "^5.50.0", 33 | "eslint": "^8.0.1", 34 | "eslint-config-standard-with-typescript": "^36.0.0", 35 | "eslint-plugin-import": "^2.25.2", 36 | "eslint-plugin-n": "^15.0.0", 37 | "eslint-plugin-promise": "^6.0.0", 38 | "husky": "^9.1.6", 39 | "lint-staged": "^15.2.10", 40 | "nock": "^13.5.5", 41 | "prettier": "^3.3.3", 42 | "smee-client": "^2.0.4", 43 | "typescript": "*", 44 | "vitest": "^3.0.5" 45 | }, 46 | "lint-staged": { 47 | "{src,spec}/**/*.ts": "prettier --write **/*.ts" 48 | }, 49 | "engines": { 50 | "node": "24.x" 51 | }, 52 | "packageManager": "yarn@4.10.3+sha512.c38cafb5c7bb273f3926d04e55e1d8c9dfa7d9c3ea1f36a4868fa028b9e5f72298f0b7f401ad5eb921749eb012eb1c3bb74bf7503df3ee43fd600d14a018266f" 53 | } 54 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | [fork]: /fork 4 | [pr]: /compare 5 | [style]: https://standardjs.com/ 6 | 7 | Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great. 8 | 9 | This project adheres to the Contributor Covenant 10 | [code of conduct](https://github.com/electron/electron/blob/main/CODE_OF_CONDUCT.md). 11 | By participating, you are expected to uphold this code. 12 | 13 | ## Submitting a pull request 14 | 15 | 1. [Fork][fork] and clone the repository 16 | 1. Configure and install the dependencies: `npm install` 17 | 1. Make sure the tests pass on your machine: `npm test`, note: these tests also apply the linter, so no need to lint seperately 18 | 1. Create a new branch: `git checkout -b my-branch-name` 19 | 1. Make your change, add tests, and make sure the tests still pass 20 | 1. Push to your fork and [submit a pull request][pr] 21 | 1. Pat your self on the back and wait for your pull request to be reviewed and merged. 22 | 23 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 24 | 25 | - Follow the [style guide][style] which is using standard. Any linting errors should be shown when running `npm test` 26 | - Write and update tests. 27 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 28 | - Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 29 | 30 | Work in Progress pull request are also welcome to get feedback early on, or if there is something blocked you. 31 | 32 | ## Resources 33 | 34 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 35 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 36 | - [GitHub Help](https://help.github.com) 37 | -------------------------------------------------------------------------------- /src/Queue.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { log } from './utils/log-util'; 3 | import { LogLevel } from './enums'; 4 | 5 | export type Executor = () => Promise; 6 | export type ErrorExecutor = (err: unknown) => Promise; 7 | 8 | const DEFAULT_MAX_ACTIVE = 5; 9 | 10 | export class ExecutionQueue extends EventEmitter { 11 | public activeIdents: Set = new Set(); 12 | private queue: [string, Executor, ErrorExecutor][] = []; 13 | private active = 0; 14 | 15 | constructor(private maxActive = DEFAULT_MAX_ACTIVE) { 16 | super(); 17 | } 18 | 19 | public enterQueue = ( 20 | identifier: string, 21 | fn: Executor, 22 | errorFn: ErrorExecutor, 23 | ) => { 24 | if (this.activeIdents.has(identifier)) return; 25 | 26 | this.activeIdents.add(identifier); 27 | if (this.active >= this.maxActive) { 28 | log('enterQueue', LogLevel.INFO, `Adding ${identifier} to queue`); 29 | this.queue.push([identifier, fn, errorFn]); 30 | } else { 31 | this.run([identifier, fn, errorFn]); 32 | } 33 | }; 34 | 35 | private run = (fns: [string, Executor, ErrorExecutor]) => { 36 | this.active += 1; 37 | fns[1]() 38 | .then(() => this.runNext(fns[0])) 39 | .catch((err: unknown) => { 40 | if (!process.env.SPEC_RUNNING) { 41 | console.error(err); 42 | } 43 | fns[2](err) 44 | .catch((e) => { 45 | if (!process.env.SPEC_RUNNING) console.error(e); 46 | }) 47 | .then(() => this.runNext(fns[0])); 48 | }); 49 | }; 50 | 51 | private runNext = (lastIdent: string) => { 52 | log( 53 | 'runNext', 54 | LogLevel.INFO, 55 | `Running queue item with identifier ${lastIdent}`, 56 | ); 57 | 58 | this.activeIdents.delete(lastIdent); 59 | this.active -= 1; 60 | if (this.queue.length > 0 && this.active < this.maxActive) { 61 | this.run(this.queue.shift()!); 62 | } else { 63 | this.emit('empty'); 64 | } 65 | }; 66 | } 67 | 68 | export default new ExecutionQueue(); 69 | -------------------------------------------------------------------------------- /spec/branch-util.spec.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { BranchMatcher, getBackportPattern } from '../src/utils/branch-util'; 4 | 5 | describe('getBackportPattern', () => { 6 | it('matches backport patterns correctly', () => { 7 | const examples = [ 8 | 'Backport of https://github.com/electron/electron/pull/27514', 9 | 'Manually backport https://github.com/electron/electron/pull/27514', 10 | 'Manual backport of https://github.com/electron/electron/pull/27514', 11 | 'Manually backport #27514', 12 | 'Manually backport of #27514', 13 | 'Manual backport of #27514', 14 | 'Backport of #27514', 15 | ]; 16 | 17 | for (const example of examples) { 18 | const pattern = getBackportPattern(); 19 | expect(pattern.test(example)).toEqual(true); 20 | } 21 | }); 22 | }); 23 | 24 | describe('BranchMatcher', () => { 25 | it('matches supported branches', () => { 26 | const bm = new BranchMatcher(/^(\d+)-x-y$/, 3); 27 | expect(bm.isBranchSupported('3-x-y')).toBeTruthy(); 28 | expect(bm.isBranchSupported('192-x-y')).toBeTruthy(); 29 | expect(bm.isBranchSupported('z-x-y')).toBeFalsy(); 30 | expect(bm.isBranchSupported('foo')).toBeFalsy(); 31 | expect(bm.isBranchSupported('3-x-y-z')).toBeFalsy(); 32 | expect(bm.isBranchSupported('x3-x-y')).toBeFalsy(); 33 | expect(bm.isBranchSupported('')).toBeFalsy(); 34 | }); 35 | 36 | it('sorts and filters release branches', () => { 37 | const bm = new BranchMatcher(/^(\d+)-x-y$/, 2); 38 | expect( 39 | bm.getSupportedBranches([ 40 | '3-x-y', 41 | '6-x-y', 42 | '5-x-y', 43 | 'unrelated', 44 | '4-x-y', 45 | ]), 46 | ).toStrictEqual(['5-x-y', '6-x-y']); 47 | }); 48 | 49 | it('when one group is undefined, the branch with fewer groups wins', () => { 50 | const bm = new BranchMatcher(/^(\d+)-(?:(\d+)-x|x-y)$/, 2); 51 | expect(bm.getSupportedBranches(['6-x-y', '5-1-x', '5-x-y'])).toStrictEqual([ 52 | '5-x-y', 53 | '6-x-y', 54 | ]); 55 | }); 56 | 57 | it('can sort non-numeric groups', () => { 58 | const bm = new BranchMatcher(/^0\.([A-Z])$/, 2); 59 | expect(bm.getSupportedBranches(['0.F', '0.H', '0.G'])).toStrictEqual([ 60 | '0.G', 61 | '0.H', 62 | ]); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /spec/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 2 | 3 | import * as logUtils from '../src/utils/log-util'; 4 | import { LogLevel } from '../src/enums'; 5 | import { tagBackportReviewers } from '../src/utils'; 6 | 7 | const backportPROpenedEvent = require('./fixtures/backport_pull_request.opened.json'); 8 | 9 | vi.mock('../src/constants', async () => ({ 10 | ...(await vi.importActual('../src/constants')), 11 | DEFAULT_BACKPORT_REVIEW_TEAM: 'electron/wg-releases', 12 | })); 13 | 14 | describe('utils', () => { 15 | describe('tagBackportReviewers()', () => { 16 | const octokit = { 17 | pulls: { 18 | requestReviewers: vi.fn(), 19 | }, 20 | repos: { 21 | getCollaboratorPermissionLevel: vi.fn().mockResolvedValue({ 22 | data: { 23 | permission: 'admin', 24 | }, 25 | }), 26 | }, 27 | }; 28 | 29 | const context = { 30 | octokit, 31 | repo: vi.fn((obj) => obj), 32 | ...backportPROpenedEvent, 33 | }; 34 | 35 | beforeEach(() => vi.clearAllMocks()); 36 | 37 | it('correctly tags team reviewers when user is undefined', async () => { 38 | await tagBackportReviewers({ context, targetPrNumber: 1234 }); 39 | expect(octokit.pulls.requestReviewers).toHaveBeenCalled(); 40 | expect(octokit.pulls.requestReviewers).toHaveBeenCalledWith({ 41 | pull_number: 1234, 42 | team_reviewers: ['wg-releases'], 43 | reviewers: [], 44 | }); 45 | }); 46 | 47 | it('correctly tags team reviewers and reviewers when user is defined', async () => { 48 | const user = 'abc'; 49 | await tagBackportReviewers({ context, targetPrNumber: 1234, user }); 50 | expect(octokit.pulls.requestReviewers).toHaveBeenCalled(); 51 | expect(octokit.pulls.requestReviewers).toHaveBeenCalledWith({ 52 | pull_number: 1234, 53 | team_reviewers: ['wg-releases'], 54 | reviewers: [user], 55 | }); 56 | }); 57 | 58 | it('logs an error if requestReviewers throws an error', async () => { 59 | const error = new Error('Request failed'); 60 | context.octokit.pulls.requestReviewers = vi.fn().mockRejectedValue(error); 61 | 62 | const logSpy = vi.spyOn(logUtils, 'log'); 63 | await tagBackportReviewers({ context, targetPrNumber: 1234 }); 64 | 65 | expect(octokit.pulls.requestReviewers).toHaveBeenCalled(); 66 | 67 | expect(logSpy).toHaveBeenCalledWith( 68 | 'tagBackportReviewers', 69 | LogLevel.ERROR, 70 | `Failed to request reviewers for PR #1234`, 71 | error, 72 | ); 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/utils/label-utils.ts: -------------------------------------------------------------------------------- 1 | import { log } from './log-util'; 2 | import { LogLevel } from '../enums'; 3 | import { SEMVER_LABELS, SEMVER_PREFIX } from '../constants'; 4 | import { 5 | SimpleWebHookRepoContext, 6 | WebHookPR, 7 | WebHookRepoContext, 8 | } from '../types'; 9 | 10 | export const addLabels = async ( 11 | context: SimpleWebHookRepoContext, 12 | prNumber: number, 13 | labelsToAdd: string[], 14 | ) => { 15 | log('addLabel', LogLevel.INFO, `Adding ${labelsToAdd} to PR #${prNumber}`); 16 | 17 | return context.octokit.issues.addLabels( 18 | context.repo({ 19 | issue_number: prNumber, 20 | labels: labelsToAdd, 21 | }), 22 | ); 23 | }; 24 | 25 | export const getSemverLabel = (pr: Pick) => { 26 | return pr.labels.find((l) => l.name.startsWith(SEMVER_PREFIX)); 27 | }; 28 | 29 | export const getHighestSemverLabel = (...labels: string[]) => { 30 | const ranked = [ 31 | SEMVER_LABELS.PATCH, 32 | SEMVER_LABELS.MINOR, 33 | SEMVER_LABELS.MAJOR, 34 | ]; 35 | 36 | const indices = labels.map((label) => ranked.indexOf(label)); 37 | if (indices.some((index) => index === -1)) { 38 | throw new Error('Invalid semver labels'); 39 | } 40 | 41 | return ranked[Math.max(...indices)]; 42 | }; 43 | 44 | export const removeLabel = async ( 45 | context: Pick, 46 | prNumber: number, 47 | labelToRemove: string, 48 | ) => { 49 | log( 50 | 'removeLabel', 51 | LogLevel.INFO, 52 | `Removing ${labelToRemove} from PR #${prNumber}`, 53 | ); 54 | 55 | // If the issue does not have the label, don't try remove it. 56 | const hasLabel = await labelExistsOnPR(context, prNumber, labelToRemove); 57 | if (!hasLabel) return; 58 | 59 | return context.octokit.issues.removeLabel( 60 | context.repo({ 61 | issue_number: prNumber, 62 | name: labelToRemove, 63 | }), 64 | ); 65 | }; 66 | 67 | export const labelToTargetBranch = ( 68 | label: { name: string }, 69 | prefix: string, 70 | ) => { 71 | return label.name.replace(prefix, ''); 72 | }; 73 | 74 | export const labelExistsOnPR = async ( 75 | context: Pick, 76 | prNumber: number, 77 | labelName: string, 78 | ) => { 79 | log( 80 | 'labelExistsOnPR', 81 | LogLevel.INFO, 82 | `Checking if ${labelName} exists on #${prNumber}`, 83 | ); 84 | 85 | const labels = await context.octokit.issues.listLabelsOnIssue( 86 | context.repo({ 87 | issue_number: prNumber, 88 | per_page: 100, 89 | page: 1, 90 | }), 91 | ); 92 | 93 | return labels.data.some((label) => label.name === labelName); 94 | }; 95 | -------------------------------------------------------------------------------- /src/operations/backport-to-location.ts: -------------------------------------------------------------------------------- 1 | import { PRStatus, BackportPurpose, LogLevel } from '../enums'; 2 | import * as labelUtils from '../utils/label-utils'; 3 | import { log } from '../utils/log-util'; 4 | import { backportImpl } from '../utils'; 5 | import { Probot } from 'probot'; 6 | import { SimpleWebHookRepoContext, WebHookPR } from '../types'; 7 | 8 | /** 9 | * Performs a backport to a specified label representing a branch. 10 | * 11 | * @param robot - an instance of Probot 12 | * @param context - the context of the event that was triggered 13 | * @param label - the label representing the target branch for backporting 14 | */ 15 | export const backportToLabel = async ( 16 | robot: Probot, 17 | context: SimpleWebHookRepoContext, 18 | pr: WebHookPR, 19 | label: { name: string }, 20 | ) => { 21 | log( 22 | 'backportToLabel', 23 | LogLevel.INFO, 24 | `Executing backport to branch from label ${label.name}`, 25 | ); 26 | 27 | if (!label.name.startsWith(PRStatus.TARGET)) { 28 | log( 29 | 'backportToLabel', 30 | LogLevel.ERROR, 31 | `Label '${label.name}' does not begin with '${PRStatus.TARGET}'`, 32 | ); 33 | return; 34 | } 35 | 36 | const targetBranch = labelUtils.labelToTargetBranch(label, PRStatus.TARGET); 37 | if (!targetBranch) { 38 | log( 39 | 'backportToLabel', 40 | LogLevel.WARN, 41 | 'No target branch specified - aborting backport process', 42 | ); 43 | return; 44 | } 45 | 46 | const labelToRemove = label.name; 47 | const labelToAdd = label.name.replace(PRStatus.TARGET, PRStatus.IN_FLIGHT); 48 | await backportImpl( 49 | robot, 50 | context, 51 | pr, 52 | targetBranch, 53 | BackportPurpose.ExecuteBackport, 54 | labelToRemove, 55 | labelToAdd, 56 | ); 57 | }; 58 | 59 | /** 60 | * Performs a backport to a specified target branch. 61 | * 62 | * @param robot - an instance of Probot 63 | * @param context - the context of the event that was triggered 64 | * @param targetBranch - the branch to which the backport will be performed 65 | */ 66 | export const backportToBranch = async ( 67 | robot: Probot, 68 | context: SimpleWebHookRepoContext, 69 | pr: WebHookPR, 70 | targetBranch: string, 71 | ) => { 72 | log( 73 | 'backportToLabel', 74 | LogLevel.INFO, 75 | `Executing backport to branch '${targetBranch}'`, 76 | ); 77 | 78 | const labelToRemove = undefined; 79 | const labelToAdd = PRStatus.IN_FLIGHT + targetBranch; 80 | await backportImpl( 81 | robot, 82 | context, 83 | pr, 84 | targetBranch, 85 | BackportPurpose.ExecuteBackport, 86 | labelToRemove, 87 | labelToAdd, 88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /src/operations/init-repo.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'yaml'; 2 | import * as fs from 'fs'; 3 | import * as os from 'os'; 4 | import * as path from 'path'; 5 | import simpleGit, { CheckRepoActions } from 'simple-git'; 6 | import { InitRepoOptions } from '../interfaces'; 7 | import { LogLevel } from '../enums'; 8 | import { log } from '../utils/log-util'; 9 | import { Mutex } from 'async-mutex'; 10 | 11 | const baseDir = 12 | process.env.WORKING_DIR ?? path.resolve(os.tmpdir(), 'trop-working'); 13 | 14 | function githubUrl({ slug, accessToken }: InitRepoOptions): string { 15 | return `https://x-access-token:${accessToken}@github.com/${slug}.git`; 16 | } 17 | 18 | const repoMutex = new Map(); 19 | function mutexForRepoCache(slug: string) { 20 | if (!repoMutex.has(slug)) repoMutex.set(slug, new Mutex()); 21 | return repoMutex.get(slug)!; 22 | } 23 | 24 | async function updateRepoCache({ slug, accessToken }: InitRepoOptions) { 25 | const cacheDir = path.resolve(baseDir, slug, 'git-cache'); 26 | 27 | await fs.promises.mkdir(cacheDir, { recursive: true }); 28 | const git = simpleGit(cacheDir); 29 | if (!(await git.checkIsRepo(CheckRepoActions.BARE))) { 30 | // The repo might be missing, or otherwise somehow corrupt. Re-clone it. 31 | log( 32 | 'updateRepoCache', 33 | LogLevel.INFO, 34 | `${cacheDir} was not a git repo, cloning...`, 35 | ); 36 | await fs.promises.rm(cacheDir, { recursive: true, force: true }); 37 | await fs.promises.mkdir(cacheDir, { recursive: true }); 38 | await git.clone(githubUrl({ slug, accessToken }), '.', ['--bare']); 39 | } 40 | await git.fetch(); 41 | 42 | return cacheDir; 43 | } 44 | 45 | /** 46 | * Initializes the cloned repo trop will use to run backports. 47 | * 48 | * @param options - repo and payload for repo initialization 49 | * @returns an object containing the repo initialization directory 50 | */ 51 | export const initRepo = async ({ 52 | slug, 53 | accessToken, 54 | }: InitRepoOptions): Promise<{ dir: string }> => { 55 | log('initRepo', LogLevel.INFO, 'Setting up local repository'); 56 | 57 | await fs.promises.mkdir(path.resolve(baseDir, slug), { recursive: true }); 58 | const prefix = path.resolve(baseDir, slug, 'job-'); 59 | const dir = await fs.promises.mkdtemp(prefix); 60 | const git = simpleGit(dir); 61 | 62 | // Concurrent access to the repo cache has the potential to mess things up. 63 | await mutexForRepoCache(slug).runExclusive(async () => { 64 | const cacheDir = await updateRepoCache({ slug, accessToken }); 65 | await git.clone(cacheDir, '.'); 66 | }); 67 | 68 | const config = fs.readFileSync('./config.yml', 'utf8'); 69 | const { tropEmail, tropName } = parse(config); 70 | await git.addConfig('user.email', tropEmail || 'trop@example.com'); 71 | await git.addConfig('user.name', tropName || 'Trop Bot'); 72 | 73 | return { dir }; 74 | }; 75 | -------------------------------------------------------------------------------- /src/utils/branch-util.ts: -------------------------------------------------------------------------------- 1 | import { NUM_SUPPORTED_VERSIONS } from '../constants'; 2 | 3 | import { getEnvVar } from './env-util'; 4 | import { log } from './log-util'; 5 | import { LogLevel } from '../enums'; 6 | import { WebHookRepoContext } from '../types'; 7 | 8 | const SUPPORTED_BRANCH_PATTERN = new RegExp( 9 | getEnvVar('SUPPORTED_BRANCH_PATTERN', '^(\\d+)-(?:(\\d+)-x|x-y)$'), 10 | ); 11 | 12 | export class BranchMatcher { 13 | branchPattern: RegExp; 14 | numSupportedVersions: number; 15 | 16 | constructor(branchPattern: RegExp, numSupportedVersions: number) { 17 | this.branchPattern = branchPattern; 18 | this.numSupportedVersions = numSupportedVersions; 19 | } 20 | 21 | isBranchSupported(branchName: string): boolean { 22 | return this.branchPattern.test(branchName); 23 | } 24 | 25 | getSupportedBranches(allBranches: string[]): string[] { 26 | const releaseBranches = allBranches.filter((branch) => 27 | this.isBranchSupported(branch), 28 | ); 29 | console.log(allBranches, releaseBranches); 30 | const filtered: Record = {}; 31 | releaseBranches.sort((a, b) => { 32 | const [, ...aParts] = this.branchPattern.exec(a)!; 33 | const [, ...bParts] = this.branchPattern.exec(b)!; 34 | for (let i = 0; i < aParts.length; i += 1) { 35 | if (aParts[i] === bParts[i]) continue; 36 | return comparePart(aParts[i], bParts[i]); 37 | } 38 | return 0; 39 | }); 40 | for (const branch of releaseBranches) 41 | filtered[this.branchPattern.exec(branch)![1]] = branch; 42 | 43 | const values = Object.values(filtered); 44 | return values.sort(comparePart).slice(-this.numSupportedVersions); 45 | } 46 | } 47 | 48 | const branchMatcher = new BranchMatcher( 49 | SUPPORTED_BRANCH_PATTERN, 50 | NUM_SUPPORTED_VERSIONS, 51 | ); 52 | 53 | export const isBranchSupported = 54 | branchMatcher.isBranchSupported.bind(branchMatcher); 55 | 56 | function comparePart(a: string, b: string): number { 57 | if (a == null && b != null) return 1; 58 | if (b == null) return -1; 59 | if (/^\d+$/.test(a)) { 60 | return parseInt(a, 10) - parseInt(b, 10); 61 | } else { 62 | return a.localeCompare(b); 63 | } 64 | } 65 | 66 | /** 67 | * Fetches an array of the currently supported branches for a repository. 68 | * 69 | * @param context - the context of the event that was triggered 70 | * @returns an array of currently supported branches in x-y-z format 71 | */ 72 | export async function getSupportedBranches( 73 | context: Pick, 74 | ): Promise { 75 | log( 76 | 'getSupportedBranches', 77 | LogLevel.INFO, 78 | 'Fetching supported branches for this repository', 79 | ); 80 | 81 | const { data: branches } = await context.octokit.repos.listBranches( 82 | context.repo({ 83 | protected: true, 84 | }), 85 | ); 86 | 87 | return branchMatcher.getSupportedBranches(branches.map((b) => b.name)); 88 | } 89 | 90 | /** 91 | * @returns A scoped Regex matching the backport pattern present in PR bodies. 92 | */ 93 | export const getBackportPattern = () => 94 | /(?:^|\n)(?:manual |manually )?backport (?:of )?(?:#(\d+)|https:\/\/github.com\/.*\/pull\/(\d+))/gim; 95 | -------------------------------------------------------------------------------- /spec/queue.spec.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import { describe, expect, it, vi } from 'vitest'; 4 | 5 | import { ExecutionQueue } from '../src/Queue'; 6 | 7 | const waitForEvent = (emitter: EventEmitter, event: string) => { 8 | return new Promise((resolve) => { 9 | emitter.once(event, resolve); 10 | }); 11 | }; 12 | 13 | const delayedEvent = async ( 14 | emitter: EventEmitter, 15 | event: string, 16 | fn: () => Promise, 17 | ) => { 18 | const waiter = waitForEvent(emitter, event); 19 | await fn(); 20 | await waiter; 21 | }; 22 | 23 | const fakeTask = (name: string) => { 24 | const namedArgs = { 25 | name, 26 | taskRunner: vi.fn().mockResolvedValue(undefined), 27 | errorHandler: vi.fn().mockResolvedValue(undefined), 28 | args: () => 29 | [name, namedArgs.taskRunner, namedArgs.errorHandler] as [ 30 | string, 31 | () => Promise, 32 | () => Promise, 33 | ], 34 | }; 35 | return namedArgs; 36 | }; 37 | 38 | describe('ExecutionQueue', () => { 39 | it('should run task immediately when queue is empty', async () => { 40 | const q = new ExecutionQueue(); 41 | 42 | const task = fakeTask('test'); 43 | await delayedEvent(q, 'empty', async () => q.enterQueue(...task.args())); 44 | expect(task.taskRunner).toHaveBeenCalledTimes(1); 45 | }); 46 | 47 | it('should run the tasks error handler if the task throws', async () => { 48 | const q = new ExecutionQueue(); 49 | 50 | const task = fakeTask('test'); 51 | task.taskRunner.mockRejectedValue('err'); 52 | await delayedEvent(q, 'empty', async () => q.enterQueue(...task.args())); 53 | expect(task.taskRunner).toHaveBeenCalledTimes(1); 54 | expect(task.errorHandler).toHaveBeenCalledTimes(1); 55 | expect(task.errorHandler).toHaveBeenNthCalledWith(1, 'err'); 56 | }); 57 | 58 | it('should run the next task if the current task succeeds', async () => { 59 | const q = new ExecutionQueue(); 60 | 61 | const task = fakeTask('test'); 62 | const task2 = fakeTask('test2'); 63 | await delayedEvent(q, 'empty', async () => { 64 | q.enterQueue(...task.args()); 65 | q.enterQueue(...task2.args()); 66 | }); 67 | expect(task.taskRunner).toHaveBeenCalledTimes(1); 68 | expect(task2.taskRunner).toHaveBeenCalledTimes(1); 69 | }); 70 | 71 | it('should run the next task if the current task fails', async () => { 72 | const q = new ExecutionQueue(); 73 | 74 | const task = fakeTask('test'); 75 | task.taskRunner.mockRejectedValue('err'); 76 | const task2 = fakeTask('test2'); 77 | await delayedEvent(q, 'empty', async () => { 78 | q.enterQueue(...task.args()); 79 | q.enterQueue(...task2.args()); 80 | }); 81 | expect(task.taskRunner).toHaveBeenCalledTimes(1); 82 | expect(task2.taskRunner).toHaveBeenCalledTimes(1); 83 | }); 84 | 85 | it("should run the next task if the current task fails and it's error handler fails", async () => { 86 | const q = new ExecutionQueue(); 87 | 88 | const task = fakeTask('test'); 89 | task.taskRunner.mockRejectedValue('err'); 90 | task.errorHandler.mockRejectedValue('bad error'); 91 | const task2 = fakeTask('test2'); 92 | await delayedEvent(q, 'empty', async () => { 93 | q.enterQueue(...task.args()); 94 | q.enterQueue(...task2.args()); 95 | }); 96 | expect(task.taskRunner).toHaveBeenCalledTimes(1); 97 | expect(task2.taskRunner).toHaveBeenCalledTimes(1); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | ## Setting Up `trop` 2 | 3 | Welcome! We're glad you want to try out `trop`. 4 | 5 | ### What Does `trop` Do? 6 | 7 | `trop` automates backporting PRs to versioned release branches. 8 | 9 | #### Using `trop`: 10 | 11 | **Automatically With Labels**: 12 | 1. Open a bugfix or feature pull request to `main` 13 | 2. Add backport label(s) to the pull request (ex. `target/2-0-x`) 14 | 3. Your pull request is reviewed and you or a co-contributor merges it into `main` 15 | 4. `trop` will automatically open pull requests containing `cherry-pick`s of the code into the backporting branches you specified in your labels (in this case, `2-0-x`). 16 | 5. You or a co-contributor resolves any conflicts and merges in `trop`'s backports 17 | 18 | **NOTE:** If `trop` fails to perform a backport, it will flag the original PR with `needs-manual-backport/2-0-x` 19 | so that you or another contributor and perform the backport manually. Trop will keep track of manual backports 20 | and update the labels appropriately. 21 | 22 | **Manual Triggering With Labels**: 23 | 1. Open a bugfix or feature pull request to `main` 24 | 2. Your pull request is reviewed and you or a co-contributor merges it into `main` 25 | 3. After it's been merged, you add backport label(s) to the pull request (ex. `target/2-0-x`) 26 | 4. You create a new comment with the following body: `/trop run backport` 27 | 5. `trop` will begin the backport process for target branches you have specified via labels 28 | 6. `trop` will automatically open pull requests containing `cherry-pick`s of the code into the backporting branches you specified in your labels (in this case, `2-0-x`). 29 | 7. You or a co-contributor resolves any conflicts and merges in `trop`'s backports 30 | 31 | **Manual Triggering Without Labels**: 32 | 1. Open a bugfix or feature pull request to `main` 33 | 2. Your pull request is reviewed and you or a co-contributor merges it into `main` 34 | 3. You create a new comment with the following body: `/trop run backport-to [BRANCH_NAME]`, where `[BRANCH_NAME]` is replaced with the branch you wish to backport to 35 | 4. `trop` will begin the backport process for target branch you manually specified 36 | 5. `trop` will automatically open pull requests containing `cherry-pick`s of the code into the branch you specified in your comment body 37 | 5. You or a co-contributor resolves any conflicts and merges in the backport pull request `trop` created 38 | 39 | **Note** 40 | - You can delete the original PR branch whenever you want - trop does not need the original branch to perform the backport. 41 | 42 | #### Environment Variables 43 | 44 | `trop` is configured by default to use variable specific to electron, so in order to get the best experience you should be sure to set the following: 45 | 46 | * `BOT_USER_NAME` - the username if your bot (e.g `trop[bot]`) 47 | * `SKIP_CHECK_LABEL` - see [skipping manual backports](./manual-backports.md#skipping-backport-checks) 48 | * `NUM_SUPPORTED_VERSIONS` - automatic backports to stable branches further than this many back will be skipped. Defaults to 3. 49 | * `NO_EOL_SUPPORT` - if set to `1`, manual backports to stable branches older than `NUM_SUPPORTED_VERSIONS` will also be disallowed. 50 | * `SUPPORTED_BRANCH_PATTERN` - regex to define what a "stable branch" is. Defaults to `^(\d+)-(?:(\d+)-x|x-y)$`, which matches branches like `8-x-y` or `5-1-x`. Regex groups will be used for sorting, to determine which branch is "older" than another. Numeric groups (matching `/^\d+$/`) will be compared numerically, otherwise groups will be compared lexically. The first group is treated as the major version. There must be at least one group. 51 | -------------------------------------------------------------------------------- /src/utils/checks-util.ts: -------------------------------------------------------------------------------- 1 | import { CheckRunStatus, LogLevel } from '../enums'; 2 | import { 3 | BACKPORT_APPROVAL_CHECK, 4 | BACKPORT_INFORMATION_CHECK, 5 | CHECK_PREFIX, 6 | } from '../constants'; 7 | import { 8 | SimpleWebHookRepoContext, 9 | WebHookPR, 10 | WebHookPRContext, 11 | } from '../types'; 12 | import { log } from '../utils/log-util'; 13 | 14 | export async function updateBackportValidityCheck( 15 | context: WebHookPRContext, 16 | checkRun: BackportCheck, 17 | statusItems: { 18 | conclusion: CheckRunStatus; 19 | title: string; 20 | summary: string; 21 | }, 22 | ) { 23 | await context.octokit.checks.update( 24 | context.repo({ 25 | check_run_id: checkRun.id, 26 | name: checkRun.name, 27 | conclusion: statusItems.conclusion as CheckRunStatus, 28 | completed_at: new Date().toISOString(), 29 | details_url: 30 | 'https://github.com/electron/trop/blob/main/docs/manual-backports.md', 31 | output: { 32 | title: statusItems.title, 33 | summary: statusItems.summary, 34 | }, 35 | }), 36 | ); 37 | } 38 | 39 | export async function getBackportInformationCheck(context: WebHookPRContext) { 40 | const pr = context.payload.pull_request; 41 | const allChecks = await context.octokit.checks.listForRef( 42 | context.repo({ 43 | ref: pr.head.sha, 44 | per_page: 100, 45 | }), 46 | ); 47 | 48 | const backportCheck = allChecks.data.check_runs.filter((run) => 49 | run.name.startsWith(BACKPORT_INFORMATION_CHECK), 50 | ); 51 | 52 | return backportCheck.length > 0 ? backportCheck[0] : null; 53 | } 54 | 55 | type BackportCheck = NonNullable< 56 | Awaited> 57 | >; 58 | 59 | export async function updateBackportInformationCheck( 60 | context: WebHookPRContext, 61 | backportCheck: BackportCheck, 62 | statusItems: { 63 | conclusion: CheckRunStatus; 64 | title: string; 65 | summary: string; 66 | }, 67 | ) { 68 | await context.octokit.checks.update( 69 | context.repo({ 70 | check_run_id: backportCheck.id, 71 | name: backportCheck.name, 72 | conclusion: statusItems.conclusion as CheckRunStatus, 73 | completed_at: new Date().toISOString(), 74 | details_url: 'https://github.com/electron/trop', 75 | output: { 76 | title: statusItems.title, 77 | summary: statusItems.summary, 78 | }, 79 | }), 80 | ); 81 | } 82 | 83 | export async function queueBackportInformationCheck(context: WebHookPRContext) { 84 | const pr = context.payload.pull_request; 85 | 86 | await context.octokit.checks.create( 87 | context.repo({ 88 | name: BACKPORT_INFORMATION_CHECK, 89 | head_sha: pr.head.sha, 90 | status: 'queued', 91 | details_url: 'https://github.com/electron/trop', 92 | output: { 93 | title: 'Needs Backport Information', 94 | summary: 95 | 'This PR requires backport information. It should have a "no-backport" or a "target/" label.', 96 | }, 97 | }), 98 | ); 99 | } 100 | 101 | export async function getBackportApprovalCheck(context: WebHookPRContext) { 102 | const pr = context.payload.pull_request; 103 | const allChecks = await context.octokit.checks.listForRef( 104 | context.repo({ 105 | ref: pr.head.sha, 106 | per_page: 100, 107 | }), 108 | ); 109 | 110 | const backportCheck = allChecks.data.check_runs.filter((run) => 111 | run.name.startsWith(BACKPORT_APPROVAL_CHECK), 112 | ); 113 | 114 | return backportCheck.length > 0 ? backportCheck[0] : null; 115 | } 116 | 117 | export async function updateBackportApprovalCheck( 118 | context: WebHookPRContext, 119 | backportCheck: BackportCheck, 120 | statusItems: { 121 | conclusion: CheckRunStatus; 122 | title: string; 123 | summary: string; 124 | }, 125 | ) { 126 | await context.octokit.checks.update( 127 | context.repo({ 128 | check_run_id: backportCheck.id, 129 | name: backportCheck.name, 130 | conclusion: statusItems.conclusion as CheckRunStatus, 131 | completed_at: new Date().toISOString(), 132 | details_url: 'https://github.com/electron/trop', 133 | output: { 134 | title: statusItems.title, 135 | summary: statusItems.summary, 136 | }, 137 | }), 138 | ); 139 | } 140 | 141 | export async function queueBackportApprovalCheck(context: WebHookPRContext) { 142 | const pr = context.payload.pull_request; 143 | 144 | await context.octokit.checks.create( 145 | context.repo({ 146 | name: BACKPORT_APPROVAL_CHECK, 147 | head_sha: pr.head.sha, 148 | status: 'queued', 149 | details_url: 'https://github.com/electron/trop', 150 | output: { 151 | title: 'Needs Backport Approval', 152 | summary: 'This PR requires backport approval.', 153 | }, 154 | }), 155 | ); 156 | } 157 | 158 | export async function getOrCreateCheckRun( 159 | context: SimpleWebHookRepoContext, 160 | pr: WebHookPR, 161 | targetBranch: string, 162 | ) { 163 | const allChecks = await context.octokit.checks.listForRef( 164 | context.repo({ 165 | ref: pr.head.sha, 166 | per_page: 100, 167 | }), 168 | ); 169 | 170 | let checkRun = allChecks.data.check_runs.find((run) => { 171 | return run.name === `${CHECK_PREFIX}${targetBranch}`; 172 | }); 173 | 174 | if (!checkRun) { 175 | const response = await context.octokit.checks.create( 176 | context.repo({ 177 | name: `${CHECK_PREFIX}${targetBranch}`, 178 | head_sha: pr.head.sha, 179 | status: 'queued' as 'queued', 180 | details_url: 'https://github.com/electron/trop', 181 | }), 182 | ); 183 | checkRun = response.data; 184 | log( 185 | 'backportImpl', 186 | LogLevel.INFO, 187 | `Created check run '${CHECK_PREFIX}${targetBranch}' (${checkRun.id}) with status 'queued'`, 188 | ); 189 | } 190 | 191 | return checkRun; 192 | } 193 | -------------------------------------------------------------------------------- /spec/operations.spec.ts: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'child_process'; 2 | import * as fs from 'fs'; 3 | import * as os from 'os'; 4 | import * as path from 'path'; 5 | 6 | import simpleGit from 'simple-git'; 7 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 8 | 9 | import { PRChange } from '../src/enums'; 10 | import { initRepo } from '../src/operations/init-repo'; 11 | import { setupRemotes } from '../src/operations/setup-remotes'; 12 | import { updateManualBackport } from '../src/operations/update-manual-backport'; 13 | import { tagBackportReviewers } from '../src/utils'; 14 | 15 | let dirObject: { dir?: string } | null = null; 16 | 17 | const saveDir = (o: { dir: string }) => { 18 | dirObject = o; 19 | return o.dir; 20 | }; 21 | 22 | const backportPRClosedEvent = require('./fixtures/backport_pull_request.closed.json'); 23 | const backportPRMergedEvent = require('./fixtures/backport_pull_request.merged.json'); 24 | const backportPROpenedEvent = require('./fixtures/backport_pull_request.opened.json'); 25 | 26 | vi.mock('../src/utils', () => ({ 27 | tagBackportReviewers: vi.fn().mockResolvedValue(undefined), 28 | isSemverMinorPR: vi.fn().mockReturnValue(false), 29 | })); 30 | 31 | vi.mock('../src/utils/label-utils', () => ({ 32 | labelExistsOnPR: vi.fn().mockResolvedValue(true), 33 | getSemverLabel: vi.fn().mockResolvedValue(false), 34 | addLabels: vi.fn(), 35 | removeLabel: vi.fn(), 36 | })); 37 | 38 | describe( 39 | 'runner', 40 | () => { 41 | console.error = vi.fn(); 42 | 43 | afterEach(async () => { 44 | if (dirObject && dirObject.dir) { 45 | await fs.promises.rm(dirObject.dir, { force: true, recursive: true }); 46 | } 47 | }); 48 | 49 | describe('initRepo()', () => { 50 | it('should clone a github repository', async () => { 51 | const dir = saveDir( 52 | await initRepo({ 53 | slug: 'electron/trop', 54 | accessToken: '', 55 | }), 56 | ); 57 | expect(fs.existsSync(dir)).toBe(true); 58 | expect(fs.existsSync(path.resolve(dir, '.git'))).toBe(true); 59 | }); 60 | 61 | it('should fail if the github repository does not exist', async () => { 62 | await expect( 63 | initRepo({ 64 | slug: 'electron/this-is-not-trop', 65 | accessToken: '', 66 | }), 67 | ).rejects.toBeTruthy(); 68 | }); 69 | }); 70 | 71 | describe('setUpRemotes()', () => { 72 | let dir: string; 73 | 74 | beforeEach(async () => { 75 | dir = await fs.promises.mkdtemp( 76 | path.resolve(os.tmpdir(), 'trop-spec-'), 77 | ); 78 | await fs.promises.mkdir(dir, { recursive: true }); 79 | spawnSync('git', ['init'], { cwd: dir }); 80 | }); 81 | 82 | afterEach(async () => { 83 | if (fs.existsSync(dir)) { 84 | await fs.promises.rm(dir, { force: true, recursive: true }); 85 | } 86 | }); 87 | 88 | it('should set new remotes correctly', async () => { 89 | await setupRemotes({ 90 | dir, 91 | remotes: [ 92 | { 93 | name: 'origin', 94 | value: 'https://github.com/electron/clerk.git', 95 | }, 96 | { 97 | name: 'secondary', 98 | value: 'https://github.com/electron/trop.git', 99 | }, 100 | ], 101 | }); 102 | const git = simpleGit(dir); 103 | const remotes = await git.raw(['remote', '-v']); 104 | const parsedRemotes = remotes 105 | .trim() 106 | .replace(/ +/g, ' ') 107 | .replace(/\t/g, ' ') 108 | .replace(/ \(fetch\)/g, '') 109 | .replace(/ \(push\)/g, '') 110 | .split(/\r?\n/g) 111 | .map((line) => line.trim().split(' ')); 112 | 113 | expect(parsedRemotes.length).toBe(4); 114 | for (const remote of parsedRemotes) { 115 | expect(remote.length).toBe(2); 116 | expect(['origin', 'secondary']).toContain(remote[0]); 117 | if (remote[0] === 'origin') { 118 | expect( 119 | remote[1].endsWith('github.com/electron/clerk.git'), 120 | ).toBeTruthy(); 121 | } else { 122 | expect( 123 | remote[1].endsWith('github.com/electron/trop.git'), 124 | ).toBeTruthy(); 125 | } 126 | } 127 | }); 128 | }); 129 | 130 | describe('updateManualBackport()', () => { 131 | const octokit = { 132 | pulls: { 133 | get: vi.fn().mockResolvedValue({}), 134 | }, 135 | issues: { 136 | createComment: vi.fn().mockResolvedValue({}), 137 | listComments: vi.fn().mockResolvedValue({ data: [] }), 138 | }, 139 | }; 140 | 141 | it('tags reviewers on manual backport creation', async () => { 142 | const context = { 143 | ...backportPROpenedEvent, 144 | octokit, 145 | repo: vi.fn(), 146 | }; 147 | await updateManualBackport(context, PRChange.OPEN, 1234); 148 | expect(tagBackportReviewers).toHaveBeenCalled(); 149 | expect(tagBackportReviewers).toHaveBeenCalledWith({ 150 | context, 151 | targetPrNumber: 7, 152 | }); 153 | }); 154 | 155 | it('does not tag reviewers on merged PRs', async () => { 156 | const context = { 157 | ...backportPRMergedEvent, 158 | octokit, 159 | repo: vi.fn(), 160 | }; 161 | await updateManualBackport(context, PRChange.MERGE, 1234); 162 | expect(tagBackportReviewers).not.toHaveBeenCalled(); 163 | }); 164 | 165 | it('does not tag reviewers on closed PRs', async () => { 166 | const context = { 167 | ...backportPRClosedEvent, 168 | octokit, 169 | repo: vi.fn(), 170 | }; 171 | await updateManualBackport(context, PRChange.CLOSE, 1234); 172 | expect(tagBackportReviewers).not.toHaveBeenCalled(); 173 | }); 174 | }); 175 | }, 176 | { timeout: 30_000 }, 177 | ); 178 | -------------------------------------------------------------------------------- /src/operations/update-manual-backport.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BACKPORT_LABEL, 3 | BACKPORT_REQUESTED_LABEL, 4 | SKIP_CHECK_LABEL, 5 | } from '../constants'; 6 | import { PRChange, PRStatus, LogLevel } from '../enums'; 7 | import { WebHookPRContext } from '../types'; 8 | import { isSemverMinorPR, tagBackportReviewers } from '../utils'; 9 | import * as labelUtils from '../utils/label-utils'; 10 | import { log } from '../utils/log-util'; 11 | 12 | /** 13 | * Updates the labels on a backport's original PR as well as comments with links 14 | * to the backport if it's a newly opened PR. 15 | * 16 | * @param context - the context of the event that was triggered 17 | * @param type - the type of PR status change: either OPEN or CLOSE 18 | * @param oldPRNumber - the number corresponding to the backport's original PR 19 | */ 20 | export const updateManualBackport = async ( 21 | context: WebHookPRContext, 22 | type: PRChange, 23 | oldPRNumber: number, 24 | ) => { 25 | const pr = context.payload.pull_request; 26 | 27 | const newPRLabelsToAdd = [pr.base.ref]; 28 | 29 | // Changed labels on the original PR. 30 | let labelToAdd: string | undefined; 31 | let labelToRemove: string; 32 | 33 | log( 34 | 'updateManualBackport', 35 | LogLevel.INFO, 36 | `Updating backport of ${oldPRNumber} to ${pr.base.ref}`, 37 | ); 38 | 39 | if (type === PRChange.OPEN) { 40 | log( 41 | 'updateManualBackport', 42 | LogLevel.INFO, 43 | `New manual backport opened at #${pr.number}`, 44 | ); 45 | 46 | labelToAdd = PRStatus.IN_FLIGHT + pr.base.ref; 47 | labelToRemove = PRStatus.NEEDS_MANUAL + pr.base.ref; 48 | 49 | const removeLabelExists = await labelUtils.labelExistsOnPR( 50 | context, 51 | oldPRNumber, 52 | labelToRemove, 53 | ); 54 | if (!removeLabelExists) { 55 | labelToRemove = PRStatus.TARGET + pr.base.ref; 56 | } 57 | 58 | const skipCheckLabelExists = await labelUtils.labelExistsOnPR( 59 | context, 60 | pr.number, 61 | SKIP_CHECK_LABEL, 62 | ); 63 | if (!skipCheckLabelExists) { 64 | newPRLabelsToAdd.push(BACKPORT_LABEL); 65 | } 66 | 67 | const { data: originalPR } = await context.octokit.pulls.get( 68 | context.repo({ pull_number: oldPRNumber }), 69 | ); 70 | 71 | // Propagate semver label from the original PR if the maintainer didn't add it. 72 | const originalPRSemverLabel = labelUtils.getSemverLabel(originalPR); 73 | if (originalPRSemverLabel) { 74 | // If the new PR for some reason has a semver label already, then 75 | // we need to compare the two semver labels and ensure the higher one 76 | // takes precedence. 77 | const newPRSemverLabel = labelUtils.getSemverLabel(pr); 78 | if ( 79 | newPRSemverLabel && 80 | newPRSemverLabel.name !== originalPRSemverLabel.name 81 | ) { 82 | const higherLabel = labelUtils.getHighestSemverLabel( 83 | originalPRSemverLabel.name, 84 | newPRSemverLabel.name, 85 | ); 86 | // The existing label is lower precedence - remove and replace it. 87 | if (higherLabel === originalPRSemverLabel.name) { 88 | await labelUtils.removeLabel( 89 | context, 90 | pr.number, 91 | newPRSemverLabel.name, 92 | ); 93 | newPRLabelsToAdd.push(originalPRSemverLabel.name); 94 | } 95 | } else { 96 | newPRLabelsToAdd.push(originalPRSemverLabel.name); 97 | } 98 | } 99 | 100 | if (await isSemverMinorPR(context, pr)) { 101 | log( 102 | 'updateManualBackport', 103 | LogLevel.INFO, 104 | `Determined that ${pr.number} is semver-minor`, 105 | ); 106 | newPRLabelsToAdd.push(BACKPORT_REQUESTED_LABEL); 107 | } 108 | 109 | // We should only comment if there is not a previous existing comment 110 | const commentBody = `@${pr.user.login} has manually backported this PR to "${pr.base.ref}", \ 111 | please check out #${pr.number}`; 112 | 113 | // TODO(codebytere): Once probot updates to @octokit/rest@16 we can use .paginate to 114 | // get all the comments properly, for now 100 should do 115 | const { data: existingComments } = 116 | await context.octokit.issues.listComments( 117 | context.repo({ 118 | issue_number: oldPRNumber, 119 | per_page: 100, 120 | }), 121 | ); 122 | 123 | // We should only comment if there is not a previous existing comment 124 | const shouldComment = !existingComments.some( 125 | (comment) => comment.body === commentBody, 126 | ); 127 | 128 | if (shouldComment) { 129 | // Comment on the original PR with the manual backport link 130 | await context.octokit.issues.createComment( 131 | context.repo({ 132 | issue_number: oldPRNumber, 133 | body: commentBody, 134 | }), 135 | ); 136 | } 137 | 138 | // Tag default reviewers to manual backport 139 | await tagBackportReviewers({ 140 | context, 141 | targetPrNumber: pr.number, 142 | }); 143 | } else if (type === PRChange.MERGE) { 144 | log( 145 | 'updateManualBackport', 146 | LogLevel.INFO, 147 | `Backport of ${oldPRNumber} at #${pr.number} merged to ${pr.base.ref}`, 148 | ); 149 | 150 | labelToRemove = PRStatus.IN_FLIGHT + pr.base.ref; 151 | 152 | // The old PR should now show that the backport PR has been merged to this branch. 153 | labelToAdd = PRStatus.MERGED + pr.base.ref; 154 | } else { 155 | log( 156 | 'updateManualBackport', 157 | LogLevel.INFO, 158 | `Backport of ${oldPRNumber} at #${pr.number} to ${pr.base.ref} was closed`, 159 | ); 160 | 161 | // If a backport is closed with unmerged commits, we just want 162 | // to remove the old in-flight/ label. 163 | labelToRemove = PRStatus.IN_FLIGHT + pr.base.ref; 164 | } 165 | 166 | // Add labels to the new manual backport PR. 167 | await labelUtils.addLabels(context, pr.number, newPRLabelsToAdd); 168 | 169 | // Update labels on the original PR. 170 | await labelUtils.removeLabel(context, oldPRNumber, labelToRemove); 171 | if (labelToAdd) { 172 | await labelUtils.addLabels(context, oldPRNumber, [labelToAdd]); 173 | } 174 | }; 175 | -------------------------------------------------------------------------------- /src/operations/backport-commits.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import simpleGit from 'simple-git'; 4 | import { BackportOptions } from '../interfaces'; 5 | import { log } from '../utils/log-util'; 6 | import { LogLevel } from '../enums'; 7 | import { isUtf8 } from 'buffer'; 8 | 9 | const cleanRawGitString = (s: string) => { 10 | let nS = s.trim(); 11 | if (nS.startsWith(`'`)) { 12 | nS = nS.slice(1).trim(); 13 | } 14 | if (nS.endsWith(`'`)) { 15 | nS = nS.slice(0, nS.length - 1).trim(); 16 | } 17 | return nS; 18 | }; 19 | 20 | /** 21 | * Runs the git commands to apply backports in a series of cherry-picked commits. 22 | * 23 | * @param options - an object containing: 24 | * 1) dir - the repo directory 25 | * 2) targetBranch - the target branch 26 | * 3) patches - a list of patches to apply to the target branch 27 | * 4) tempBranch - the temporary branch to PR against the target branch 28 | * @returns false on failure, otherwise an object containing the repo initialization directory 29 | */ 30 | export const backportCommitsToBranch = async (options: BackportOptions) => { 31 | log( 32 | 'backportCommitsToBranch', 33 | LogLevel.INFO, 34 | `Backporting ${options.patches.length} commits to ${options.targetBranch}`, 35 | ); 36 | 37 | const git = simpleGit(options.dir); 38 | 39 | // Abort previous patch attempts 40 | try { 41 | await git.raw(['am', '--abort']); 42 | } catch {} 43 | 44 | // Create branch to cherry-pick the commits to. 45 | try { 46 | await git.checkout(`target_repo/${options.targetBranch}`); 47 | await git.pull('target_repo', options.targetBranch); 48 | if ( 49 | Object.keys((await git.branchLocal()).branches).includes( 50 | options.tempBranch, 51 | ) 52 | ) { 53 | log( 54 | 'backportCommitsToBranch', 55 | LogLevel.INFO, 56 | `The provided temporary branch name "${options.tempBranch}" already exists, deleting existing ref before backporting`, 57 | ); 58 | await git.branch(['-D', options.tempBranch]); 59 | } 60 | await git.checkoutBranch( 61 | options.tempBranch, 62 | `target_repo/${options.targetBranch}`, 63 | ); 64 | } catch (error) { 65 | log( 66 | 'backportCommitsToBranch', 67 | LogLevel.ERROR, 68 | `Failed to checkout new backport branch`, 69 | error, 70 | ); 71 | 72 | return false; 73 | } 74 | 75 | // Cherry pick the commits to be backported. 76 | const patchPath = `${options.dir}.patch`; 77 | 78 | for (const patch of options.patches) { 79 | try { 80 | await fs.promises.writeFile(patchPath, patch, 'utf8'); 81 | await git.raw(['am', '-3', '--keep-cr', patchPath]); 82 | } catch (error) { 83 | log( 84 | 'backportCommitsToBranch', 85 | LogLevel.ERROR, 86 | `Failed to apply patch to ${options.targetBranch}`, 87 | error, 88 | ); 89 | 90 | return false; 91 | } finally { 92 | if (fs.existsSync(patchPath)) { 93 | await fs.promises.rm(patchPath, { force: true, recursive: true }); 94 | } 95 | } 96 | } 97 | 98 | // Push the commit to the target branch on the remote. 99 | if (options.shouldPush) { 100 | const appliedCommits = await git.log({ 101 | from: `target_repo/${options.targetBranch}`, 102 | to: options.tempBranch, 103 | }); 104 | let baseCommitSha = await git.revparse([ 105 | `target_repo/${options.targetBranch}`, 106 | ]); 107 | const baseTree = await options.github.git.getCommit( 108 | options.context.repo({ 109 | commit_sha: baseCommitSha, 110 | }), 111 | ); 112 | let baseTreeSha = baseTree.data.sha; 113 | 114 | for (const commit of [...appliedCommits.all].reverse()) { 115 | const rawDiffTree: string = await git.raw([ 116 | 'diff-tree', 117 | '--no-commit-id', 118 | '--name-only', 119 | '-r', 120 | commit.hash, 121 | ]); 122 | const changedFiles = rawDiffTree 123 | .trim() 124 | .split('\n') 125 | .map((s: string) => s.trim()); 126 | await git.checkout(commit.hash); 127 | 128 | const newTree = await options.github.git.createTree( 129 | options.context.repo({ 130 | base_tree: baseTreeSha, 131 | tree: await Promise.all( 132 | changedFiles.map(async (changedFile) => { 133 | const onDiskPath = path.resolve(options.dir, changedFile); 134 | if (!fs.existsSync(onDiskPath)) { 135 | return { 136 | path: changedFile, 137 | mode: '100644', 138 | type: 'blob', 139 | sha: null, 140 | }; 141 | } 142 | const fileContents = await fs.promises.readFile(onDiskPath); 143 | const stat = await fs.promises.stat(onDiskPath); 144 | const userMode = (stat.mode & parseInt('777', 8)).toString(8)[0]; 145 | if (isUtf8(fileContents)) { 146 | return { 147 | path: changedFile, 148 | mode: userMode === '6' ? '100644' : '100755', 149 | type: 'blob', 150 | content: fileContents.toString('utf-8'), 151 | }; 152 | } 153 | 154 | const blob = await options.github.git.createBlob( 155 | options.context.repo({ 156 | content: fileContents.toString('base64'), 157 | encoding: 'base64', 158 | }), 159 | ); 160 | 161 | return { 162 | path: changedFile, 163 | mode: userMode === '6' ? '100644' : '100755', 164 | type: 'blob', 165 | sha: blob.data.sha, 166 | }; 167 | }), 168 | ), 169 | }), 170 | ); 171 | 172 | const authorEmail = cleanRawGitString( 173 | await git.raw(['show', '-s', "--format='%ae'", commit.hash]), 174 | ); 175 | const authorName = cleanRawGitString( 176 | await git.raw(['show', '-s', "--format='%an'", commit.hash]), 177 | ); 178 | const commitMessage = cleanRawGitString( 179 | await git.raw(['show', '-s', "--format='%B'", commit.hash]), 180 | ); 181 | 182 | const newMessage = `${commitMessage}\n\nCo-authored-by: ${authorName} <${authorEmail}>`; 183 | 184 | const newCommit = await options.github.git.createCommit( 185 | options.context.repo({ 186 | parents: [baseCommitSha], 187 | tree: newTree.data.sha, 188 | message: newMessage, 189 | }), 190 | ); 191 | 192 | baseTreeSha = newTree.data.sha; 193 | baseCommitSha = newCommit.data.sha; 194 | } 195 | 196 | await options.github.git.createRef( 197 | options.context.repo({ 198 | sha: baseCommitSha, 199 | ref: `refs/heads/${options.tempBranch}`, 200 | }), 201 | ); 202 | } 203 | 204 | return { dir: options.dir }; 205 | }; 206 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationFunction } from 'probot'; 2 | 3 | import { 4 | backportImpl, 5 | getPRNumbersFromPRBody, 6 | isAuthorizedUser, 7 | labelClosedPR, 8 | } from './utils'; 9 | import { 10 | labelToTargetBranch, 11 | labelExistsOnPR, 12 | removeLabel, 13 | } from './utils/label-utils'; 14 | import { 15 | BACKPORT_APPROVAL_CHECK, 16 | BACKPORT_APPROVED_LABEL, 17 | BACKPORT_REQUESTED_LABEL, 18 | CHECK_PREFIX, 19 | NO_BACKPORT_LABEL, 20 | SKIP_CHECK_LABEL, 21 | VALID_BACKPORT_CHECK_NAME, 22 | } from './constants'; 23 | import { getEnvVar } from './utils/env-util'; 24 | import { PRChange, PRStatus, BackportPurpose, CheckRunStatus } from './enums'; 25 | import { Label } from '@octokit/webhooks-types'; 26 | import { 27 | backportToLabel, 28 | backportToBranch, 29 | } from './operations/backport-to-location'; 30 | import { updateManualBackport } from './operations/update-manual-backport'; 31 | import { getSupportedBranches, isBranchSupported } from './utils/branch-util'; 32 | import { 33 | getBackportApprovalCheck, 34 | getBackportInformationCheck, 35 | queueBackportApprovalCheck, 36 | queueBackportInformationCheck, 37 | updateBackportApprovalCheck, 38 | updateBackportInformationCheck, 39 | updateBackportValidityCheck, 40 | } from './utils/checks-util'; 41 | import { register } from './utils/prom'; 42 | import { SimpleWebHookRepoContext, WebHookPR, WebHookPRContext } from './types'; 43 | 44 | import { execSync } from 'child_process'; 45 | 46 | // @ts-ignore - builtin fetch doesn't support global-agent. 47 | delete globalThis.fetch; 48 | 49 | const probotHandler: ApplicationFunction = async (robot, { getRouter }) => { 50 | getRouter?.('/metrics').get('/', (req, res) => { 51 | register 52 | .metrics() 53 | .then((metrics) => { 54 | res.setHeader('Content-Type', register.contentType); 55 | res.end(metrics); 56 | }) 57 | .catch((err) => { 58 | console.error('Failed to send metrics:', err); 59 | res.status(500).end(''); 60 | }); 61 | }); 62 | 63 | const handleClosedPRLabels = async ( 64 | context: WebHookPRContext, 65 | pr: WebHookPR, 66 | change: PRChange, 67 | ) => { 68 | for (const label of pr.labels) { 69 | if (isBranchSupported(label.name)) { 70 | await labelClosedPR(context, pr, label.name, change); 71 | } 72 | } 73 | }; 74 | 75 | const backportAllLabels = ( 76 | context: SimpleWebHookRepoContext, 77 | pr: WebHookPR, 78 | ) => { 79 | for (const label of pr.labels) { 80 | backportToLabel(robot, context, pr, label); 81 | } 82 | }; 83 | 84 | const handleTropBackportClosed = async ( 85 | context: WebHookPRContext, 86 | pr: WebHookPR, 87 | change: PRChange, 88 | ) => { 89 | const closeType = change === PRChange.MERGE ? 'merged' : 'closed'; 90 | robot.log( 91 | `Updating labels on original PR for ${closeType} PR: #${pr.number}`, 92 | ); 93 | await handleClosedPRLabels(context, pr, change); 94 | 95 | robot.log(`Deleting base branch: ${pr.head.ref}`); 96 | try { 97 | await context.octokit.git.deleteRef( 98 | context.repo({ ref: `heads/${pr.head.ref}` }), 99 | ); 100 | } catch (e) { 101 | robot.log('Failed to delete backport branch: ', e); 102 | } 103 | }; 104 | 105 | const runCheck = async (context: WebHookPRContext, pr: WebHookPR) => { 106 | const allChecks = await context.octokit.checks.listForRef( 107 | context.repo({ 108 | ref: pr.head.sha, 109 | per_page: 100, 110 | }), 111 | ); 112 | const checkRuns = allChecks.data.check_runs.filter((run) => 113 | run.name.startsWith(CHECK_PREFIX), 114 | ); 115 | 116 | for (const label of pr.labels) { 117 | if (!label.name.startsWith(PRStatus.TARGET)) continue; 118 | const targetBranch = labelToTargetBranch(label, PRStatus.TARGET); 119 | 120 | await backportImpl( 121 | robot, 122 | context, 123 | pr, 124 | targetBranch, 125 | BackportPurpose.Check, 126 | ); 127 | } 128 | 129 | for (const checkRun of checkRuns) { 130 | if ( 131 | !pr.labels.find( 132 | (prLabel) => 133 | prLabel.name === 134 | `${PRStatus.TARGET}${checkRun.name.replace(CHECK_PREFIX, '')}`, 135 | ) 136 | ) { 137 | await updateBackportValidityCheck(context, checkRun, { 138 | title: 'Cancelled', 139 | summary: 140 | 'This trop check was cancelled and can be ignored as this \ 141 | PR is no longer targeting this branch for a backport', 142 | conclusion: CheckRunStatus.NEUTRAL, 143 | }); 144 | } 145 | } 146 | }; 147 | 148 | const maybeRunCheck = async (context: WebHookPRContext) => { 149 | const payload = context.payload; 150 | if (!payload.pull_request.merged) { 151 | await runCheck(context, payload.pull_request); 152 | } 153 | }; 154 | 155 | const gitExists = execSync('which git', { encoding: 'utf-8' }).trim(); 156 | if (/git not found/.test(gitExists)) { 157 | robot.log('Git not found - unable to proceed with backporting'); 158 | process.exit(1); 159 | } 160 | 161 | /** 162 | * Checks for backport PRs. 163 | */ 164 | robot.on( 165 | [ 166 | 'pull_request.opened', 167 | 'pull_request.edited', 168 | 'pull_request.synchronize', 169 | 'pull_request.labeled', 170 | 'pull_request.unlabeled', 171 | ], 172 | async (context) => { 173 | const { pull_request: pr, action } = context.payload; 174 | let label: Label; 175 | if ('label' in context.payload) { 176 | label = context.payload.label; 177 | } 178 | const oldPRNumbers = getPRNumbersFromPRBody(pr, true); 179 | 180 | robot.log(`Found ${oldPRNumbers.length} backport numbers for this PR`); 181 | 182 | // Only check for manual backports when a new PR is opened or if the PR body is edited. 183 | if (oldPRNumbers.length > 0 && ['opened', 'edited'].includes(action)) { 184 | for (const oldPRNumber of oldPRNumbers) { 185 | robot.log( 186 | `Updating original backport at ${oldPRNumber} for ${pr.number}`, 187 | ); 188 | await updateManualBackport(context, PRChange.OPEN, oldPRNumber); 189 | } 190 | } 191 | 192 | // Check if the PR is going to main, if it's not check if it's correctly 193 | // tagged as a backport of a PR that has already been merged into main. 194 | const { data: allChecks } = await context.octokit.checks.listForRef( 195 | context.repo({ 196 | ref: pr.head.sha, 197 | per_page: 100, 198 | }), 199 | ); 200 | let checkRun = allChecks.check_runs.find( 201 | (run) => run.name === VALID_BACKPORT_CHECK_NAME, 202 | ); 203 | let backportApprovalCheck = allChecks.check_runs.find( 204 | (run) => run.name === BACKPORT_APPROVAL_CHECK, 205 | ); 206 | 207 | if (!checkRun) { 208 | robot.log(`Queueing new check run for #${pr.number}`); 209 | const response = await context.octokit.checks.create( 210 | context.repo({ 211 | name: VALID_BACKPORT_CHECK_NAME, 212 | head_sha: pr.head.sha, 213 | status: 'queued' as 'queued', 214 | details_url: 'https://github.com/electron/trop', 215 | }), 216 | ); 217 | 218 | checkRun = response.data; 219 | } 220 | 221 | if (pr.base.ref !== pr.base.repo.default_branch) { 222 | if (!backportApprovalCheck) { 223 | await queueBackportApprovalCheck(context); 224 | backportApprovalCheck = (await getBackportApprovalCheck(context))!; 225 | } 226 | 227 | // Ensure that we aren't including our own base branch in the backport process. 228 | if (action === 'labeled') { 229 | if ( 230 | Object.values(PRStatus).some((status) => 231 | label.name.startsWith(status), 232 | ) 233 | ) { 234 | if (isBranchSupported(label!.name) && label!.name === pr.base.ref) { 235 | robot.log( 236 | `#${pr.number} is trying to backport to itself - this is not allowed`, 237 | ); 238 | await removeLabel(context, 1, ''); 239 | } 240 | } 241 | } 242 | 243 | // If a branch is targeting something that isn't main it might not be a backport; 244 | // allow for a label to skip backport validity check for these branches. 245 | if (await labelExistsOnPR(context, pr.number, SKIP_CHECK_LABEL)) { 246 | robot.log( 247 | `#${pr.number} is labeled with ${SKIP_CHECK_LABEL} - skipping backport validation check`, 248 | ); 249 | await updateBackportValidityCheck(context, checkRun, { 250 | title: 'Backport Check Skipped', 251 | summary: 252 | 'This PR is not a backport - skip backport validation check', 253 | conclusion: CheckRunStatus.NEUTRAL, 254 | }); 255 | await updateBackportApprovalCheck(context, backportApprovalCheck, { 256 | title: 'Skipped', 257 | summary: `This PR is targeting '${pr.base.repo.default_branch}' and is not a backport`, 258 | conclusion: CheckRunStatus.NEUTRAL, 259 | }); 260 | return; 261 | } 262 | 263 | const FASTTRACK_PREFIXES = ['build:', 'ci:']; 264 | const FASTTRACK_USERS = [ 265 | getEnvVar('BOT_USER_NAME'), 266 | getEnvVar('COMMITTER_USER_NAME'), 267 | ]; 268 | const FASTTRACK_LABELS: string[] = ['fast-track 🚅']; 269 | 270 | const failureMap = new Map(); 271 | 272 | // There are several types of PRs which might not target main yet which are 273 | // inherently valid; e.g roller-bot PRs. Check for and allow those here. 274 | if (oldPRNumbers.length === 0) { 275 | robot.log( 276 | `#${pr.number} does not have backport numbers - checking fast track status`, 277 | ); 278 | if ( 279 | !FASTTRACK_PREFIXES.some((pre) => pr.title.startsWith(pre)) && 280 | !FASTTRACK_USERS.some((user) => pr.user.login === user) && 281 | !FASTTRACK_LABELS.some((label) => 282 | pr.labels.some((prLabel) => prLabel.name === label), 283 | ) 284 | ) { 285 | robot.log( 286 | `#${pr.number} is not a fast track PR - marking check run as failed`, 287 | ); 288 | await updateBackportValidityCheck(context, checkRun, { 289 | title: 'Invalid Backport', 290 | summary: `This PR is targeting a branch that is not ${pr.base.repo.default_branch} but is missing a "Backport of #{N}" declaration. Check out the trop documentation linked below for more information.`, 291 | conclusion: CheckRunStatus.FAILURE, 292 | }); 293 | } else { 294 | robot.log( 295 | `#${pr.number} is a fast track PR - marking check run as succeeded`, 296 | ); 297 | await updateBackportValidityCheck(context, checkRun, { 298 | title: 'Valid Backport', 299 | summary: `This PR is targeting a branch that is not ${pr.base.repo.default_branch} but a designated fast-track backport which does not require a manual backport number.`, 300 | conclusion: CheckRunStatus.SUCCESS, 301 | }); 302 | } 303 | } else { 304 | robot.log( 305 | `#${pr.number} has backport numbers - checking their validity now`, 306 | ); 307 | const supported = await getSupportedBranches(context); 308 | 309 | for (const oldPRNumber of oldPRNumbers) { 310 | robot.log(`Checking validity of #${oldPRNumber}`); 311 | const { data: oldPR } = await context.octokit.pulls.get( 312 | context.repo({ 313 | pull_number: oldPRNumber, 314 | }), 315 | ); 316 | 317 | // The current PR is only valid if the PR it is backporting 318 | // was merged to main or to a supported release branch. 319 | if ( 320 | ![pr.base.repo.default_branch, ...supported].includes( 321 | oldPR.base.ref, 322 | ) 323 | ) { 324 | const cause = 325 | 'the PR that it is backporting was not targeting the default branch.'; 326 | failureMap.set(oldPRNumber, cause); 327 | } else if (!oldPR.merged) { 328 | const cause = 329 | 'the PR that this is backporting has not been merged yet.'; 330 | failureMap.set(oldPRNumber, cause); 331 | } 332 | } 333 | } 334 | 335 | for (const oldPRNumber of oldPRNumbers) { 336 | if (failureMap.has(oldPRNumber)) { 337 | robot.log( 338 | `#${pr.number} is targeting a branch that is not ${ 339 | pr.base.repo.default_branch 340 | } - ${failureMap.get(oldPRNumber)}`, 341 | ); 342 | await updateBackportValidityCheck(context, checkRun, { 343 | title: 'Invalid Backport', 344 | summary: `This PR is targeting a branch that is not ${ 345 | pr.base.repo.default_branch 346 | } but ${failureMap.get(oldPRNumber)}`, 347 | conclusion: CheckRunStatus.FAILURE, 348 | }); 349 | } else { 350 | robot.log(`#${pr.number} is a valid backport of #${oldPRNumber}`); 351 | await updateBackportValidityCheck(context, checkRun, { 352 | title: 'Valid Backport', 353 | summary: `This PR is declared as backporting "#${oldPRNumber}" which is a valid PR that has been merged into ${pr.base.repo.default_branch}`, 354 | conclusion: CheckRunStatus.SUCCESS, 355 | }); 356 | } 357 | } 358 | 359 | const isBackportApproved = pr.labels.some( 360 | (prLabel) => prLabel.name === BACKPORT_APPROVED_LABEL, 361 | ); 362 | const isBackportRequested = pr.labels.some( 363 | (prLabel) => prLabel.name === BACKPORT_REQUESTED_LABEL, 364 | ); 365 | 366 | if (isBackportApproved) { 367 | await updateBackportApprovalCheck(context, backportApprovalCheck, { 368 | title: 'Backport Approved', 369 | summary: 'This PR has been approved for backporting.', 370 | conclusion: CheckRunStatus.SUCCESS, 371 | }); 372 | } else if (!isBackportRequested) { 373 | await updateBackportApprovalCheck(context, backportApprovalCheck, { 374 | title: 'Backport Approval Not Required', 375 | summary: 'This PR does not need backport approval.', 376 | conclusion: CheckRunStatus.SUCCESS, 377 | }); 378 | } else { 379 | if (backportApprovalCheck.status !== 'queued') { 380 | await queueBackportApprovalCheck(context); 381 | } 382 | } 383 | } else { 384 | // If we're somehow targeting main and have a check run, 385 | // we mark this check as cancelled. 386 | robot.log( 387 | `#${pr.number} is targeting '${pr.base.repo.default_branch}' and is not a backport - marking as cancelled`, 388 | ); 389 | await updateBackportValidityCheck(context, checkRun, { 390 | title: 'Cancelled', 391 | summary: `This PR is targeting '${pr.base.repo.default_branch}' and is not a backport`, 392 | conclusion: CheckRunStatus.NEUTRAL, 393 | }); 394 | } 395 | 396 | // Only run the backportable checks on "opened" and "synchronize" 397 | // an "edited" change can not impact backportability. 398 | if (['edited', 'synchronize'].includes(action)) { 399 | maybeRunCheck(context); 400 | } 401 | }, 402 | ); 403 | 404 | robot.on('pull_request.reopened', maybeRunCheck); 405 | robot.on('pull_request.labeled', maybeRunCheck); 406 | robot.on('pull_request.unlabeled', maybeRunCheck); 407 | 408 | /** 409 | * Checks that a PR done to `main` contains the required 410 | * backport information, i.e.: at least a `no-backport` or 411 | * a `target/XYZ` labels. 412 | */ 413 | robot.on( 414 | [ 415 | 'pull_request.opened', 416 | 'pull_request.reopened', 417 | 'pull_request.labeled', 418 | 'pull_request.unlabeled', 419 | 'pull_request.synchronize', 420 | ], 421 | async (context) => { 422 | const pr = context.payload.pull_request; 423 | 424 | if (pr.base.ref !== pr.base.repo.default_branch) { 425 | return; 426 | } 427 | 428 | let backportCheck = await getBackportInformationCheck(context); 429 | 430 | if (!backportCheck) { 431 | await queueBackportInformationCheck(context); 432 | backportCheck = (await getBackportInformationCheck(context))!; 433 | } 434 | 435 | const isNoBackport = pr.labels.some( 436 | (prLabel) => prLabel.name === NO_BACKPORT_LABEL, 437 | ); 438 | const hasTarget = pr.labels.some( 439 | (prLabel) => 440 | prLabel.name.startsWith(PRStatus.TARGET) || 441 | prLabel.name.startsWith(PRStatus.IN_FLIGHT) || 442 | prLabel.name.startsWith(PRStatus.NEEDS_MANUAL) || 443 | prLabel.name.startsWith(PRStatus.MERGED), 444 | ); 445 | 446 | if (hasTarget && isNoBackport) { 447 | await updateBackportInformationCheck(context, backportCheck, { 448 | title: 'Conflicting Backport Information', 449 | summary: 450 | 'The PR has a "no-backport" and at least one "target/" label. Impossible to determine backport action.', 451 | conclusion: CheckRunStatus.FAILURE, 452 | }); 453 | 454 | return; 455 | } 456 | 457 | if (!hasTarget && !isNoBackport) { 458 | if (backportCheck.status !== 'queued') { 459 | await queueBackportInformationCheck(context); 460 | } 461 | 462 | return; 463 | } 464 | 465 | await updateBackportInformationCheck(context, backportCheck, { 466 | title: 'Backport Information Provided', 467 | summary: 'This PR contains the required backport information.', 468 | conclusion: CheckRunStatus.SUCCESS, 469 | }); 470 | }, 471 | ); 472 | 473 | // Backport pull requests to labeled targets when PR is merged. 474 | robot.on('pull_request.closed', async (context) => { 475 | const { pull_request: pr } = context.payload; 476 | 477 | const oldPRNumbers = getPRNumbersFromPRBody(pr, true); 478 | if (pr.merged) { 479 | if (oldPRNumbers.length > 0) { 480 | robot.log(`Automatic backport merged for: #${pr.number}`); 481 | robot.log(`Labeling original PR for merged PR: #${pr.number}`); 482 | for (const oldPRNumber of oldPRNumbers) { 483 | await updateManualBackport(context, PRChange.MERGE, oldPRNumber); 484 | } 485 | await handleClosedPRLabels(context, pr, PRChange.MERGE); 486 | } 487 | 488 | // Check that the closed PR is trop's own and act accordingly. 489 | if (pr.user.login === getEnvVar('BOT_USER_NAME')) { 490 | await handleTropBackportClosed(context, pr, PRChange.MERGE); 491 | } else { 492 | robot.log( 493 | `Backporting #${pr.number} to all branches specified by labels`, 494 | ); 495 | backportAllLabels(context, pr); 496 | } 497 | } else { 498 | robot.log( 499 | `Automatic backport #${pr.number} closed with unmerged commits`, 500 | ); 501 | 502 | if (oldPRNumbers.length > 0) { 503 | robot.log(`Updating label on original PR for closed PR: #${pr.number}`); 504 | for (const oldPRNumber of oldPRNumbers) { 505 | await updateManualBackport(context, PRChange.CLOSE, oldPRNumber); 506 | } 507 | } 508 | 509 | if (pr.user.login === getEnvVar('BOT_USER_NAME')) { 510 | // If the closed PR is trop's own, remove labels 511 | // from the original PR and delete the base branch. 512 | await handleTropBackportClosed(context, pr, PRChange.CLOSE); 513 | } else { 514 | await handleClosedPRLabels(context, pr, PRChange.CLOSE); 515 | } 516 | } 517 | }); 518 | 519 | const TROP_COMMAND_PREFIX = '/trop '; 520 | 521 | // Manually trigger backporting process on trigger comment phrase. 522 | robot.on('issue_comment.created', async (context) => { 523 | const { issue, comment } = context.payload; 524 | 525 | const isPullRequest = (i: { number: number; html_url: string }) => 526 | i.html_url.endsWith(`/pull/${i.number}`); 527 | 528 | if (!isPullRequest(issue)) return; 529 | 530 | const cmd = comment.body; 531 | if (!cmd.startsWith(TROP_COMMAND_PREFIX)) return; 532 | 533 | // Allow all users with push access to handle backports. 534 | if (!(await isAuthorizedUser(context, comment.user.login))) { 535 | robot.log( 536 | `@${comment.user.login} is not authorized to run PR backports - stopping`, 537 | ); 538 | await context.octokit.issues.createComment( 539 | context.repo({ 540 | issue_number: issue.number, 541 | body: `@${comment.user.login} is not authorized to run PR backports.`, 542 | }), 543 | ); 544 | return; 545 | } 546 | 547 | const actualCmd = cmd.substr(TROP_COMMAND_PREFIX.length); 548 | 549 | const actions = [ 550 | { 551 | name: 'backport sanity checker', 552 | command: /^run backport/, 553 | execute: async () => { 554 | const pr = ( 555 | await context.octokit.pulls.get( 556 | context.repo({ pull_number: issue.number }), 557 | ) 558 | ).data; 559 | if (!pr.merged) { 560 | await context.octokit.issues.createComment( 561 | context.repo({ 562 | issue_number: issue.number, 563 | body: 'This PR has not been merged yet, and cannot be backported.', 564 | }), 565 | ); 566 | return false; 567 | } 568 | return true; 569 | }, 570 | }, 571 | { 572 | name: 'backport automatically', 573 | command: /^run backport$/, 574 | execute: async () => { 575 | const pr = ( 576 | await context.octokit.pulls.get( 577 | context.repo({ pull_number: issue.number }), 578 | ) 579 | ).data as WebHookPR; 580 | await context.octokit.issues.createComment( 581 | context.repo({ 582 | body: 'The backport process for this PR has been manually initiated - here we go! :D', 583 | issue_number: issue.number, 584 | }), 585 | ); 586 | backportAllLabels(context, pr); 587 | return true; 588 | }, 589 | }, 590 | { 591 | name: 'backport to branch', 592 | command: /^run backport-to (([^,]*)(, ?([^,]*))*)/, 593 | execute: async (targetBranches: string) => { 594 | const branches = new Set( 595 | targetBranches.split(',').map((b) => b.trim()), 596 | ); 597 | for (const branch of branches) { 598 | robot.log( 599 | `Attempting backport to \`${branch}\` from 'backport-to' comment`, 600 | ); 601 | 602 | const noEOLSupport = getEnvVar('NO_EOL_SUPPORT', ''); 603 | if (noEOLSupport) { 604 | const supported = await getSupportedBranches(context); 605 | if (!supported.includes(branch)) { 606 | robot.log(`${branch} is EOL - no backport will be initiated`); 607 | await context.octokit.issues.createComment( 608 | context.repo({ 609 | body: `Provided branch \`${branch}\` is EOL - no backport will be performed.`, 610 | issue_number: issue.number, 611 | }), 612 | ); 613 | continue; 614 | } 615 | } 616 | 617 | try { 618 | await context.octokit.repos.getBranch(context.repo({ branch })); 619 | } catch (err) { 620 | robot.log( 621 | `${branch} does not exist - no backport will be initiated`, 622 | ); 623 | await context.octokit.issues.createComment( 624 | context.repo({ 625 | body: `Provided branch \`${branch}\` does not appear to exist.`, 626 | issue_number: issue.number, 627 | }), 628 | ); 629 | continue; 630 | } 631 | 632 | robot.log( 633 | `Initiating manual backport process for #${issue.number} to ${branch}`, 634 | ); 635 | 636 | await context.octokit.issues.createComment( 637 | context.repo({ 638 | body: `The backport process for this PR has been manually initiated - sending your PR to \`${branch}\`!`, 639 | issue_number: issue.number, 640 | }), 641 | ); 642 | 643 | const { data: pr } = await context.octokit.pulls.get( 644 | context.repo({ pull_number: issue.number }), 645 | ); 646 | 647 | backportToBranch(robot, context, pr as WebHookPR, branch); 648 | } 649 | return true; 650 | }, 651 | }, 652 | ]; 653 | 654 | for (const action of actions) { 655 | const match = actualCmd.match(action.command); 656 | if (!match) continue; 657 | 658 | robot.log(`running action: ${action.name} for comment`); 659 | 660 | // @ts-ignore (false positive on next line arg count) 661 | if (!(await action.execute(...match.slice(1)))) { 662 | robot.log(`${action.name} failed, stopping responder chain`); 663 | break; 664 | } 665 | } 666 | }); 667 | }; 668 | 669 | export default probotHandler; 670 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import fetch from 'node-fetch'; 3 | import { execSync } from 'child_process'; 4 | import Queue from 'queue'; 5 | import simpleGit from 'simple-git'; 6 | 7 | import queue from './Queue'; 8 | import { 9 | BACKPORT_REQUESTED_LABEL, 10 | DEFAULT_BACKPORT_REVIEW_TEAM, 11 | BACKPORT_LABEL, 12 | CHECK_PREFIX, 13 | } from './constants'; 14 | import { PRStatus, BackportPurpose, LogLevel, PRChange } from './enums'; 15 | 16 | import * as labelUtils from './utils/label-utils'; 17 | import { initRepo } from './operations/init-repo'; 18 | import { setupRemotes } from './operations/setup-remotes'; 19 | import { backportCommitsToBranch } from './operations/backport-commits'; 20 | import { getRepoToken } from './utils/token-util'; 21 | import { getSupportedBranches, getBackportPattern } from './utils/branch-util'; 22 | import { getOrCreateCheckRun } from './utils/checks-util'; 23 | import { getEnvVar } from './utils/env-util'; 24 | import { log } from './utils/log-util'; 25 | import { TryBackportOptions } from './interfaces'; 26 | import { client, register } from './utils/prom'; 27 | import { 28 | SimpleWebHookRepoContext, 29 | WebHookIssueContext, 30 | WebHookPR, 31 | WebHookRepoContext, 32 | } from './types'; 33 | import { Probot } from 'probot'; 34 | 35 | const { parse: parseDiff } = require('what-the-diff'); 36 | 37 | const backportViaAllHisto = new client.Histogram({ 38 | name: 'backport_via_all', 39 | help: 'Successful backports via tryBackportAllCommits', 40 | buckets: [0, 100, 500, 1500, 3000, 5000, 10000], 41 | }); 42 | const backportViaSquashHisto = new client.Histogram({ 43 | name: 'backport_via_squash', 44 | help: 'Successful backports via tryBackportSquashCommit', 45 | buckets: [0, 100, 500, 1500, 3000, 5000, 10000], 46 | }); 47 | register.registerMetric(backportViaAllHisto); 48 | register.registerMetric(backportViaSquashHisto); 49 | 50 | export const labelClosedPR = async ( 51 | context: WebHookRepoContext, 52 | pr: WebHookPR, 53 | targetBranch: String, 54 | change: PRChange, 55 | ) => { 56 | log( 57 | 'labelClosedPR', 58 | LogLevel.INFO, 59 | `Labeling original PRs for PR at #${pr.number}`, 60 | ); 61 | 62 | const targetLabel = PRStatus.TARGET + targetBranch; 63 | 64 | if (change === PRChange.CLOSE) { 65 | await labelUtils.removeLabel(context, pr.number, targetLabel); 66 | } 67 | 68 | const backportNumbers = getPRNumbersFromPRBody(pr); 69 | for (const prNumber of backportNumbers) { 70 | const inFlightLabel = PRStatus.IN_FLIGHT + targetBranch; 71 | await labelUtils.removeLabel(context, prNumber, inFlightLabel); 72 | 73 | if (change === PRChange.MERGE) { 74 | const mergedLabel = PRStatus.MERGED + targetBranch; 75 | const needsManualLabel = PRStatus.NEEDS_MANUAL + targetBranch; 76 | 77 | // Add merged label to the original PR. 78 | await labelUtils.addLabels(context, prNumber, [mergedLabel]); 79 | 80 | // Remove the needs-manual-backport label from the original PR. 81 | await labelUtils.removeLabel(context, prNumber, needsManualLabel); 82 | 83 | // Remove the target label from the intermediate PR. 84 | await labelUtils.removeLabel(context, pr.number, targetLabel); 85 | } 86 | } 87 | }; 88 | 89 | const tryBackportAllCommits = async (opts: TryBackportOptions) => { 90 | log( 91 | 'backportImpl', 92 | LogLevel.INFO, 93 | `Getting rev list from: ${opts.pr.base.sha}..${opts.pr.head.sha}`, 94 | ); 95 | 96 | const { context } = opts; 97 | if (!context) return; 98 | 99 | const commits = ( 100 | await context.octokit.paginate( 101 | 'GET /repos/{owner}/{repo}/pulls/{pull_number}/commits', 102 | context.repo({ pull_number: opts.pr.number, per_page: 100 }), 103 | ) 104 | ).map((commit) => commit.sha); 105 | 106 | if (commits.length === 0) { 107 | log( 108 | 'backportImpl', 109 | LogLevel.INFO, 110 | 'Found no commits to backport - aborting backport process', 111 | ); 112 | return false; 113 | } 114 | 115 | // Over 240 commits is probably the limit from GitHub so let's not bother. 116 | if (commits.length >= 240) { 117 | log( 118 | 'backportImpl', 119 | LogLevel.ERROR, 120 | `Too many commits (${commits.length})...backport will not be performed.`, 121 | ); 122 | await context.octokit.issues.createComment( 123 | context.repo({ 124 | issue_number: opts.pr.number, 125 | body: 'This PR has exceeded the automatic backport commit limit \ 126 | and must be performed manually.', 127 | }), 128 | ); 129 | 130 | return false; 131 | } 132 | 133 | log( 134 | 'backportImpl', 135 | LogLevel.INFO, 136 | `Found ${commits.length} commits to backport - requesting details now.`, 137 | ); 138 | 139 | const patches: string[] = new Array(commits.length).fill(''); 140 | const q = new Queue({ concurrency: 5 }); 141 | q.stop(); 142 | 143 | for (const [i, commit] of commits.entries()) { 144 | q.push(async () => { 145 | const patchUrl = `https://api.github.com/repos/${opts.slug}/commits/${commit}`; 146 | const patchBody = await fetch(patchUrl, { 147 | headers: { 148 | Accept: 'application/vnd.github.VERSION.patch', 149 | Authorization: `token ${opts.repoAccessToken}`, 150 | }, 151 | }); 152 | patches[i] = await patchBody.text(); 153 | log( 154 | 'backportImpl', 155 | LogLevel.INFO, 156 | `Got patch (${i + 1}/${commits.length})`, 157 | ); 158 | }); 159 | } 160 | 161 | await new Promise((resolve, reject) => 162 | q.start((err) => (err ? reject(err) : resolve())), 163 | ); 164 | log('backportImpl', LogLevel.INFO, 'Got all commit info'); 165 | 166 | log( 167 | 'backportImpl', 168 | LogLevel.INFO, 169 | `Checking out target: "target_repo/${opts.targetBranch}" to temp: "${opts.tempBranch}"`, 170 | ); 171 | 172 | const success = await backportCommitsToBranch({ 173 | dir: opts.dir, 174 | slug: opts.slug, 175 | targetBranch: opts.targetBranch, 176 | tempBranch: opts.tempBranch, 177 | patches, 178 | targetRemote: 'target_repo', 179 | shouldPush: opts.purpose === BackportPurpose.ExecuteBackport, 180 | github: context.octokit, 181 | context, 182 | }); 183 | 184 | if (success) { 185 | log( 186 | 'backportImpl', 187 | LogLevel.INFO, 188 | 'Cherry pick success - pushed up to target_repo', 189 | ); 190 | } 191 | 192 | return success; 193 | }; 194 | 195 | const tryBackportSquashCommit = async (opts: TryBackportOptions) => { 196 | // Fetch the merged squash commit. 197 | log('backportImpl', LogLevel.INFO, `Fetching squash commit details`); 198 | 199 | if (!opts.pr.merged) { 200 | log('backportImpl', LogLevel.INFO, `PR was not squash merged - aborting`); 201 | return false; 202 | } 203 | 204 | const patchUrl = `https://api.github.com/repos/${opts.slug}/commits/${opts.pr.merge_commit_sha}`; 205 | const patchBody = await fetch(patchUrl, { 206 | headers: { 207 | Accept: 'application/vnd.github.VERSION.patch', 208 | Authorization: `token ${opts.repoAccessToken}`, 209 | }, 210 | }); 211 | 212 | const rawPatch = await patchBody.text(); 213 | let patch: string = ''; 214 | let subjectLineFound = false; 215 | for (const patchLine of rawPatch.split('\n')) { 216 | if (patchLine.startsWith('Subject: ') && !subjectLineFound) { 217 | subjectLineFound = true; 218 | const branchAwarePatchLine = patchLine 219 | // Replace branch references in commit message with new branch 220 | .replaceAll(`(${opts.pr.base.ref})`, `${opts.targetBranch}`) 221 | // Replace PR references in squashed message with empty string 222 | .replaceAll(/ \(#[0-9]+\)$/g, ''); 223 | patch += `${branchAwarePatchLine}\n`; 224 | } else { 225 | patch += `${patchLine}\n`; 226 | } 227 | } 228 | 229 | log('backportImpl', LogLevel.INFO, 'Got squash commit details'); 230 | 231 | log( 232 | 'backportImpl', 233 | LogLevel.INFO, 234 | `Checking out target: "target_repo/${opts.targetBranch}" to temp: "${opts.tempBranch}"`, 235 | ); 236 | 237 | const success = await backportCommitsToBranch({ 238 | dir: opts.dir, 239 | slug: opts.slug, 240 | targetBranch: opts.targetBranch, 241 | tempBranch: opts.tempBranch, 242 | patches: [patch], 243 | targetRemote: 'target_repo', 244 | shouldPush: opts.purpose === BackportPurpose.ExecuteBackport, 245 | github: opts.context.octokit, 246 | context: opts.context, 247 | }); 248 | 249 | if (success) { 250 | log( 251 | 'backportImpl', 252 | LogLevel.INFO, 253 | 'Cherry pick success - pushed up to target_repo', 254 | ); 255 | } 256 | 257 | return success; 258 | }; 259 | 260 | export const isAuthorizedUser = async ( 261 | context: WebHookIssueContext, 262 | username: string, 263 | ) => { 264 | const { data } = await context.octokit.repos.getCollaboratorPermissionLevel( 265 | context.repo({ 266 | username, 267 | }), 268 | ); 269 | 270 | return ['admin', 'write'].includes(data.permission); 271 | }; 272 | 273 | export const getPRNumbersFromPRBody = (pr: WebHookPR, checkNotBot = false) => { 274 | const backportNumbers: number[] = []; 275 | 276 | const isBot = pr.user.login === getEnvVar('BOT_USER_NAME'); 277 | if (checkNotBot && isBot) return backportNumbers; 278 | 279 | let match: RegExpExecArray | null; 280 | const backportPattern = getBackportPattern(); 281 | while ((match = backportPattern.exec(pr.body || ''))) { 282 | // This might be the first or second capture group depending on if it's a link or not. 283 | backportNumbers.push( 284 | match[1] ? parseInt(match[1], 10) : parseInt(match[2], 10), 285 | ); 286 | } 287 | 288 | return backportNumbers; 289 | }; 290 | 291 | /** 292 | * 293 | * It can be the case that someone marks a PR for backporting via label or comment 294 | * which it *itself* a backport. 295 | * 296 | * In this case, we should ensure that the PR being passed is the original backport. 297 | * If it isn't, we should traverse via "Backport of #12345" links in each nested 298 | * backport until we arrive at the backport which is the original to ensure 299 | * optimal bookkeeping. 300 | * 301 | * TODO(codebytere): support multi-backports. 302 | * 303 | * @param context Context 304 | * @param pr Pull Request 305 | */ 306 | const getOriginalBackportNumber = async ( 307 | context: SimpleWebHookRepoContext, 308 | pr: WebHookPR, 309 | ) => { 310 | let originalPR: Pick = pr; 311 | let match: RegExpExecArray | null; 312 | 313 | const backportPattern = getBackportPattern(); 314 | while ((match = backportPattern.exec(originalPR.body || ''))) { 315 | // This might be the first or second capture group depending on if it's a link or not. 316 | const oldPRNumber = match[1] 317 | ? parseInt(match[1], 10) 318 | : parseInt(match[2], 10); 319 | 320 | // Fetch the PR body this PR is marked as backporting. 321 | const { data: pullRequest } = await context.octokit.pulls.get({ 322 | owner: pr.base.repo.owner.login, 323 | repo: pr.base.repo.name, 324 | pull_number: oldPRNumber, 325 | }); 326 | 327 | originalPR = pullRequest; 328 | } 329 | 330 | return originalPR.number; 331 | }; 332 | 333 | export const isSemverMinorPR = async ( 334 | context: SimpleWebHookRepoContext, 335 | pr: WebHookPR, 336 | ) => { 337 | log( 338 | 'isSemverMinorPR', 339 | LogLevel.INFO, 340 | `Checking if #${pr.number} is semver-minor`, 341 | ); 342 | const SEMVER_MINOR_LABEL = 'semver-minor'; 343 | 344 | const hasPrefix = pr.title.startsWith('feat:'); 345 | const hasLabel = await labelUtils.labelExistsOnPR( 346 | context, 347 | pr.number, 348 | SEMVER_MINOR_LABEL, 349 | ); 350 | 351 | return hasLabel || hasPrefix; 352 | }; 353 | 354 | const checkUserHasWriteAccess = async ( 355 | context: SimpleWebHookRepoContext, 356 | user: string, 357 | ) => { 358 | log( 359 | 'checkUserHasWriteAccess', 360 | LogLevel.INFO, 361 | `Checking whether ${user} has write access`, 362 | ); 363 | 364 | const params = context.repo({ username: user }); 365 | const { data: userInfo } = 366 | await context.octokit.repos.getCollaboratorPermissionLevel(params); 367 | 368 | // Possible values for the permission key: 'admin', 'write', 'read', 'none'. 369 | // In order for the user's review to count, they must be at least 'write'. 370 | return ['write', 'admin'].includes(userInfo.permission); 371 | }; 372 | 373 | const createBackportComment = async ( 374 | context: SimpleWebHookRepoContext, 375 | pr: WebHookPR, 376 | ) => { 377 | const prNumber = await getOriginalBackportNumber(context, pr); 378 | 379 | log( 380 | 'createBackportComment', 381 | LogLevel.INFO, 382 | `Creating backport comment for #${prNumber}`, 383 | ); 384 | 385 | let body = `Backport of #${prNumber}\n\nSee that PR for details.`; 386 | 387 | const onelineMatch = pr.body?.match( 388 | /(?:(?:\r?\n)|^)notes: (.+?)(?:(?:\r?\n)|$)/gi, 389 | ); 390 | const multilineMatch = pr.body?.match( 391 | /(?:(?:\r?\n)Notes:(?:\r?\n)((?:\*.+(?:(?:\r?\n)|$))+))/gi, 392 | ); 393 | 394 | // attach release notes to backport PR body 395 | if (onelineMatch && onelineMatch[0]) { 396 | body += `\n\n${onelineMatch[0]}`; 397 | } else if (multilineMatch && multilineMatch[0]) { 398 | body += `\n\n${multilineMatch[0]}`; 399 | } else { 400 | body += '\n\nNotes: no-notes'; 401 | } 402 | 403 | return body; 404 | }; 405 | 406 | export const tagBackportReviewers = async ({ 407 | context, 408 | targetPrNumber, 409 | user, 410 | }: { 411 | context: SimpleWebHookRepoContext; 412 | targetPrNumber: number; 413 | user?: string; 414 | }) => { 415 | const reviewers = []; 416 | const teamReviewers = []; 417 | 418 | if (DEFAULT_BACKPORT_REVIEW_TEAM) { 419 | // Optionally request a default review team for backports. 420 | // Use team slug value. i.e electron/wg-releases => wg-releases 421 | const slug = 422 | DEFAULT_BACKPORT_REVIEW_TEAM.split('/')[1] || 423 | DEFAULT_BACKPORT_REVIEW_TEAM; 424 | teamReviewers.push(slug); 425 | } 426 | 427 | if (user) { 428 | const hasWrite = await checkUserHasWriteAccess(context, user); 429 | // If the PR author has write access, also request their review. 430 | if (hasWrite) reviewers.push(user); 431 | } 432 | 433 | if (Math.max(reviewers.length, teamReviewers.length) > 0) { 434 | try { 435 | await context.octokit.pulls.requestReviewers( 436 | context.repo({ 437 | pull_number: targetPrNumber, 438 | reviewers, 439 | team_reviewers: teamReviewers, 440 | }), 441 | ); 442 | } catch (error) { 443 | log( 444 | 'tagBackportReviewers', 445 | LogLevel.ERROR, 446 | `Failed to request reviewers for PR #${targetPrNumber}`, 447 | error, 448 | ); 449 | } 450 | } 451 | }; 452 | 453 | export const backportImpl = async ( 454 | robot: Probot, 455 | context: SimpleWebHookRepoContext, 456 | pr: WebHookPR, 457 | targetBranch: string, 458 | purpose: BackportPurpose, 459 | labelToRemove?: string, 460 | labelToAdd?: string, 461 | ) => { 462 | // Optionally disallow backports to EOL branches 463 | const noEOLSupport = getEnvVar('NO_EOL_SUPPORT', ''); 464 | if (noEOLSupport) { 465 | const supported = await getSupportedBranches(context); 466 | const defaultBranch = context.payload.repository.default_branch; 467 | if (![defaultBranch, ...supported].includes(targetBranch)) { 468 | log( 469 | 'backportImpl', 470 | LogLevel.WARN, 471 | `${targetBranch} is no longer supported - no backport will be initiated.`, 472 | ); 473 | await context.octokit.issues.createComment( 474 | context.repo({ 475 | body: `${targetBranch} is no longer supported - no backport will be initiated.`, 476 | issue_number: pr.number, 477 | }), 478 | ); 479 | return; 480 | } 481 | } 482 | 483 | const gitExists = execSync('which git', { encoding: 'utf-8' }).trim(); 484 | if (/git not found/.test(gitExists)) { 485 | await context.octokit.issues.createComment( 486 | context.repo({ 487 | body: `Git not found - unable to proceed with backporting to ${targetBranch}`, 488 | issue_number: pr.number, 489 | }), 490 | ); 491 | return; 492 | } 493 | 494 | const base = pr.base; 495 | const slug = `${base.repo.owner.login}/${base.repo.name}`; 496 | const bp = `backport from PR #${pr.number} to "${targetBranch}"`; 497 | log('backportImpl', LogLevel.INFO, `Queuing ${bp} for "${slug}"`); 498 | 499 | let createdDir: string | null = null; 500 | 501 | queue.enterQueue( 502 | `backport-${pr.head.sha}-${targetBranch}-${purpose}`, 503 | async () => { 504 | log('backportImpl', LogLevel.INFO, `Executing ${bp} for "${slug}"`); 505 | const checkRun = await getOrCreateCheckRun(context, pr, targetBranch); 506 | log( 507 | 'backportImpl', 508 | LogLevel.INFO, 509 | `Updating check run '${CHECK_PREFIX}${targetBranch}' (${checkRun.id}) with status 'in_progress'`, 510 | ); 511 | await context.octokit.checks.update( 512 | context.repo({ 513 | check_run_id: checkRun.id, 514 | name: checkRun.name, 515 | status: 'in_progress' as 'in_progress', 516 | }), 517 | ); 518 | 519 | const repoAccessToken = await getRepoToken(robot, context); 520 | 521 | // Set up empty repo on main. 522 | const { dir } = await initRepo({ 523 | slug, 524 | accessToken: repoAccessToken, 525 | }); 526 | createdDir = dir; 527 | log('backportImpl', LogLevel.INFO, `Working directory cleaned: ${dir}`); 528 | 529 | const targetRepoRemote = `https://x-access-token:${repoAccessToken}@github.com/${slug}.git`; 530 | await setupRemotes({ 531 | dir, 532 | remotes: [ 533 | { 534 | name: 'target_repo', 535 | value: targetRepoRemote, 536 | }, 537 | ], 538 | }); 539 | 540 | // Create temporary branch name. 541 | const sanitizedTitle = pr.title 542 | .replace(/\*/g, 'x') 543 | .toLowerCase() 544 | .replace(/[^a-z0-9_]+/g, '-'); 545 | const tempBranch = `trop/${targetBranch}-bp-${sanitizedTitle}-${Date.now()}`; 546 | 547 | // First try to backport all commits in the original PR. 548 | const end = backportViaAllHisto.startTimer(); 549 | let success = await tryBackportAllCommits({ 550 | context, 551 | repoAccessToken, 552 | purpose, 553 | pr, 554 | dir, 555 | slug, 556 | targetBranch, 557 | tempBranch, 558 | }); 559 | end(); 560 | 561 | // If that fails, try to backport the squash commit. 562 | if (!success) { 563 | const end = backportViaSquashHisto.startTimer(); 564 | success = await tryBackportSquashCommit({ 565 | context, 566 | repoAccessToken, 567 | purpose, 568 | pr, 569 | dir, 570 | slug, 571 | targetBranch, 572 | tempBranch, 573 | }); 574 | end(); 575 | } 576 | 577 | console.log( 578 | JSON.stringify({ 579 | msg: 'backport-result', 580 | pullRequest: pr.number, 581 | backportPurpose: purpose, 582 | success, 583 | }), 584 | ); 585 | 586 | // Throw if neither succeeded - if we don't we 587 | // never enter the ErrorExecutor and the check hangs. 588 | if (!success) { 589 | log( 590 | 'backportImpl', 591 | LogLevel.ERROR, 592 | `Cherry picking commits to branch failed`, 593 | ); 594 | 595 | throw new Error(`Cherry picking commit(s) to branch failed`); 596 | } 597 | 598 | if (purpose === BackportPurpose.ExecuteBackport) { 599 | log('backportImpl', LogLevel.INFO, 'Creating Pull Request'); 600 | 601 | const branchAwarePrTitle = pr.title.replaceAll( 602 | `(${pr.base.ref})`, 603 | `(${targetBranch})`, 604 | ); 605 | 606 | const { data: newPr } = await context.octokit.pulls.create( 607 | context.repo({ 608 | head: `${tempBranch}`, 609 | base: targetBranch, 610 | title: branchAwarePrTitle, 611 | body: await createBackportComment(context, pr), 612 | maintainer_can_modify: false, 613 | }), 614 | ); 615 | 616 | await tagBackportReviewers({ 617 | context, 618 | targetPrNumber: newPr.number, 619 | user: pr.user.login, 620 | }); 621 | 622 | log( 623 | 'backportImpl', 624 | LogLevel.INFO, 625 | `Adding breadcrumb comment to ${pr.number}`, 626 | ); 627 | await context.octokit.issues.createComment( 628 | context.repo({ 629 | issue_number: pr.number, 630 | body: `I have automatically backported this PR to "${targetBranch}", \ 631 | please check out #${newPr.number}`, 632 | }), 633 | ); 634 | 635 | // TODO(codebytere): getOriginalBackportNumber doesn't support multi-backports yet, 636 | // so only try if the backport is a single backport. 637 | const backportNumbers = getPRNumbersFromPRBody(pr); 638 | const originalPRNumber = 639 | backportNumbers.length === 1 640 | ? await getOriginalBackportNumber(context, pr) 641 | : pr.number; 642 | 643 | if (labelToAdd) { 644 | await labelUtils.addLabels(context, originalPRNumber, [labelToAdd]); 645 | } 646 | 647 | if (labelToRemove) { 648 | await labelUtils.removeLabel( 649 | context, 650 | originalPRNumber, 651 | labelToRemove, 652 | ); 653 | } else if (labelToAdd?.startsWith(PRStatus.IN_FLIGHT)) { 654 | await labelUtils.removeLabel( 655 | context, 656 | originalPRNumber, 657 | `${PRStatus.NEEDS_MANUAL}${targetBranch}`, 658 | ); 659 | } 660 | 661 | const labelsToAdd = [BACKPORT_LABEL, `${targetBranch}`]; 662 | 663 | if (await isSemverMinorPR(context, pr)) { 664 | log( 665 | 'backportImpl', 666 | LogLevel.INFO, 667 | `Determined that ${pr.number} is semver-minor`, 668 | ); 669 | labelsToAdd.push(BACKPORT_REQUESTED_LABEL); 670 | } 671 | 672 | const semverLabel = labelUtils.getSemverLabel(pr); 673 | if (semverLabel) { 674 | // If the new PR for some reason has a semver label already, then 675 | // we need to compare the two semver labels and ensure the higher one 676 | // takes precedence. 677 | const newPRSemverLabel = labelUtils.getSemverLabel(newPr); 678 | if (newPRSemverLabel && newPRSemverLabel.name !== semverLabel.name) { 679 | const higherLabel = labelUtils.getHighestSemverLabel( 680 | semverLabel.name, 681 | newPRSemverLabel.name, 682 | ); 683 | // The existing label is lower precedence - remove and replace it. 684 | if (higherLabel === semverLabel.name) { 685 | await labelUtils.removeLabel( 686 | context, 687 | newPr.number, 688 | newPRSemverLabel.name, 689 | ); 690 | labelsToAdd.push(semverLabel.name); 691 | } 692 | } else { 693 | labelsToAdd.push(semverLabel.name); 694 | } 695 | } 696 | 697 | await labelUtils.addLabels(context, newPr.number, labelsToAdd); 698 | 699 | log('backportImpl', LogLevel.INFO, 'Backport process complete'); 700 | } 701 | 702 | log( 703 | 'backportImpl', 704 | LogLevel.INFO, 705 | `Updating check run '${CHECK_PREFIX}${targetBranch}' (${checkRun.id}) with conclusion 'success'`, 706 | ); 707 | 708 | await context.octokit.checks.update( 709 | context.repo({ 710 | check_run_id: checkRun.id, 711 | name: checkRun.name, 712 | conclusion: 'success' as 'success', 713 | completed_at: new Date().toISOString(), 714 | output: { 715 | title: 'Clean Backport', 716 | summary: `This PR was checked and can be backported to "${targetBranch}" cleanly.`, 717 | }, 718 | }), 719 | ); 720 | 721 | await fs.promises.rm(createdDir, { force: true, recursive: true }); 722 | }, 723 | async () => { 724 | let annotations: unknown[] | null = null; 725 | let diff; 726 | let rawDiff; 727 | if (createdDir) { 728 | const git = simpleGit(createdDir); 729 | rawDiff = await git.diff(); 730 | diff = parseDiff(rawDiff); 731 | 732 | annotations = []; 733 | for (const file of diff) { 734 | if (file.binary) continue; 735 | 736 | for (const hunk of file.hunks || []) { 737 | const startOffset = hunk.lines.findIndex((line: string) => 738 | line.includes('<<<<<<<'), 739 | ); 740 | const endOffset = 741 | hunk.lines.findIndex((line: string) => line.includes('=======')) - 742 | 2; 743 | const finalOffset = hunk.lines.findIndex((line: string) => 744 | line.includes('>>>>>>>'), 745 | ); 746 | annotations.push({ 747 | path: file.filePath, 748 | start_line: hunk.theirStartLine + Math.max(0, startOffset), 749 | end_line: hunk.theirStartLine + Math.max(0, endOffset), 750 | annotation_level: 'failure', 751 | message: 'Patch Conflict', 752 | raw_details: hunk.lines 753 | .filter( 754 | (_: unknown, i: number) => 755 | i >= startOffset && i <= finalOffset, 756 | ) 757 | .join('\n'), 758 | }); 759 | } 760 | } 761 | 762 | await fs.promises.rm(createdDir, { force: true, recursive: true }); 763 | } 764 | 765 | if (purpose === BackportPurpose.ExecuteBackport) { 766 | await context.octokit.issues.createComment( 767 | context.repo({ 768 | issue_number: pr.number, 769 | body: `I was unable to backport this PR to "${targetBranch}" cleanly; 770 | you will need to perform this [backport manually](https://github.com/electron/trop/blob/main/docs/manual-backports.md#manual-backports).`, 771 | }), 772 | ); 773 | 774 | const labelToRemove = PRStatus.TARGET + targetBranch; 775 | await labelUtils.removeLabel(context, pr.number, labelToRemove); 776 | 777 | const labelToAdd = PRStatus.NEEDS_MANUAL + targetBranch; 778 | const originalBackportNumber = await getOriginalBackportNumber( 779 | context, 780 | pr, 781 | ); 782 | await labelUtils.addLabels(context, originalBackportNumber, [ 783 | labelToAdd, 784 | ]); 785 | } 786 | 787 | const checkRun = await getOrCreateCheckRun(context, pr, targetBranch); 788 | const mdSep = '``````````````````````````````'; 789 | const updateOpts = context.repo({ 790 | check_run_id: checkRun.id, 791 | name: checkRun.name, 792 | conclusion: 'neutral' as 'neutral', 793 | completed_at: new Date().toISOString(), 794 | output: { 795 | title: 'Backport Failed', 796 | summary: `This PR was checked and could not be automatically backported to "${targetBranch}" cleanly`, 797 | text: diff 798 | ? `Failed Diff:\n\n${mdSep}diff\n${rawDiff}\n${mdSep}` 799 | : undefined, 800 | annotations: annotations ? annotations : undefined, 801 | }, 802 | }); 803 | log( 804 | 'backportImpl', 805 | LogLevel.INFO, 806 | `Updating check run '${CHECK_PREFIX}${targetBranch}' (${checkRun.id}) with conclusion 'neutral'`, 807 | ); 808 | try { 809 | await context.octokit.checks.update(updateOpts); 810 | } catch (err) { 811 | // A GitHub error occurred - try to mark it as a failure without annotations. 812 | updateOpts.output!.annotations = undefined; 813 | await context.octokit.checks.update(updateOpts); 814 | } 815 | }, 816 | ); 817 | }; 818 | -------------------------------------------------------------------------------- /spec/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import { posix as path } from 'path'; 3 | import { execSync } from 'child_process'; 4 | 5 | import nock from 'nock'; 6 | import { Probot, ProbotOctokit } from 'probot'; 7 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 8 | 9 | import { 10 | BACKPORT_APPROVAL_CHECK, 11 | BACKPORT_APPROVED_LABEL, 12 | BACKPORT_REQUESTED_LABEL, 13 | SKIP_CHECK_LABEL, 14 | } from '../src/constants'; 15 | import { CheckRunStatus, PRChange } from '../src/enums'; 16 | import { default as trop } from '../src/index'; 17 | import { 18 | backportToBranch, 19 | backportToLabel, 20 | } from '../src/operations/backport-to-location'; 21 | import { updateManualBackport } from '../src/operations/update-manual-backport'; 22 | 23 | import { labelClosedPR, getPRNumbersFromPRBody } from '../src/utils'; 24 | import * as checkUtils from '../src/utils/checks-util'; 25 | 26 | // event fixtures 27 | const prClosedEvent = require('./fixtures/pull_request.closed.json'); 28 | const issueCommentBackportCreatedEvent = require('./fixtures/issue_comment_backport.created.json'); 29 | const issueCommentBackportToCreatedEvent = require('./fixtures/issue_comment_backport_to.created.json'); 30 | const issueCommentBackportToMultipleCreatedEvent = require('./fixtures/issue_comment_backport_to_multiple.created.json'); 31 | const issueCommentBackportToMultipleCreatedSpacesEvent = require('./fixtures/issue_comment_backport_to_multiple_spaces.created.json'); 32 | 33 | const backportPRMergedBotEvent = require('./fixtures/backport_pull_request.merged.bot.json'); 34 | const backportPRClosedBotEvent = require('./fixtures/backport_pull_request.closed.bot.json'); 35 | const backportPRMergedEvent = require('./fixtures/backport_pull_request.merged.json'); 36 | const backportPRClosedEvent = require('./fixtures/backport_pull_request.closed.json'); 37 | const backportPROpenedEvent = require('./fixtures/backport_pull_request.opened.json'); 38 | 39 | const newPROpenedEventPath = path.join( 40 | __dirname, 41 | 'fixtures', 42 | 'pull_request.opened.json', 43 | ); 44 | 45 | const prLabeledEventPath = path.join( 46 | __dirname, 47 | 'fixtures', 48 | 'pull_request.labeled.json', 49 | ); 50 | 51 | const prUnlabeledEventPath = path.join( 52 | __dirname, 53 | 'fixtures', 54 | 'pull_request.labeled.json', 55 | ); 56 | 57 | const newPRBackportOpenedEventPath = path.join( 58 | __dirname, 59 | 'fixtures', 60 | 'backport_pull_request.opened.json', 61 | ); 62 | 63 | const backportPRLabeledEventPath = path.join( 64 | __dirname, 65 | 'fixtures', 66 | 'backport_pull_request.labeled.json', 67 | ); 68 | 69 | const backportPRUnlabeledEventPath = path.join( 70 | __dirname, 71 | 'fixtures', 72 | 'backport_pull_request.unlabeled.json', 73 | ); 74 | 75 | const noBackportLabel = { 76 | name: 'no-backport', 77 | color: '000', 78 | }; 79 | 80 | const targetLabel = { 81 | name: 'target/12-x-y', 82 | color: 'fff', 83 | }; 84 | 85 | const backportApprovedLabel = { 86 | name: BACKPORT_APPROVED_LABEL, 87 | color: 'fff', 88 | }; 89 | 90 | const backportRequestedLabel = { 91 | name: BACKPORT_REQUESTED_LABEL, 92 | color: 'fff', 93 | }; 94 | 95 | vi.mock('../src/utils', () => ({ 96 | labelClosedPR: vi.fn(), 97 | isAuthorizedUser: vi.fn().mockResolvedValue([true]), 98 | getPRNumbersFromPRBody: vi.fn().mockReturnValue([12345]), 99 | })); 100 | 101 | vi.mock('../src/utils/env-util', () => ({ 102 | getEnvVar: vi.fn(), 103 | })); 104 | 105 | vi.mock('../src/operations/update-manual-backport', () => ({ 106 | updateManualBackport: vi.fn(), 107 | })); 108 | 109 | vi.mock('../src/operations/backport-to-location', () => ({ 110 | backportToBranch: vi.fn(), 111 | backportToLabel: vi.fn(), 112 | })); 113 | 114 | const getBackportApprovalCheck = vi.hoisted(() => { 115 | return vi.fn().mockResolvedValue({ status: 'completed' }); 116 | }); 117 | 118 | vi.mock('../src/utils/checks-util', () => ({ 119 | updateBackportValidityCheck: vi.fn(), 120 | getBackportInformationCheck: vi.fn().mockResolvedValue({ status: 'thing' }), 121 | updateBackportInformationCheck: vi.fn().mockResolvedValue(undefined), 122 | queueBackportInformationCheck: vi.fn().mockResolvedValue(undefined), 123 | getBackportApprovalCheck, 124 | updateBackportApprovalCheck: vi.fn().mockResolvedValue(undefined), 125 | queueBackportApprovalCheck: vi.fn().mockResolvedValue(undefined), 126 | })); 127 | 128 | describe('trop', () => { 129 | let robot: Probot; 130 | let octokit: any; 131 | process.env = { ...process.env, BOT_USER_NAME: 'trop[bot]' }; 132 | 133 | beforeEach(() => { 134 | nock.disableNetConnect(); 135 | octokit = { 136 | repos: { 137 | getBranch: vi.fn().mockResolvedValue(undefined), 138 | listBranches: vi.fn().mockResolvedValue({ 139 | data: [{ name: '8-x-y' }, { name: '7-1-x' }], 140 | }), 141 | }, 142 | git: { 143 | deleteRef: vi.fn().mockResolvedValue(undefined), 144 | }, 145 | pulls: { 146 | get: vi.fn().mockResolvedValue({ 147 | data: { 148 | merged: true, 149 | head: { 150 | sha: '6dcb09b5b57875f334f61aebed695e2e4193db5e', 151 | }, 152 | base: { 153 | ref: 'main', 154 | repo: { 155 | default_branch: 'main', 156 | }, 157 | }, 158 | labels: [ 159 | { 160 | url: 'my_cool_url', 161 | name: 'target/X-X-X', 162 | color: 'fc2929', 163 | }, 164 | ], 165 | }, 166 | }), 167 | }, 168 | issues: { 169 | addLabels: vi.fn().mockResolvedValue({}), 170 | removeLabel: vi.fn().mockResolvedValue({}), 171 | createLabel: vi.fn().mockResolvedValue({}), 172 | createComment: vi.fn().mockResolvedValue({}), 173 | listLabelsOnIssue: vi.fn().mockResolvedValue({ 174 | data: [ 175 | { 176 | id: 208045946, 177 | url: 'https://api.github.com/repos/octocat/Hello-World/labels/bug', 178 | name: 'bug', 179 | description: "Something isn't working", 180 | color: 'f29513', 181 | }, 182 | ], 183 | }), 184 | }, 185 | checks: { 186 | listForRef: vi.fn().mockResolvedValue({ data: { check_runs: [] } }), 187 | create: vi.fn().mockResolvedValue({ data: vi.fn() }), 188 | }, 189 | }; 190 | 191 | robot = new Probot({ 192 | githubToken: 'test', 193 | Octokit: ProbotOctokit.defaults({ 194 | retry: { enabled: false }, 195 | throttle: { enabled: false }, 196 | }), 197 | }); 198 | robot['state'].octokit.auth = () => Promise.resolve(octokit); 199 | robot.auth = () => Promise.resolve(octokit); 200 | robot.load(trop); 201 | }); 202 | 203 | afterEach(() => { 204 | nock.cleanAll(); 205 | nock.enableNetConnect(); 206 | }); 207 | 208 | describe('issue_comment.created event', () => { 209 | it('manually triggers the backport on comment', async () => { 210 | await robot.receive(issueCommentBackportCreatedEvent); 211 | 212 | expect(octokit.pulls.get).toHaveBeenCalled(); 213 | expect(octokit.issues.createComment).toHaveBeenCalled(); 214 | expect(backportToLabel).toHaveBeenCalled(); 215 | }); 216 | 217 | it('does not trigger the backport on comment if the PR is not merged', async () => { 218 | octokit.pulls.get = vi 219 | .fn() 220 | .mockResolvedValue({ data: { merged: false } }); 221 | 222 | await robot.receive(issueCommentBackportCreatedEvent); 223 | 224 | expect(octokit.pulls.get).toHaveBeenCalled(); 225 | expect(octokit.issues.createComment).toHaveBeenCalled(); 226 | expect(backportToLabel).not.toHaveBeenCalled(); 227 | }); 228 | 229 | it('triggers the backport on comment to a targeted branch', async () => { 230 | await robot.receive(issueCommentBackportToCreatedEvent); 231 | 232 | expect(octokit.pulls.get).toHaveBeenCalled(); 233 | expect(octokit.issues.createComment).toHaveBeenCalled(); 234 | expect(backportToBranch).toHaveBeenCalled(); 235 | }); 236 | 237 | it('allows for multiple PRs to be triggered in the same comment', async () => { 238 | await robot.receive(issueCommentBackportToMultipleCreatedEvent); 239 | 240 | expect(octokit.pulls.get).toHaveBeenCalledTimes(3); 241 | expect(octokit.issues.createComment).toHaveBeenCalledTimes(2); 242 | expect(backportToBranch).toHaveBeenCalledTimes(2); 243 | }); 244 | 245 | it('allows for multiple PRs to be triggered in the same comment with space-separated branches', async () => { 246 | await robot.receive(issueCommentBackportToMultipleCreatedSpacesEvent); 247 | 248 | expect(octokit.pulls.get).toHaveBeenCalledTimes(4); 249 | expect(octokit.issues.createComment).toHaveBeenCalledTimes(3); 250 | expect(backportToBranch).toHaveBeenCalledTimes(3); 251 | }); 252 | 253 | it('does not trigger the backport on comment to a targeted branch if the branch does not exist', async () => { 254 | octokit.repos.getBranch = vi 255 | .fn() 256 | .mockReturnValue(Promise.reject(new Error('404'))); 257 | await robot.receive(issueCommentBackportToCreatedEvent); 258 | 259 | expect(octokit.pulls.get).toHaveBeenCalled(); 260 | expect(octokit.issues.createComment).toHaveBeenCalled(); 261 | expect(backportToBranch).toHaveBeenCalledTimes(0); 262 | }); 263 | }); 264 | 265 | describe('pull_request.opened event', () => { 266 | it('labels the original PR when a manual backport PR has been opened', async () => { 267 | await robot.receive(backportPROpenedEvent); 268 | 269 | expect(updateManualBackport).toHaveBeenCalled(); 270 | }); 271 | 272 | it('queues the check if there is no backport information for a new PR', async () => { 273 | const event = JSON.parse( 274 | await fs.readFile(newPROpenedEventPath, 'utf-8'), 275 | ); 276 | 277 | event.payload.pull_request.labels = []; 278 | 279 | await robot.receive(event); 280 | 281 | expect(checkUtils.queueBackportInformationCheck).toHaveBeenCalledTimes(1); 282 | }); 283 | 284 | it('fails the check if there is conflicting backport information in a new PR', async () => { 285 | vi.mocked(getPRNumbersFromPRBody).mockReturnValueOnce([]); 286 | 287 | const event = JSON.parse( 288 | await fs.readFile(newPROpenedEventPath, 'utf-8'), 289 | ); 290 | 291 | event.payload.pull_request.labels = [noBackportLabel, targetLabel]; 292 | 293 | await robot.receive(event); 294 | 295 | const updatePayload = vi.mocked(checkUtils.updateBackportInformationCheck) 296 | .mock.calls[0][2]; 297 | 298 | expect(updatePayload).toMatchObject({ 299 | title: 'Conflicting Backport Information', 300 | summary: 301 | 'The PR has a "no-backport" and at least one "target/" label. Impossible to determine backport action.', 302 | conclusion: CheckRunStatus.FAILURE, 303 | }); 304 | }); 305 | 306 | it('passes the check if there is a "no-backport" label and no "target/" label in a new PR', async () => { 307 | vi.mocked(getPRNumbersFromPRBody).mockReturnValueOnce([]); 308 | 309 | const event = JSON.parse( 310 | await fs.readFile(newPROpenedEventPath, 'utf-8'), 311 | ); 312 | 313 | event.payload.pull_request.labels = [noBackportLabel]; 314 | 315 | await robot.receive(event); 316 | 317 | const updatePayload = vi.mocked(checkUtils.updateBackportInformationCheck) 318 | .mock.calls[0][2]; 319 | 320 | expect(updatePayload).toMatchObject({ 321 | title: 'Backport Information Provided', 322 | summary: 'This PR contains the required backport information.', 323 | conclusion: CheckRunStatus.SUCCESS, 324 | }); 325 | }); 326 | 327 | it('passes the check if there is no "no-backport" label and a "target/" label in a new PR', async () => { 328 | vi.mocked(getPRNumbersFromPRBody).mockReturnValueOnce([]); 329 | 330 | const event = JSON.parse( 331 | await fs.readFile(newPROpenedEventPath, 'utf-8'), 332 | ); 333 | 334 | event.payload.pull_request.labels = [targetLabel]; 335 | 336 | await robot.receive(event); 337 | 338 | const updatePayload = vi.mocked(checkUtils.updateBackportInformationCheck) 339 | .mock.calls[0][2]; 340 | 341 | expect(updatePayload).toMatchObject({ 342 | title: 'Backport Information Provided', 343 | summary: 'This PR contains the required backport information.', 344 | conclusion: CheckRunStatus.SUCCESS, 345 | }); 346 | }); 347 | 348 | it('skips the backport approval check if PR is not a backport', async () => { 349 | const event = JSON.parse( 350 | await fs.readFile(newPROpenedEventPath, 'utf-8'), 351 | ); 352 | 353 | await robot.receive(event); 354 | 355 | expect(checkUtils.queueBackportApprovalCheck).not.toHaveBeenCalled(); 356 | expect(checkUtils.updateBackportApprovalCheck).not.toHaveBeenCalled(); 357 | }); 358 | 359 | it('passes the backport approval check if the "backport/requested" label is not on new backport PR', async () => { 360 | await robot.receive(backportPROpenedEvent); 361 | 362 | const updatePayload = vi.mocked(checkUtils.updateBackportApprovalCheck) 363 | .mock.calls[0][2]; 364 | 365 | expect(updatePayload).toMatchObject({ 366 | title: 'Backport Approval Not Required', 367 | summary: `This PR does not need backport approval.`, 368 | conclusion: CheckRunStatus.SUCCESS, 369 | }); 370 | }); 371 | 372 | it('queues the backport approval check if the "backport/requested" label is on a new backport PR', async () => { 373 | getBackportApprovalCheck.mockResolvedValueOnce({ 374 | name: BACKPORT_APPROVAL_CHECK, 375 | status: 'queued', 376 | }); 377 | 378 | const event = JSON.parse( 379 | await fs.readFile(newPRBackportOpenedEventPath, 'utf-8'), 380 | ); 381 | 382 | event.payload.pull_request.labels = [backportRequestedLabel]; 383 | 384 | await robot.receive(event); 385 | 386 | expect(checkUtils.queueBackportApprovalCheck).toHaveBeenCalledTimes(1); 387 | expect(checkUtils.updateBackportApprovalCheck).not.toHaveBeenCalled(); 388 | }); 389 | 390 | it('passes the backport approval check if the "backport/approved" label is on a new backport PR', async () => { 391 | const event = JSON.parse( 392 | await fs.readFile(newPRBackportOpenedEventPath, 'utf-8'), 393 | ); 394 | 395 | event.payload.pull_request.labels = [backportApprovedLabel]; 396 | 397 | await robot.receive(event); 398 | 399 | const updatePayload = vi.mocked(checkUtils.updateBackportApprovalCheck) 400 | .mock.calls[0][2]; 401 | 402 | expect(updatePayload).toMatchObject({ 403 | title: 'Backport Approved', 404 | summary: 'This PR has been approved for backporting.', 405 | conclusion: CheckRunStatus.SUCCESS, 406 | }); 407 | }); 408 | }); 409 | 410 | describe('pull_request.labeled event', () => { 411 | it('skips the backport approval check if PR is not a backport', async () => { 412 | const event = JSON.parse(await fs.readFile(prLabeledEventPath, 'utf-8')); 413 | 414 | await robot.receive(event); 415 | 416 | expect(checkUtils.queueBackportApprovalCheck).not.toHaveBeenCalled(); 417 | expect(checkUtils.updateBackportApprovalCheck).not.toHaveBeenCalled(); 418 | }); 419 | 420 | it('queues the backport approval check if the "backport/requested" label is added', async () => { 421 | vi.mocked(octokit.checks.listForRef).mockResolvedValueOnce({ 422 | data: { 423 | check_runs: [ 424 | { 425 | name: BACKPORT_APPROVAL_CHECK, 426 | status: 'completed', 427 | conclusion: 'success', 428 | }, 429 | ], 430 | }, 431 | }); 432 | 433 | const event = JSON.parse( 434 | await fs.readFile(backportPRLabeledEventPath, 'utf-8'), 435 | ); 436 | 437 | event.payload.label = backportApprovedLabel; 438 | event.payload.pull_request.labels = [backportRequestedLabel]; 439 | 440 | await robot.receive(event); 441 | 442 | expect(checkUtils.queueBackportApprovalCheck).toHaveBeenCalledTimes(1); 443 | expect(checkUtils.updateBackportApprovalCheck).not.toHaveBeenCalled(); 444 | }); 445 | 446 | it('passes the backport approval check if the "backport/approved" label is added', async () => { 447 | const event = JSON.parse( 448 | await fs.readFile(backportPRLabeledEventPath, 'utf-8'), 449 | ); 450 | 451 | event.payload.label = backportApprovedLabel; 452 | event.payload.pull_request.labels = [backportApprovedLabel]; 453 | 454 | await robot.receive(event); 455 | 456 | const updatePayload = vi.mocked(checkUtils.updateBackportApprovalCheck) 457 | .mock.calls[0][2]; 458 | 459 | expect(updatePayload).toMatchObject({ 460 | title: 'Backport Approved', 461 | summary: 'This PR has been approved for backporting.', 462 | conclusion: CheckRunStatus.SUCCESS, 463 | }); 464 | }); 465 | }); 466 | 467 | describe('pull_request.unlabeled event', () => { 468 | it('skips the backport approval check if PR is not a backport', async () => { 469 | const event = JSON.parse( 470 | await fs.readFile(prUnlabeledEventPath, 'utf-8'), 471 | ); 472 | 473 | await robot.receive(event); 474 | 475 | expect(checkUtils.queueBackportApprovalCheck).not.toHaveBeenCalled(); 476 | expect(checkUtils.updateBackportApprovalCheck).not.toHaveBeenCalled(); 477 | }); 478 | 479 | it('passes the backport approval check if all "backport/*" labels are removed', async () => { 480 | const event = JSON.parse( 481 | await fs.readFile(backportPRUnlabeledEventPath, 'utf-8'), 482 | ); 483 | 484 | event.payload.label = backportRequestedLabel; 485 | event.payload.pull_request.labels = []; 486 | 487 | await robot.receive(event); 488 | 489 | const updatePayload = vi.mocked(checkUtils.updateBackportApprovalCheck) 490 | .mock.calls[0][2]; 491 | 492 | expect(updatePayload).toMatchObject({ 493 | title: 'Backport Approval Not Required', 494 | summary: 'This PR does not need backport approval.', 495 | conclusion: CheckRunStatus.SUCCESS, 496 | }); 497 | }); 498 | 499 | it('queues the backport approval check if the "backport/approved" label is removed and "backport/requested" remains', async () => { 500 | vi.mocked(octokit.checks.listForRef).mockResolvedValueOnce({ 501 | data: { 502 | check_runs: [ 503 | { 504 | name: BACKPORT_APPROVAL_CHECK, 505 | status: 'completed', 506 | conclusion: 'success', 507 | }, 508 | ], 509 | }, 510 | }); 511 | 512 | const event = JSON.parse( 513 | await fs.readFile(backportPRUnlabeledEventPath, 'utf-8'), 514 | ); 515 | 516 | event.payload.label = backportApprovedLabel; 517 | event.payload.pull_request.labels = [backportRequestedLabel]; 518 | 519 | await robot.receive(event); 520 | 521 | expect(checkUtils.queueBackportApprovalCheck).toHaveBeenCalledTimes(1); 522 | expect(checkUtils.updateBackportApprovalCheck).not.toHaveBeenCalled(); 523 | }); 524 | }); 525 | 526 | describe('pull_request.closed event', () => { 527 | it('begins the backporting process if the PR was merged', async () => { 528 | await robot.receive(prClosedEvent); 529 | 530 | expect(backportToLabel).toHaveBeenCalled(); 531 | }); 532 | 533 | it('updates labels on the original PR when a bot backport PR has been closed with unmerged commits', async () => { 534 | await robot.receive(backportPRClosedBotEvent); 535 | 536 | const pr = { 537 | number: 15, 538 | body: `Backport of #14 539 | See that PR for details. 540 | Notes: `, 541 | created_at: '2018-11-01T17:29:51Z', 542 | head: { 543 | ref: '123456789iuytdxcvbnjhfdriuyfedfgy54escghjnbg', 544 | }, 545 | base: { 546 | ref: '36-x-y', 547 | repo: { 548 | default_branch: 'main', 549 | }, 550 | }, 551 | labels: [ 552 | { 553 | color: 'ededed', 554 | name: '5-0-x', 555 | }, 556 | { 557 | name: 'backport', 558 | color: 'ededed', 559 | }, 560 | ], 561 | merged: false, 562 | merged_at: '2018-11-01T17:30:11Z', 563 | state: 'closed', 564 | title: 'mirror', 565 | user: { 566 | login: 'trop[bot]', 567 | }, 568 | }; 569 | 570 | expect(vi.mocked(labelClosedPR)).toHaveBeenCalledWith( 571 | expect.anything(), 572 | pr, 573 | '5-0-x', 574 | PRChange.CLOSE, 575 | ); 576 | }); 577 | 578 | it('updates labels on the original PR when a bot backport PR has been merged', async () => { 579 | await robot.receive(backportPRMergedBotEvent); 580 | 581 | const pr = { 582 | number: 15, 583 | body: `Backport of #14 584 | See that PR for details. 585 | Notes: `, 586 | created_at: '2018-11-01T17:29:51Z', 587 | head: { 588 | ref: '123456789iuytdxcvbnjhfdriuyfedfgy54escghjnbg', 589 | }, 590 | base: { 591 | ref: '36-x-y', 592 | repo: { 593 | default_branch: 'main', 594 | }, 595 | }, 596 | labels: [ 597 | { 598 | color: 'ededed', 599 | name: '4-0-x', 600 | }, 601 | { 602 | name: 'backport', 603 | color: 'ededed', 604 | }, 605 | ], 606 | merged: true, 607 | merged_at: '2018-11-01T17:30:11Z', 608 | state: 'closed', 609 | title: 'mirror', 610 | user: { 611 | login: 'trop[bot]', 612 | }, 613 | }; 614 | 615 | expect(vi.mocked(labelClosedPR)).toHaveBeenCalledWith( 616 | expect.anything(), 617 | pr, 618 | '4-0-x', 619 | PRChange.MERGE, 620 | ); 621 | }); 622 | 623 | it('updates labels on the original PR when a manual backport PR has been closed with unmerged commits', async () => { 624 | await robot.receive(backportPRClosedEvent); 625 | 626 | const pr = { 627 | number: 15, 628 | body: `Backport of #14 629 | See that PR for details. 630 | Notes: `, 631 | created_at: '2018-11-01T17:29:51Z', 632 | head: { 633 | ref: '123456789iuytdxcvbnjhfdriuyfedfgy54escghjnbg', 634 | }, 635 | base: { 636 | ref: '36-x-y', 637 | repo: { 638 | default_branch: 'main', 639 | }, 640 | }, 641 | labels: [ 642 | { 643 | color: 'ededed', 644 | name: '4-0-x', 645 | }, 646 | { 647 | name: 'backport', 648 | color: 'ededed', 649 | }, 650 | ], 651 | merged: false, 652 | merged_at: '2018-11-01T17:30:11Z', 653 | state: 'closed', 654 | title: 'mirror', 655 | user: { 656 | login: 'codebytere', 657 | }, 658 | }; 659 | 660 | expect(updateManualBackport).toHaveBeenCalled(); 661 | 662 | expect(vi.mocked(labelClosedPR)).toHaveBeenCalledWith( 663 | expect.anything(), 664 | pr, 665 | '4-0-x', 666 | PRChange.CLOSE, 667 | ); 668 | }); 669 | 670 | it('updates labels on the original PR when a manual backport PR has been merged', async () => { 671 | await robot.receive(backportPRMergedEvent); 672 | 673 | const pr = { 674 | number: 15, 675 | body: `Backport of #14 676 | See that PR for details. 677 | Notes: `, 678 | created_at: '2018-11-01T17:29:51Z', 679 | head: { 680 | ref: '123456789iuytdxcvbnjhfdriuyfedfgy54escghjnbg', 681 | }, 682 | base: { 683 | ref: '36-x-y', 684 | repo: { 685 | default_branch: 'main', 686 | }, 687 | }, 688 | labels: [ 689 | { 690 | color: 'ededed', 691 | name: '4-0-x', 692 | }, 693 | { 694 | name: 'backport', 695 | color: 'ededed', 696 | }, 697 | ], 698 | merged: true, 699 | merged_at: '2018-11-01T17:30:11Z', 700 | state: 'closed', 701 | title: 'mirror', 702 | user: { 703 | login: 'codebytere', 704 | }, 705 | }; 706 | 707 | expect(updateManualBackport).toHaveBeenCalled(); 708 | 709 | expect(vi.mocked(labelClosedPR)).toHaveBeenCalledWith( 710 | expect.anything(), 711 | pr, 712 | '4-0-x', 713 | PRChange.MERGE, 714 | ); 715 | }); 716 | }); 717 | 718 | describe('updateBackportValidityCheck from pull_request events', () => { 719 | it('skips the backport validity check if there is skip check label in a new PR', async () => { 720 | vi.mocked(getPRNumbersFromPRBody).mockReturnValueOnce([]); 721 | octokit.issues.listLabelsOnIssue.mockResolvedValue({ 722 | data: [ 723 | { 724 | name: SKIP_CHECK_LABEL, 725 | }, 726 | ], 727 | }); 728 | const event = JSON.parse( 729 | await fs.readFile(newPRBackportOpenedEventPath, 'utf-8'), 730 | ); 731 | event.payload.action = 'synchronize'; 732 | event.payload.pull_request.base.ref = '30-x-y'; 733 | await robot.receive(event); 734 | 735 | const updatePayload = vi.mocked(checkUtils.updateBackportValidityCheck) 736 | .mock.calls[0][2]; 737 | 738 | expect(updatePayload).toMatchObject({ 739 | title: 'Backport Check Skipped', 740 | summary: 'This PR is not a backport - skip backport validation check', 741 | conclusion: CheckRunStatus.NEUTRAL, 742 | }); 743 | }); 744 | 745 | it('cancels the backport validity check if branch is targeting main', async () => { 746 | vi.mocked(getPRNumbersFromPRBody).mockReturnValueOnce([]); 747 | 748 | const event = JSON.parse( 749 | await fs.readFile(newPROpenedEventPath, 'utf-8'), 750 | ); 751 | 752 | await robot.receive(event); 753 | 754 | const updatePayload = vi.mocked(checkUtils.updateBackportValidityCheck) 755 | .mock.calls[0][2]; 756 | 757 | expect(updatePayload).toMatchObject({ 758 | title: 'Cancelled', 759 | summary: "This PR is targeting 'main' and is not a backport", 760 | conclusion: CheckRunStatus.NEUTRAL, 761 | }); 762 | }); 763 | 764 | it('fails the backport validity check if old PR was not merged to a supported release branch', async () => { 765 | vi.mocked(getPRNumbersFromPRBody).mockReturnValueOnce([1234]); 766 | octokit.pulls.get.mockResolvedValueOnce({ 767 | data: { 768 | merged: true, 769 | base: { 770 | ref: 'not-supported-branch', 771 | }, 772 | }, 773 | }); 774 | const event = JSON.parse( 775 | await fs.readFile(newPRBackportOpenedEventPath, 'utf-8'), 776 | ); 777 | event.payload.pull_request.base.ref = '30-x-y'; 778 | event.payload.action = 'synchronize'; 779 | await robot.receive(event); 780 | 781 | const updatePayload = vi.mocked(checkUtils.updateBackportValidityCheck) 782 | .mock.calls[0][2]; 783 | 784 | expect(updatePayload).toMatchObject({ 785 | title: 'Invalid Backport', 786 | summary: 787 | 'This PR is targeting a branch that is not main but the PR that it is backporting was not targeting the default branch.', 788 | conclusion: CheckRunStatus.FAILURE, 789 | }); 790 | }); 791 | 792 | it('fails the backport validity check if old PR has not been merged yet', async () => { 793 | vi.mocked(getPRNumbersFromPRBody).mockReturnValueOnce([1234]); 794 | octokit.pulls.get.mockResolvedValueOnce({ 795 | data: { 796 | merged: false, 797 | base: { 798 | ref: 'main', 799 | }, 800 | }, 801 | }); 802 | const event = JSON.parse( 803 | await fs.readFile(newPRBackportOpenedEventPath, 'utf-8'), 804 | ); 805 | event.payload.pull_request.base.ref = '30-x-y'; 806 | event.payload.action = 'synchronize'; 807 | await robot.receive(event); 808 | 809 | const updatePayload = vi.mocked(checkUtils.updateBackportValidityCheck) 810 | .mock.calls[0][2]; 811 | 812 | expect(updatePayload).toMatchObject({ 813 | title: 'Invalid Backport', 814 | summary: 815 | 'This PR is targeting a branch that is not main but the PR that this is backporting has not been merged yet.', 816 | conclusion: CheckRunStatus.FAILURE, 817 | }); 818 | }); 819 | 820 | it('succeeds the backport validity check if all checks pass', async () => { 821 | vi.mocked(getPRNumbersFromPRBody).mockReturnValueOnce([1234]); 822 | octokit.pulls.get.mockResolvedValueOnce({ 823 | data: { 824 | merged: true, 825 | base: { 826 | ref: 'main', 827 | }, 828 | }, 829 | }); 830 | const event = JSON.parse( 831 | await fs.readFile(newPRBackportOpenedEventPath, 'utf-8'), 832 | ); 833 | event.payload.pull_request.base.ref = '30-x-y'; 834 | event.payload.action = 'synchronize'; 835 | await robot.receive(event); 836 | 837 | const updatePayload = vi.mocked(checkUtils.updateBackportValidityCheck) 838 | .mock.calls[0][2]; 839 | 840 | expect(updatePayload).toMatchObject({ 841 | title: 'Valid Backport', 842 | summary: 843 | 'This PR is declared as backporting "#1234" which is a valid PR that has been merged into main', 844 | conclusion: CheckRunStatus.SUCCESS, 845 | }); 846 | }); 847 | }); 848 | }); 849 | --------------------------------------------------------------------------------