Skip to content

Commit

Permalink
feat(issue-77): handle multiple target branches (#78)
Browse files Browse the repository at this point in the history
fix: #77

This enhancement allow users to backport the same change to multiple
branches with one single tool invocation
  • Loading branch information
lampajr authored Aug 3, 2023
1 parent c19a56a commit 5fc72e1
Show file tree
Hide file tree
Showing 25 changed files with 1,774 additions and 234 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ This tool comes with some inputs that allow users to override the default behavi
|---------------|----------------------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|-------------|
| Version | -V, --version | - | Current version of the tool | |
| Help | -h, --help | - | Display the help message | |
| Target Branch | -tb, --target-branch | N | Branch where the changes must be backported to | |
| Target Branches | -tb, --target-branch | N | Comma separated list of branches where the changes must be backported to | |
| Pull Request | -pr, --pull-request | N | Original pull request url, the one that must be backported, e.g., https://github.com/kiegroup/git-backporting/pull/1 | |
| Configuration File | -cf, --config-file | N | Configuration file, in JSON format, containing all options to be overridded, note that if provided all other CLI options will be ignored | |
| Auth | -a, --auth | N | `GITHUB_TOKEN`, `GITLAB_TOKEN` or a `repo` scoped [Personal Access Token](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) | "" |
Expand All @@ -107,7 +107,7 @@ This tool comes with some inputs that allow users to override the default behavi
| Reviewers | --reviewers | N | Backporting pull request comma-separated reviewers list | [] |
| Assignees | --assignes | N | Backporting pull request comma-separated assignees list | [] |
| No Reviewers Inheritance | --no-inherit-reviewers | N | Considered only if reviewers is empty, if true keep reviewers as empty list, otherwise inherit from original pull request | false |
| Backport Branch Name | --bp-branch-name | N | Name of the backporting pull request branch, if it exceeds 250 chars it will be truncated | bp-{target-branch}-{sha1}...{shaN} |
| Backport Branch Names | --bp-branch-name | N | Comma separated lists of the backporting pull request branch names, if they exceeds 250 chars they will be truncated | bp-{target-branch}-{sha1}...{shaN} |
| Labels | --labels | N | Provide custom labels to be added to the backporting pull request | [] |
| Inherit labels | --inherit-labels | N | If enabled inherit lables from the original pull request | false |
| No squash | --no-squash | N | If provided the backporting will try to backport all pull request commits without squashing | false |
Expand Down
4 changes: 2 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ inputs:
description: "URL of the pull request to backport, e.g., https://github.com/kiegroup/git-backporting/pull/1"
required: false
target-branch:
description: "Branch where the pull request must be backported to"
description: "Comma separated list of branches where the pull request must be backported to"
required: false
config-file:
description: "Path to a file containing the json configuration for this tool, the object must match the Args interface"
Expand Down Expand Up @@ -36,7 +36,7 @@ inputs:
description: "Backporting PR body. Default is the original PR body"
required: false
bp-branch-name:
description: "Backporting PR branch name. Default is auto-generated from commit"
description: "Comma separated list of backporting PR branch names. Default is auto-generated from commit and target branches"
required: false
reviewers:
description: "Comma separated list of reviewers for the backporting pull request"
Expand Down
141 changes: 94 additions & 47 deletions dist/cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ class ArgsParser {
parse() {
const args = this.readArgs();
// validate and fill with defaults
if (!args.pullRequest || !args.targetBranch) {
throw new Error("Missing option: pull request and target branch must be provided");
if (!args.pullRequest || !args.targetBranch || args.targetBranch.trim().length == 0) {
throw new Error("Missing option: pull request and target branches must be provided");
}
return {
pullRequest: args.pullRequest,
Expand Down Expand Up @@ -179,7 +179,7 @@ class CLIArgsParser extends args_parser_1.default {
return new commander_1.Command(package_json_1.name)
.version(package_json_1.version)
.description(package_json_1.description)
.option("-tb, --target-branch <branch>", "branch where changes must be backported to")
.option("-tb, --target-branch <branches>", "comma separated list of branches where changes must be backported to")
.option("-pr, --pull-request <pr-url>", "pull request url, e.g., https://github.com/kiegroup/git-backporting/pull/1")
.option("-d, --dry-run", "if enabled the tool does not create any pull request nor push anything remotely")
.option("-a, --auth <auth>", "git service authentication string, e.g., github token")
Expand All @@ -189,7 +189,7 @@ class CLIArgsParser extends args_parser_1.default {
.option("--title <bp-title>", "backport pr title, default original pr title prefixed by target branch")
.option("--body <bp-body>", "backport pr title, default original pr body prefixed by bodyPrefix")
.option("--body-prefix <bp-body-prefix>", "backport pr body prefix, default `backport <original-pr-link>`")
.option("--bp-branch-name <bp-branch-name>", "backport pr branch name, default auto-generated by the commit")
.option("--bp-branch-name <bp-branch-names>", "comma separated list of backport pr branch names, default auto-generated by the commit and target branch")
.option("--reviewers <reviewers>", "comma separated list of reviewers for the backporting pull request", args_utils_1.getAsCleanedCommaSeparatedList)
.option("--assignees <assignees>", "comma separated list of assignees for the backporting pull request", args_utils_1.getAsCleanedCommaSeparatedList)
.option("--no-inherit-reviewers", "if provided and reviewers option is empty then inherit them from original pull request")
Expand Down Expand Up @@ -288,6 +288,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", ({ value: true }));
const args_utils_1 = __nccwpck_require__(8048);
const configs_parser_1 = __importDefault(__nccwpck_require__(5799));
const git_client_factory_1 = __importDefault(__nccwpck_require__(8550));
class PullRequestConfigsParser extends configs_parser_1.default {
Expand All @@ -305,15 +306,19 @@ class PullRequestConfigsParser extends configs_parser_1.default {
throw error;
}
const folder = args.folder ?? this.getDefaultFolder();
const targetBranches = [...new Set((0, args_utils_1.getAsCommaSeparatedList)(args.targetBranch))];
const bpBranchNames = [...new Set(args.bpBranchName ? ((0, args_utils_1.getAsCleanedCommaSeparatedList)(args.bpBranchName) ?? []) : [])];
if (bpBranchNames.length > 1 && bpBranchNames.length != targetBranches.length) {
throw new Error(`The number of backport branch names, if provided, must match the number of target branches or just one, provided ${bpBranchNames.length} branch names instead`);
}
return {
dryRun: args.dryRun,
auth: args.auth,
folder: `${folder.startsWith("/") ? "" : process.cwd() + "/"}${args.folder ?? this.getDefaultFolder()}`,
targetBranch: args.targetBranch,
mergeStrategy: args.strategy,
mergeStrategyOption: args.strategyOption,
originalPullRequest: pr,
backportPullRequest: this.getDefaultBackportPullRequest(pr, args),
backportPullRequests: this.generateBackportPullRequestsData(pr, args, targetBranches, bpBranchNames),
git: {
user: args.gitUser ?? this.gitClient.getDefaultGitUser(),
email: args.gitEmail ?? this.gitClient.getDefaultGitEmail(),
Expand All @@ -330,7 +335,7 @@ class PullRequestConfigsParser extends configs_parser_1.default {
* @param targetBranch target branch where the backport should be applied
* @returns {GitPullRequest}
*/
getDefaultBackportPullRequest(originalPullRequest, args) {
generateBackportPullRequestsData(originalPullRequest, args, targetBranches, bpBranchNames) {
const reviewers = args.reviewers ?? [];
if (reviewers.length == 0 && args.inheritReviewers) {
// inherit only if args.reviewers is empty and args.inheritReviewers set to true
Expand All @@ -345,29 +350,37 @@ class PullRequestConfigsParser extends configs_parser_1.default {
if (args.inheritLabels) {
labels.push(...originalPullRequest.labels);
}
let backportBranch = args.bpBranchName;
if (backportBranch === undefined || backportBranch.trim() === "") {
// for each commit takes the first 7 chars that are enough to uniquely identify them in most of the projects
const concatenatedCommits = originalPullRequest.commits.map(c => c.slice(0, 7)).join("-");
backportBranch = `bp-${args.targetBranch}-${concatenatedCommits}`;
}
if (backportBranch.length > 250) {
this.logger.warn(`Backport branch (length=${backportBranch.length}) exceeded the max length of 250 chars, branch name truncated!`);
backportBranch = backportBranch.slice(0, 250);
}
return {
owner: originalPullRequest.targetRepo.owner,
repo: originalPullRequest.targetRepo.project,
head: backportBranch,
base: args.targetBranch,
title: args.title ?? `[${args.targetBranch}] ${originalPullRequest.title}`,
// preserve new line chars
body: body.replace(/\\n/g, "\n").replace(/\\r/g, "\r"),
reviewers: [...new Set(reviewers)],
assignees: [...new Set(args.assignees)],
labels: [...new Set(labels)],
comments: args.comments?.map(c => c.replace(/\\n/g, "\n").replace(/\\r/g, "\r")) ?? [],
};
return targetBranches.map((tb, idx) => {
// if there multiple branch names take the corresponding one, otherwise get the the first one if it exists
let backportBranch = bpBranchNames.length > 1 ? bpBranchNames[idx] : bpBranchNames[0];
if (backportBranch === undefined || backportBranch.trim() === "") {
// for each commit takes the first 7 chars that are enough to uniquely identify them in most of the projects
const concatenatedCommits = originalPullRequest.commits.map(c => c.slice(0, 7)).join("-");
backportBranch = `bp-${tb}-${concatenatedCommits}`;
}
else if (bpBranchNames.length == 1 && targetBranches.length > 1) {
// multiple targets and single custom backport branch name we need to differentiate branch names
// so append "-${tb}" to the provided name
backportBranch = backportBranch + `-${tb}`;
}
if (backportBranch.length > 250) {
this.logger.warn(`Backport branch (length=${backportBranch.length}) exceeded the max length of 250 chars, branch name truncated!`);
backportBranch = backportBranch.slice(0, 250);
}
return {
owner: originalPullRequest.targetRepo.owner,
repo: originalPullRequest.targetRepo.project,
head: backportBranch,
base: tb,
title: args.title ?? `[${tb}] ${originalPullRequest.title}`,
// preserve new line chars
body: body.replace(/\\n/g, "\n").replace(/\\r/g, "\r"),
reviewers: [...new Set(reviewers)],
assignees: [...new Set(args.assignees)],
labels: [...new Set(labels)],
comments: args.comments?.map(c => c.replace(/\\n/g, "\n").replace(/\\r/g, "\r")) ?? [],
};
});
}
}
exports["default"] = PullRequestConfigsParser;
Expand Down Expand Up @@ -436,10 +449,12 @@ class GitCLIService {
this.logger.info(`Cloning repository ${from} to ${to}`);
if (!fs_1.default.existsSync(to)) {
await (0, simple_git_1.default)().clone(this.remoteWithAuth(from), to, ["--quiet", "--shallow-submodules", "--no-tags", "--branch", branch]);
return;
}
else {
this.logger.warn(`Folder ${to} already exist. Won't clone`);
}
this.logger.info(`Folder ${to} already exist. Won't clone`);
// checkout to the proper branch
this.logger.info(`Checking out branch ${branch}`);
await this.git(to).checkout(branch);
}
/**
* Create a new branch starting from the current one and checkout in it
Expand Down Expand Up @@ -1115,22 +1130,31 @@ class ConsoleLoggerService {
this.logger = new logger_1.default();
this.verbose = verbose;
}
setContext(newContext) {
this.context = newContext;
}
clearContext() {
this.context = undefined;
}
trace(message) {
this.logger.log("TRACE", message);
this.logger.log("TRACE", this.fromContext(message));
}
debug(message) {
if (this.verbose) {
this.logger.log("DEBUG", message);
this.logger.log("DEBUG", this.fromContext(message));
}
}
info(message) {
this.logger.log("INFO", message);
this.logger.log("INFO", this.fromContext(message));
}
warn(message) {
this.logger.log("WARN", message);
this.logger.log("WARN", this.fromContext(message));
}
error(message) {
this.logger.log("ERROR", message);
this.logger.log("ERROR", this.fromContext(message));
}
fromContext(msg) {
return this.context ? `[${this.context}] ${msg}` : msg;
}
}
exports["default"] = ConsoleLoggerService;
Expand Down Expand Up @@ -1242,39 +1266,62 @@ class Runner {
// 3. parse configs
this.logger.debug("Parsing configs..");
const configs = await new pr_configs_parser_1.default().parseAndValidate(args);
const originalPR = configs.originalPullRequest;
const backportPR = configs.backportPullRequest;
const backportPRs = configs.backportPullRequests;
// start local git operations
const git = new git_cli_1.default(configs.auth, configs.git);
const failures = [];
// we need sequential backporting as they will operate on the same folder
// avoid cloning the same repo multiple times
for (const pr of backportPRs) {
try {
await this.executeBackport(configs, pr, {
gitClientType: gitClientType,
gitClientApi: gitApi,
gitCli: git,
});
}
catch (error) {
this.logger.error(`Something went wrong backporting to ${pr.base}: ${error}`);
failures.push(error);
}
}
if (failures.length > 0) {
throw new Error(`Failure occurred during one of the backports: [${failures.join(" ; ")}]`);
}
}
async executeBackport(configs, backportPR, git) {
this.logger.setContext(backportPR.base);
const originalPR = configs.originalPullRequest;
// 4. clone the repository
this.logger.debug("Cloning repo..");
await git.clone(configs.originalPullRequest.targetRepo.cloneUrl, configs.folder, configs.targetBranch);
await git.gitCli.clone(configs.originalPullRequest.targetRepo.cloneUrl, configs.folder, backportPR.base);
// 5. create new branch from target one and checkout
this.logger.debug("Creating local branch..");
await git.createLocalBranch(configs.folder, backportPR.head);
await git.gitCli.createLocalBranch(configs.folder, backportPR.head);
// 6. fetch pull request remote if source owner != target owner or pull request still open
if (configs.originalPullRequest.sourceRepo.owner !== configs.originalPullRequest.targetRepo.owner ||
configs.originalPullRequest.state === "open") {
this.logger.debug("Fetching pull request remote..");
const prefix = gitClientType === git_types_1.GitClientType.GITHUB ? "pull" : "merge-requests"; // default is for gitlab
await git.fetch(configs.folder, `${prefix}/${configs.originalPullRequest.number}/head:pr/${configs.originalPullRequest.number}`);
const prefix = git.gitClientType === git_types_1.GitClientType.GITHUB ? "pull" : "merge-requests"; // default is for gitlab
await git.gitCli.fetch(configs.folder, `${prefix}/${configs.originalPullRequest.number}/head:pr/${configs.originalPullRequest.number}`);
}
// 7. apply all changes to the new branch
this.logger.debug("Cherry picking commits..");
for (const sha of originalPR.commits) {
await git.cherryPick(configs.folder, sha, configs.mergeStrategy, configs.mergeStrategyOption);
await git.gitCli.cherryPick(configs.folder, sha, configs.mergeStrategy, configs.mergeStrategyOption);
}
if (!configs.dryRun) {
// 8. push the new branch to origin
await git.push(configs.folder, backportPR.head);
await git.gitCli.push(configs.folder, backportPR.head);
// 9. create pull request new branch -> target branch (using octokit)
const prUrl = await gitApi.createPullRequest(backportPR);
const prUrl = await git.gitClientApi.createPullRequest(backportPR);
this.logger.info(`Pull request created: ${prUrl}`);
}
else {
this.logger.warn("Pull request creation and remote push skipped");
this.logger.info(`${JSON.stringify(backportPR, null, 2)}`);
}
this.logger.clearContext();
}
}
exports["default"] = Runner;
Expand Down
Loading

0 comments on commit 5fc72e1

Please sign in to comment.