Skip to content

Commit 60eff3f

Browse files
authored
Add GitHub Action for backporting PRs to branches (#40955)
This adds an action that can be triggered with a special comment on a PR: `/backport to <some branch>`. The action then creates a new PR to the target branch with the commits from the source PR applied.
1 parent 042a138 commit 60eff3f

File tree

3 files changed

+218
-0
lines changed

3 files changed

+218
-0
lines changed

.github/workflows/backport.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Backport PR to branch
2+
on:
3+
issue_comment:
4+
types: [created]
5+
6+
jobs:
7+
backport:
8+
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/backport to')
9+
runs-on: ubuntu-20.04
10+
steps:
11+
- name: Checkout repo
12+
uses: actions/checkout@v2
13+
- name: Run backport
14+
uses: ./eng/actions/backport
15+
with:
16+
auth_token: ${{ secrets.GITHUB_TOKEN }}
17+
pr_description_template: |
18+
Backport of #%source_pr_number% to %target_branch%
19+
20+
/cc %cc_users%
21+
22+
## Customer Impact
23+
24+
## Testing
25+
26+
## Risk

eng/actions/backport/action.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: 'PR Backporter'
2+
description: 'Backports a pull request to a branch using the "/backport to <branch>" comment'
3+
inputs:
4+
auth_token:
5+
description: 'The token used to authenticate to GitHub.'
6+
pr_title_template:
7+
description: 'The template used for the PR title. Special placeholder tokens that will be replaced with a value: %target_branch%, %source_pr_title%, %source_pr_number%, %cc_users%.'
8+
default: '[%target_branch%] %source_pr_title%'
9+
pr_description_template:
10+
description: 'The template used for the PR description. Special placeholder tokens that will be replaced with a value: %target_branch%, %source_pr_title%, %source_pr_number%, %cc_users%.'
11+
default: |
12+
Backport of #%source_pr_number% to %target_branch%
13+
14+
/cc %cc_users%
15+
16+
runs:
17+
using: 'node12'
18+
main: 'index.js'

eng/actions/backport/index.js

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
function BackportException(message, postToGitHub = true) {
5+
this.message = message;
6+
this.postToGitHub = postToGitHub;
7+
}
8+
9+
async function run() {
10+
const util = require("util");
11+
const jsExec = util.promisify(require("child_process").exec);
12+
13+
console.log("Installing npm dependencies");
14+
const { stdout, stderr } = await jsExec("npm install @actions/core @actions/github @actions/exec");
15+
console.log("npm-install stderr:\n\n" + stderr);
16+
console.log("npm-install stdout:\n\n" + stdout);
17+
console.log("Finished installing npm dependencies");
18+
19+
const core = require("@actions/core");
20+
const github = require("@actions/github");
21+
const exec = require("@actions/exec");
22+
23+
if (github.context.eventName !== "issue_comment") throw "Error: This action only works on issue_comment events.";
24+
25+
const run_id = process.env.GITHUB_RUN_ID;
26+
const repo_owner = github.context.payload.repository.owner.login;
27+
const repo_name = github.context.payload.repository.name;
28+
const pr_number = github.context.payload.issue.number;
29+
const pr_source_ref = process.env.GITHUB_REF;
30+
const comment_user = github.context.payload.comment.user.login;
31+
32+
let octokit = github.getOctokit(core.getInput("auth_token"));
33+
let target_branch = "";
34+
35+
try {
36+
// verify the comment user is a repo collaborator
37+
try {
38+
await octokit.repos.checkCollaborator({
39+
owner: repo_owner,
40+
repo: repo_name,
41+
username: comment_user
42+
});
43+
console.log(`Verified ${comment_user} is a repo collaborator.`);
44+
} catch {
45+
throw new BackportException(`Error: @${comment_user} is not a repo collaborator, backporting is not allowed.`);
46+
}
47+
48+
// extract the target branch name from the trigger phrase containing these characters: a-z, A-Z, digits, forward slash, dot, hyphen, underscore
49+
console.log(`Extracting target branch`);
50+
const regex = /\/backport to ([a-zA-Z\d\/\.\-\_]+)/;
51+
target_branch = regex.exec(github.context.payload.comment.body)[1];
52+
if (target_branch == null) throw new BackportException("Error: No backport branch found in the trigger phrase.");
53+
try { await exec.exec(`git ls-remote --exit-code --heads origin ${target_branch}`) } catch { throw new BackportException(`Error: The specified backport target branch ${target_branch} wasn't found in the repo.`); }
54+
console.log(`Backport target branch: ${target_branch}`);
55+
56+
// Post backport started comment to pull request
57+
const backport_start_body = `Started backporting to ${target_branch}: https://github.com/${repo_owner}/${repo_name}/actions/runs/${run_id}`;
58+
await octokit.issues.createComment({
59+
owner: repo_owner,
60+
repo: repo_name,
61+
issue_number: pr_number,
62+
body: backport_start_body
63+
});
64+
65+
console.log("Applying backport patch");
66+
67+
await exec.exec(`git -c protocol.version=2 fetch --no-tags --progress --no-recurse-submodules origin ${target_branch} ${pr_source_ref}`);
68+
await exec.exec(`git checkout ${target_branch}`);
69+
await exec.exec(`git clean -xdff`);
70+
71+
// configure git
72+
await exec.exec(`git config user.name "github-actions"`);
73+
await exec.exec(`git config user.email "[email protected]"`);
74+
75+
// create temporary backport branch
76+
const temp_branch = `backport/pr-${pr_number}-to-${target_branch}`;
77+
await exec.exec(`git checkout -b ${temp_branch}`);
78+
79+
// skip opening PR if the branch already exists on the origin remote since that means it was opened
80+
// by an earlier backport and force pushing to the branch updates the existing PR
81+
let should_open_pull_request = true;
82+
try {
83+
await exec.exec(`git ls-remote --exit-code --heads origin ${temp_branch}`);
84+
should_open_pull_request = false;
85+
} catch { }
86+
87+
// download and apply patch
88+
await exec.exec(`curl -sSL "${github.context.payload.issue.pull_request.patch_url}" --output changes.patch`);
89+
90+
const git_am_command = "git am --3way --ignore-whitespace --keep-non-patch changes.patch";
91+
let git_am_output = `$ ${git_am_command}\n\n`;
92+
let git_am_failed = false;
93+
try {
94+
await exec.exec(git_am_command, [], {
95+
listeners: {
96+
stdout: function stdout(data) { git_am_output += data; },
97+
stderr: function stderr(data) { git_am_output += data; }
98+
}
99+
});
100+
} catch (error) {
101+
git_am_output += error;
102+
git_am_failed = true;
103+
}
104+
105+
if (git_am_failed) {
106+
const git_am_failed_body = `@${github.context.payload.comment.user.login} backporting to ${target_branch} failed, the patch most likely resulted in conflicts:\n\n\`\`\`shell\n${git_am_output}\n\`\`\`\n\nPlease backport manually!`;
107+
await octokit.issues.createComment({
108+
owner: repo_owner,
109+
repo: repo_name,
110+
issue_number: pr_number,
111+
body: git_am_failed_body
112+
});
113+
throw new BackportException("Error: git am failed, most likely due to a merge conflict.", false);
114+
}
115+
else {
116+
// push the temp branch to the repository
117+
await exec.exec(`git push --force --set-upstream origin HEAD:${temp_branch}`);
118+
}
119+
120+
if (!should_open_pull_request) {
121+
console.log("Backport temp branch already exists, skipping opening a PR.");
122+
return;
123+
}
124+
125+
// prepate the GitHub PR details
126+
let backport_pr_title = core.getInput("pr_title_template");
127+
let backport_pr_description = core.getInput("pr_description_template");
128+
129+
// get users to cc (append PR author if different from user who issued the backport command)
130+
let cc_users = `@${comment_user}`;
131+
if (comment_user != github.context.payload.issue.user.login) cc_users += ` @${github.context.payload.issue.user.login}`;
132+
133+
// replace the special placeholder tokens with values
134+
backport_pr_title = backport_pr_title
135+
.replace(/%target_branch%/g, target_branch)
136+
.replace(/%source_pr_title%/g, github.context.payload.issue.title)
137+
.replace(/%source_pr_number%/g, github.context.payload.issue.number)
138+
.replace(/%cc_users%/g, cc_users);
139+
140+
backport_pr_description = backport_pr_description
141+
.replace(/%target_branch%/g, target_branch)
142+
.replace(/%source_pr_title%/g, github.context.payload.issue.title)
143+
.replace(/%source_pr_number%/g, github.context.payload.issue.number)
144+
.replace(/%cc_users%/g, cc_users);
145+
146+
// open the GitHub PR
147+
await octokit.pulls.create({
148+
owner: repo_owner,
149+
repo: repo_name,
150+
title: backport_pr_title,
151+
body: backport_pr_description,
152+
head: temp_branch,
153+
base: target_branch
154+
});
155+
156+
console.log("Successfully opened the GitHub PR.");
157+
} catch (error) {
158+
159+
core.setFailed(error);
160+
161+
if (error.postToGitHub === undefined || error.postToGitHub == true) {
162+
// post failure to GitHub comment
163+
const unknown_error_body = `@${comment_user} an error occurred while backporting to ${target_branch}, please check the run log for details!\n\n${error.message}`;
164+
await octokit.issues.createComment({
165+
owner: repo_owner,
166+
repo: repo_name,
167+
issue_number: pr_number,
168+
body: unknown_error_body
169+
});
170+
}
171+
}
172+
}
173+
174+
run();

0 commit comments

Comments
 (0)