Base implementation

This commit is contained in:
Andrea Lamparelli 2022-12-23 19:03:23 +01:00
parent 05d156a5b0
commit 74703c48f3
53 changed files with 34684 additions and 392 deletions

View file

@ -0,0 +1,10 @@
import { Args } from "@bp/service/args/args.types";
/**
* Abstract arguments parser interface in charge to parse inputs and
* produce a common Args object
*/
export default interface ArgsParser {
parse(): Args;
}

View file

@ -0,0 +1,11 @@
/**
* Input arguments
*/
export interface Args {
dryRun: boolean, // if enabled do not push anything remotely
auth: string, // git service auth, like github token
targetBranch: string, // branch on the target repo where the change should be backported to
pullRequest: string, // url of the pull request to backport
folder?: string, // local folder where the repositories should be cloned
author?: string, // backport pr author, default taken from pr
}

View file

@ -0,0 +1,49 @@
import ArgsParser from "@bp/service/args/args-parser";
import { Args } from "@bp/service/args/args.types";
import { Command } from "commander";
import { env } from "process";
interface PkgInfo {
name: string;
version: string;
description: string;
}
export default class CLIArgsParser implements ArgsParser {
private pkg: PkgInfo;
constructor() {
this.pkg = {
name: env.npm_package_name ?? "backporting",
version: env.npm_package_version ?? "0.0.0",
description: env.npm_package_description ?? ""
};
}
private getCommand(): Command {
return new Command(this.pkg.name)
.version(this.pkg.version)
.description(this.pkg.description)
.requiredOption("-tb, --target-branch <branch>", "branch where changes must be backported to.")
.requiredOption("-pr, --pull-request <pr url>", "pull request url, e.g., https://github.com/lampajr/backporting/pull/1.")
.option("-d, --dry-run", "if enabled the tool does not create any pull request nor push anything remotely", false)
.option("-a, --auth <auth>", "git service authentication string, e.g., github token.", "")
.option("-f, --folder <folder>", "local folder where the repo will be checked out, e.g., /tmp/folder.", undefined);
}
parse(): Args {
const opts = this.getCommand()
.parse()
.opts();
return {
dryRun: opts.dryRun,
auth: opts.auth,
pullRequest: opts.pullRequest,
targetBranch: opts.targetBranch,
folder: opts.folder
};
}
}

View file

@ -0,0 +1,17 @@
import ArgsParser from "@bp/service/args/args-parser";
import { Args } from "@bp/service/args/args.types";
import { getInput } from "@actions/core";
export default class GHAArgsParser implements ArgsParser {
parse(): Args {
return {
dryRun: getInput("dry-run") === "true",
auth: getInput("auth") ? getInput("auth") : "",
pullRequest: getInput("pull-request"),
targetBranch: getInput("target-branch"),
folder: getInput("folder") !== "" ? getInput("folder") : undefined
};
}
}

View file

@ -0,0 +1,22 @@
import { Args } from "@bp/service/args/args.types";
import { Configs } from "@bp/service/configs/configs.types";
/**
* Abstract configuration parser class in charge to parse
* Args and produces a common Configs object
*/
export default abstract class ConfigsParser {
abstract parse(args: Args): Promise<Configs>;
async parseAndValidate(args: Args): Promise<Configs> {
const configs: Configs = await this.parse(args);
// apply validation, throw errors if something is wrong
if (configs.originalPullRequest.state == "open" || !configs.originalPullRequest.merged) {
throw new Error("Provided pull request is not merged!");
}
return Promise.resolve(configs);
}
}

View file

@ -0,0 +1,17 @@
import { GitPullRequest } from "@bp/service/git/git.types";
/**
* Internal configuration object
*/
export interface Configs {
dryRun: boolean,
auth: string,
author: string, // author of the backport pr
folder: string,
targetBranch: string,
originalPullRequest: GitPullRequest,
backportPullRequest: GitPullRequest
}

View file

@ -0,0 +1,60 @@
import { Args } from "@bp/service/args/args.types";
import ConfigsParser from "@bp/service/configs/configs-parser";
import { Configs } from "@bp/service/configs/configs.types";
import GitService from "@bp/service/git/git-service";
import GitServiceFactory from "@bp/service/git/git-service-factory";
import { GitPullRequest } from "@bp/service/git/git.types";
export default class PullRequestConfigsParser extends ConfigsParser {
private gitService: GitService;
constructor() {
super();
this.gitService = GitServiceFactory.getService();
}
public async parse(args: Args): Promise<Configs> {
const pr: GitPullRequest = await this.gitService.getPullRequestFromUrl(args.pullRequest);
const folder: string = args.folder ?? this.getDefaultFolder();
return {
dryRun: args.dryRun,
auth: args.auth,
author: args.author ?? pr.author,
folder: `${folder.startsWith("/") ? "" : process.cwd() + "/"}${args.folder ?? this.getDefaultFolder()}`,
targetBranch: args.targetBranch,
originalPullRequest: pr,
backportPullRequest: this.getDefaultBackportPullRequest(pr, args.targetBranch)
};
}
private getDefaultFolder() {
return "bp";
}
/**
* Create a default backport pull request starting from the target branch and
* the original pr to be backported
* @param originalPullRequest original pull request
* @param targetBranch target branch where the backport should be applied
* @returns {GitPullRequest}
*/
private getDefaultBackportPullRequest(originalPullRequest: GitPullRequest, targetBranch: string): GitPullRequest {
const reviewers = [];
reviewers.push(originalPullRequest.author);
if (originalPullRequest.mergedBy) {
reviewers.push(originalPullRequest.mergedBy);
}
return {
author: originalPullRequest.author,
title: `[${targetBranch}] ${originalPullRequest.title}`,
body: `**Backport:** ${originalPullRequest.htmlUrl}\r\n\r\n${originalPullRequest.body}\r\n\r\nPowered by [BPer](https://github.com/lampajr/backporting).`,
reviewers: [...new Set(reviewers)],
targetRepo: originalPullRequest.targetRepo,
sourceRepo: originalPullRequest.targetRepo,
commits: [] // TODO needed?
};
}
}

View file

@ -1,5 +1,5 @@
import LoggerService from "@gb/service/logger/logger-service";
import LoggerServiceFactory from "@gb/service/logger/logger-service-factory";
import LoggerService from "@bp/service/logger/logger-service";
import LoggerServiceFactory from "@bp/service/logger/logger-service-factory";
import simpleGit, { SimpleGit } from "simple-git";
import fs from "fs";
@ -9,9 +9,13 @@ import fs from "fs";
export default class GitCLIService {
private readonly logger: LoggerService;
private readonly auth: string;
private readonly author: string;
constructor() {
constructor(auth: string, author: string) {
this.logger = LoggerServiceFactory.getLogger();
this.auth = auth;
this.author = author;
}
/**
@ -22,7 +26,20 @@ export default class GitCLIService {
*/
private git(cwd?: string): SimpleGit {
const gitConfig = { ...(cwd ? { baseDir: cwd } : {})};
return simpleGit(gitConfig).addConfig("user.name", "Github").addConfig("user.email", "noreply@github.com");
return simpleGit(gitConfig).addConfig("user.name", this.author).addConfig("user.email", "noreply@github.com");
}
/**
* Update the provided remote URL by adding the auth token if not empty
* @param remoteURL remote link, e.g., https://github.com/lampajr/backporting-example.git
*/
private remoteWithAuth(remoteURL: string): string {
if (this.auth && this.author) {
return remoteURL.replace("://", `://${this.author}:${this.auth}@`);
}
// return remote as it is
return remoteURL;
}
/**
@ -42,9 +59,9 @@ export default class GitCLIService {
* @param branch branch which should be cloned
*/
async clone(from: string, to: string, branch: string): Promise<void> {
this.logger.info(`Cloning repository ${from}..`);
this.logger.info(`Cloning repository ${from} to ${to}.`);
if (!fs.existsSync(to)) {
await this.git().clone(from, to, ["--quiet", "--shallow-submodules", "--no-tags", "--branch", branch]);
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`);
}
@ -56,7 +73,7 @@ export default class GitCLIService {
* @param newBranch new branch name
*/
async createLocalBranch(cwd: string, newBranch: string): Promise<void> {
this.logger.info(`Creating branch ${newBranch}..`);
this.logger.info(`Creating branch ${newBranch}.`);
await this.git(cwd).checkoutLocalBranch(newBranch);
}
@ -67,8 +84,8 @@ export default class GitCLIService {
* @param remoteName [optional] name of the remote, by default 'fork' is used
*/
async addRemote(cwd: string, remote: string, remoteName = "fork"): Promise<void> {
this.logger.info(`Adding new remote ${remote}..`);
await this.git(cwd).addRemote(remoteName, remote);
this.logger.info(`Adding new remote ${remote}.`);
await this.git(cwd).addRemote(remoteName, this.remoteWithAuth(remote));
}
/**
@ -87,8 +104,8 @@ export default class GitCLIService {
* @param sha commit sha
*/
async cherryPick(cwd: string, sha: string): Promise<void> {
this.logger.info(`Cherry picking ${sha}..`);
await this.git(cwd).raw(["cherry-pick", "--strategy=recursive", "-X", "theirs", sha]);
this.logger.info(`Cherry picking ${sha}.`);
await this.git(cwd).raw(["cherry-pick", "-m", "1", "--strategy=recursive", "--strategy-option=theirs", sha]);
}
/**
@ -98,7 +115,7 @@ export default class GitCLIService {
* @param remote [optional] remote to which the branch should be pushed to, by default 'origin'
*/
async push(cwd: string, branch: string, remote = "origin", force = false): Promise<void> {
this.logger.info(`Pushing ${branch} to ${remote}..`);
this.logger.info(`Pushing ${branch} to ${remote}.`);
const options = ["--quiet"];
if (force) {

View file

@ -1,12 +1,15 @@
import GitService from "@gb/service/git/git-service";
import { GitServiceType } from "@gb/service/git/git.types";
import GitHubService from "@gb/service/git/github/github-service";
import GitService from "@bp/service/git/git-service";
import { GitServiceType } from "@bp/service/git/git.types";
import GitHubService from "@bp/service/git/github/github-service";
import LoggerService from "@bp/service/logger/logger-service";
import LoggerServiceFactory from "@bp/service/logger/logger-service-factory";
/**
* Singleton git service factory class
*/
export default class GitServiceFactory {
private static logger: LoggerService = LoggerServiceFactory.getLogger();
private static instance?: GitService;
public static getService(): GitService {
@ -25,7 +28,8 @@ export default class GitServiceFactory {
public static init(type: GitServiceType, auth: string): void {
if (GitServiceFactory.instance) {
throw new Error("Git service already initialized!");
GitServiceFactory.logger.warn("Git service already initialized!");
return;
}
switch(type) {

View file

@ -1,4 +1,4 @@
import { GitPullRequest } from "@gb/service/git/git.types";
import { BackportPullRequest, GitPullRequest } from "@bp/service/git/git.types";
/**
* Git management service interface, which provides a common API for interacting
@ -17,17 +17,18 @@ import { GitPullRequest } from "@gb/service/git/git.types";
*/
getPullRequest(owner: string, repo: string, prNumber: number): Promise<GitPullRequest>;
/**
* Get a pull request object from the underneath git service
* @param prUrl pull request html url
* @returns {Promise<PullRequest>}
*/
getPullRequestFromUrl(prUrl: string): Promise<GitPullRequest>;
// WRITE
/**
* Create a new pull request on the underneath git service
* @param owner repository's owner
* @param repo repository's name
* @param head name of the source branch
* @param base name of the target branch
* @param title pr title
* @param body pr body
* @param reviewers pr list of reviewers
* @param backport backport pull request data
*/
createPullRequest(owner: string, repo: string, head: string, base: string, title: string, body: string, reviewers: string[]): Promise<void>;
createPullRequest(backport: BackportPullRequest): Promise<void>;
}

View file

@ -1,15 +1,34 @@
export interface GitPullRequest {
url: string,
patchUrl: string,
state: string,
author: string,
url?: string,
htmlUrl?: string,
state?: "open" | "closed",
merged?: boolean,
mergedBy?: string,
title: string,
body: string,
reviewers: string[],
targetRepo: string,
sourceRepo: string,
targetRepo: GitRepository,
sourceRepo: GitRepository,
commits: string[]
}
export interface GitRepository {
owner: string,
project: string,
cloneUrl: string
}
export interface BackportPullRequest {
owner: string, // repository's owner
repo: string, // repository's name
head: string, // name of the source branch
base: string, // name of the target branch
title: string, // pr title
body: string, // pr body
reviewers: string[] // pr list of reviewers
}
export enum GitServiceType {
GITHUB = "github"
}

View file

@ -1,19 +1,30 @@
import { GitPullRequest } from "@gb/service/git/git.types";
import { GitPullRequest } from "@bp/service/git/git.types";
import { PullRequest, User } from "@octokit/webhooks-types";
export default class GitHubMapper {
mapPullRequest(pr: PullRequest): GitPullRequest {
return {
author: pr.user.login,
url: pr.url,
htmlUrl: pr.html_url,
title: pr.title,
body: pr.body,
patchUrl: pr.patch_url,
body: pr.body ?? "",
state: pr.state,
merged: pr.merged ?? false,
mergedBy: pr.merged_by?.login,
reviewers: pr.requested_reviewers.filter(r => "login" in r).map((r => (r as User)?.login)),
sourceRepo: pr.head.repo.full_name,
targetRepo: pr.base.repo.full_name,
commits: [pr.merge_commit_sha]
} as GitPullRequest;
sourceRepo: {
owner: pr.head.repo.full_name.split("/")[0],
project: pr.head.repo.full_name.split("/")[1],
cloneUrl: pr.head.repo.clone_url
},
targetRepo: {
owner: pr.base.repo.full_name.split("/")[0],
project: pr.base.repo.full_name.split("/")[1],
cloneUrl: pr.base.repo.clone_url
},
commits: [pr.merge_commit_sha as string]
};
}
}

View file

@ -1,16 +1,20 @@
import GitService from "@gb/service/git/git-service";
import { GitPullRequest } from "@gb/service/git/git.types";
import GitHubMapper from "@gb/service/git/github/github-mapper";
import OctokitFactory from "@gb/service/git/github/octokit-factory";
import GitService from "@bp/service/git/git-service";
import { BackportPullRequest, GitPullRequest } from "@bp/service/git/git.types";
import GitHubMapper from "@bp/service/git/github/github-mapper";
import OctokitFactory from "@bp/service/git/github/octokit-factory";
import LoggerService from "@bp/service/logger/logger-service";
import LoggerServiceFactory from "@bp/service/logger/logger-service-factory";
import { Octokit } from "@octokit/rest";
import { PullRequest } from "@octokit/webhooks-types";
export default class GitHubService implements GitService {
private logger: LoggerService;
private octokit: Octokit;
private mapper: GitHubMapper;
constructor(token: string) {
this.logger = LoggerServiceFactory.getLogger();
this.octokit = OctokitFactory.getOctokit(token);
this.mapper = new GitHubMapper();
}
@ -18,6 +22,7 @@ export default class GitHubService implements GitService {
// READ
async getPullRequest(owner: string, repo: string, prNumber: number): Promise<GitPullRequest> {
this.logger.info(`Getting pull request ${owner}/${repo}/${prNumber}.`);
const { data } = await this.octokit.rest.pulls.get({
owner: owner,
repo: repo,
@ -27,12 +32,52 @@ export default class GitHubService implements GitService {
return this.mapper.mapPullRequest(data as PullRequest);
}
async getPullRequestFromUrl(prUrl: string): Promise<GitPullRequest> {
const {owner, project} = this.getRepositoryFromPrUrl(prUrl);
return this.getPullRequest(owner, project, parseInt(prUrl.substring(prUrl.lastIndexOf("/") + 1, prUrl.length)));
}
// WRITE
// eslint-disable-next-line @typescript-eslint/no-unused-vars
createPullRequest(owner: string, repo: string, head: string, base: string, title: string, body: string, reviewers: string[]): Promise<void> {
// throw new Error("Method not implemented.");
// TODO implement
return Promise.resolve();
async createPullRequest(backport: BackportPullRequest): Promise<void> {
this.logger.info(`Creating pull request ${backport.head} -> ${backport.base}.`);
this.logger.info(`${JSON.stringify(backport, null, 2)}`);
const { data } = await this.octokit.pulls.create({
owner: backport.owner,
repo: backport.repo,
head: backport.head,
base: backport.base,
title: backport.title,
body: backport.body
});
if (backport.reviewers.length > 0) {
try {
await this.octokit.pulls.requestReviewers({
owner: backport.owner,
repo: backport.repo,
pull_number: (data as PullRequest).number,
reviewers: backport.reviewers
});
} catch (error) {
this.logger.error(`Error requesting reviewers: ${error}`);
}
}
}
// UTILS
/**
* Extract repository owner and project from the pull request url
* @param prUrl pull request url
* @returns {{owner: string, project: string}}
*/
private getRepositoryFromPrUrl(prUrl: string): {owner: string, project: string} {
const elems: string[] = prUrl.split("/");
return {
owner: elems[elems.length - 4],
project: elems[elems.length - 3]
};
}
}

View file

@ -1,5 +1,5 @@
import LoggerService from "@gb/service/logger/logger-service";
import LoggerServiceFactory from "@gb/service/logger/logger-service-factory";
import LoggerService from "@bp/service/logger/logger-service";
import LoggerServiceFactory from "@bp/service/logger/logger-service-factory";
import { Octokit } from "@octokit/rest";
/**
@ -12,7 +12,7 @@ export default class OctokitFactory {
public static getOctokit(token: string): Octokit {
if (!OctokitFactory.octokit) {
OctokitFactory.logger.info("Creating octokit instance..");
OctokitFactory.logger.info("Creating octokit instance.");
OctokitFactory.octokit = new Octokit({
auth: token,
userAgent: "lampajr/backporting"

View file

@ -1,5 +1,5 @@
import Logger from "@gb/service/logger/logger";
import LoggerService from "@gb/service/logger/logger-service";
import Logger from "@bp/service/logger/logger";
import LoggerService from "@bp/service/logger/logger-service";
export default class ConsoleLoggerService implements LoggerService {

View file

@ -1,5 +1,5 @@
import ConsoleLoggerService from "@gb/service/logger/console-logger-service";
import LoggerService from "@gb/service/logger/logger-service";
import ConsoleLoggerService from "@bp/service/logger/console-logger-service";
import LoggerService from "@bp/service/logger/logger-service";
/**
* Singleton factory class

View file

@ -0,0 +1,123 @@
import ArgsParser from "@bp/service/args/args-parser";
import { Args } from "@bp/service/args/args.types";
import { Configs } from "@bp/service/configs/configs.types";
import PullRequestConfigsParser from "@bp/service/configs/pullrequest/pr-configs-parser";
import GitCLIService from "@bp/service/git/git-cli";
import GitService from "@bp/service/git/git-service";
import GitServiceFactory from "@bp/service/git/git-service-factory";
import { BackportPullRequest, GitPullRequest, GitServiceType } from "@bp/service/git/git.types";
import LoggerService from "@bp/service/logger/logger-service";
import LoggerServiceFactory from "@bp/service/logger/logger-service-factory";
/**
* Main runner implementation, it implements the core logic flow
*/
export default class Runner {
private logger: LoggerService;
private argsParser: ArgsParser;
constructor(parser: ArgsParser) {
this.logger = LoggerServiceFactory.getLogger();
this.argsParser = parser;
}
/**
* Infer the remote GIT service to interact with based on the provided
* pull request URL
* @param prUrl provided pull request URL
* @returns {GitServiceType}
*/
private inferRemoteGitService(prUrl: string): GitServiceType {
const stdPrUrl = prUrl.toLowerCase().trim();
if (stdPrUrl.includes(GitServiceType.GITHUB.toString())) {
return GitServiceType.GITHUB;
}
throw new Error(`Remote GIT service not recognixed from PR url: ${prUrl}`);
}
/**
* Entry point invoked by the command line or gha
*/
async run(): Promise<void> {
this.logger.info("Starting process.");
try {
await this.execute();
this.logger.info("Process succeeded!");
process.exit(0);
} catch (error) {
this.logger.error(`${error}`);
this.logger.info("Process failed!");
process.exit(1);
}
}
/**
* Core logic
*/
async execute(): Promise<void>{
// 1. parse args
const args: Args = this.argsParser.parse();
if (args.dryRun) {
this.logger.warn("Dry run enabled!");
}
// 2. init git service
GitServiceFactory.init(this.inferRemoteGitService(args.pullRequest), args.auth);
const gitApi: GitService = GitServiceFactory.getService();
// 3. parse configs
const configs: Configs = await new PullRequestConfigsParser().parseAndValidate(args);
const originalPR: GitPullRequest = configs.originalPullRequest;
const backportPR: GitPullRequest = configs.backportPullRequest;
// start local git operations
const git: GitCLIService = new GitCLIService(configs.auth, configs.author);
// 4. clone the repository
await git.clone(configs.originalPullRequest.targetRepo.cloneUrl, configs.folder, configs.targetBranch);
// 5. create new branch from target one and checkout
const backportBranch = `bp-${configs.targetBranch}-${originalPR.commits.join("-")}`;
await git.createLocalBranch(configs.folder, backportBranch);
// 6. add new remote if source != target and fetch source repo
// Skip this, we assume the PR has been already merged
// 7. apply all changes to the new branch
for (const sha of originalPR.commits) {
await git.cherryPick(configs.folder, sha);
}
const backport: BackportPullRequest = {
owner: originalPR.targetRepo.owner,
repo: originalPR.targetRepo.project,
head: backportBranch,
base: configs.targetBranch,
title: backportPR.title,
body: backportPR.body,
reviewers: backportPR.reviewers
};
if (!configs.dryRun) {
// 8. push the new branch to origin
await git.push(configs.folder, backportBranch);
// 9. create pull request new branch -> target branch (using octokit)
await gitApi.createPullRequest(backport);
} else {
this.logger.warn("Pull request creation and remote push skipped!");
this.logger.info(`${JSON.stringify(backport, null, 2)}`);
}
}
}