feat(issue-77): handle multiple target branches (#78)

fix: https://github.com/kiegroup/git-backporting/issues/77

This enhancement allow users to backport the same change to multiple
branches with one single tool invocation
This commit is contained in:
Andrea Lamparelli 2023-08-03 21:57:11 +02:00 committed by GitHub
parent c19a56a9ad
commit 5fc72e127b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1774 additions and 234 deletions

View file

@ -17,8 +17,8 @@ export default abstract class ArgsParser {
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 {

View file

@ -1,8 +1,9 @@
/**
* Input arguments
* Tool's input arguments interface
*/
export interface Args {
targetBranch: string, // branch on the target repo where the change should be backported to
// NOTE: keep targetBranch as singular and of type string for backward compatibilities
targetBranch: string, // comma separated list of branches on the target repo where the change should be backported to
pullRequest: string, // url of the pull request to backport
dryRun?: boolean, // if enabled do not push anything remotely
auth?: string, // git service auth, like github token
@ -12,7 +13,8 @@ export interface Args {
title?: string, // backport pr title, default original pr title prefixed by target branch
body?: string, // backport pr title, default original pr body prefixed by bodyPrefix
bodyPrefix?: string, // backport pr body prefix, default `backport <original-pr-link>`
bpBranchName?: string, // backport pr branch name, default computed from commit
// NOTE: keep bpBranchName as singular and of type string for backward compatibilities
bpBranchName?: string, // comma separated list of backport pr branch names, default computed from commit and target branches
reviewers?: string[], // backport pr reviewers
assignees?: string[], // backport pr assignees
inheritReviewers?: boolean, // if true and reviewers == [] then inherit reviewers from original pr

View file

@ -10,7 +10,7 @@ export default class CLIArgsParser extends ArgsParser {
return new Command(name)
.version(version)
.description(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")
@ -20,7 +20,7 @@ export default class CLIArgsParser extends ArgsParser {
.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", getAsCleanedCommaSeparatedList)
.option("--assignees <assignees>", "comma separated list of assignees for the backporting pull request", getAsCleanedCommaSeparatedList)
.option("--no-inherit-reviewers", "if provided and reviewers option is empty then inherit them from original pull request")

View file

@ -15,10 +15,9 @@ export interface Configs {
auth?: string,
git: LocalGit,
folder: string,
targetBranch: string,
mergeStrategy?: string, // cherry-pick merge strategy
mergeStrategyOption?: string, // cherry-pick merge strategy option
originalPullRequest: GitPullRequest,
backportPullRequest: BackportPullRequest,
backportPullRequests: BackportPullRequest[],
}

View file

@ -1,3 +1,4 @@
import { getAsCleanedCommaSeparatedList, getAsCommaSeparatedList } from "@bp/service/args/args-utils";
import { Args } from "@bp/service/args/args.types";
import ConfigsParser from "@bp/service/configs/configs-parser";
import { Configs } from "@bp/service/configs/configs.types";
@ -25,15 +26,21 @@ export default class PullRequestConfigsParser extends ConfigsParser {
const folder: string = args.folder ?? this.getDefaultFolder();
const targetBranches: string[] = [...new Set(getAsCommaSeparatedList(args.targetBranch)!)];
const bpBranchNames: string[] = [...new Set(args.bpBranchName ? (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(),
@ -52,7 +59,13 @@ export default class PullRequestConfigsParser extends ConfigsParser {
* @param targetBranch target branch where the backport should be applied
* @returns {GitPullRequest}
*/
private getDefaultBackportPullRequest(originalPullRequest: GitPullRequest, args: Args): BackportPullRequest {
private generateBackportPullRequestsData(
originalPullRequest: GitPullRequest,
args: Args,
targetBranches: string[],
bpBranchNames: string[]
): BackportPullRequest[] {
const reviewers = args.reviewers ?? [];
if (reviewers.length == 0 && args.inheritReviewers) {
// inherit only if args.reviewers is empty and args.inheritReviewers set to true
@ -70,30 +83,38 @@ export default class PullRequestConfigsParser extends ConfigsParser {
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: string = originalPullRequest.commits!.map(c => c.slice(0, 7)).join("-");
backportBranch = `bp-${args.targetBranch}-${concatenatedCommits}`;
}
return targetBranches.map((tb, idx) => {
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);
}
// 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: string = 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: 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 {
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")) ?? [],
};
}) as BackportPullRequest[];
}
}

View file

@ -63,9 +63,13 @@ export default class GitCLIService {
this.logger.info(`Cloning repository ${from} to ${to}`);
if (!fs.existsSync(to)) {
await simpleGit().clone(this.remoteWithAuth(from), to, ["--quiet", "--shallow-submodules", "--no-tags", "--branch", branch]);
} else {
this.logger.warn(`Folder ${to} already exist. Won't clone`);
return;
}
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);
}
/**

View file

@ -5,32 +5,44 @@ export default class ConsoleLoggerService implements LoggerService {
private readonly logger: Logger;
private readonly verbose: boolean;
private context?: string;
constructor(verbose = true) {
this.logger = new Logger();
this.verbose = verbose;
}
setContext(newContext: string) {
this.context = newContext;
}
clearContext() {
this.context = undefined;
}
trace(message: string): void {
this.logger.log("TRACE", message);
this.logger.log("TRACE", this.fromContext(message));
}
debug(message: string): void {
if (this.verbose) {
this.logger.log("DEBUG", message);
this.logger.log("DEBUG", this.fromContext(message));
}
}
info(message: string): void {
this.logger.log("INFO", message);
this.logger.log("INFO", this.fromContext(message));
}
warn(message: string): void {
this.logger.log("WARN", message);
this.logger.log("WARN", this.fromContext(message));
}
error(message: string): void {
this.logger.log("ERROR", message);
this.logger.log("ERROR", this.fromContext(message));
}
private fromContext(msg: string): string {
return this.context ? `[${this.context}] ${msg}` : msg;
}
}

View file

@ -3,6 +3,10 @@
*/
export default interface LoggerService {
setContext(newContext: string): void;
clearContext(): void;
trace(message: string): void;
debug(message: string): void;

View file

@ -10,6 +10,12 @@ import LoggerService from "@bp/service/logger/logger-service";
import LoggerServiceFactory from "@bp/service/logger/logger-service-factory";
import { inferGitClient, inferGitApiUrl } from "@bp/service/git/git-util";
interface Git {
gitClientType: GitClientType;
gitClientApi: GitClient;
gitCli: GitCLIService;
}
/**
* Main runner implementation, it implements the core logic flow
*/
@ -62,47 +68,73 @@ export default class Runner {
// 3. parse configs
this.logger.debug("Parsing configs..");
const configs: Configs = await new PullRequestConfigsParser().parseAndValidate(args);
const originalPR: GitPullRequest = configs.originalPullRequest;
const backportPR: BackportPullRequest = configs.backportPullRequest;
const backportPRs: BackportPullRequest[] = configs.backportPullRequests;
// start local git operations
const git: GitCLIService = new GitCLIService(configs.auth, configs.git);
const failures: string[] = [];
// 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 as string);
}
}
if (failures.length > 0) {
throw new Error(`Failure occurred during one of the backports: [${failures.join(" ; ")}]`);
}
}
async executeBackport(configs: Configs, backportPR: BackportPullRequest, git: Git): Promise<void> {
this.logger.setContext(backportPR.base);
const originalPR: GitPullRequest = 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 === 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 === 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();
}
}