feat: pull request backporting

feat: backport still open pull requests
This commit is contained in:
Andrea Lamparelli 2023-01-05 11:41:14 +01:00
commit b3936e019a
53 changed files with 48467 additions and 0 deletions

128
src/service/git/git-cli.ts Normal file
View file

@ -0,0 +1,128 @@
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";
/**
* Command line git commands executor service
*/
export default class GitCLIService {
private readonly logger: LoggerService;
private readonly auth: string;
private readonly author: string;
constructor(auth: string, author: string) {
this.logger = LoggerServiceFactory.getLogger();
this.auth = auth;
this.author = author;
}
/**
* Return a pre-configured SimpleGit instance able to execute commands from current
* directory or the provided one
* @param cwd [optional] current working directory
* @returns {SimpleGit}
*/
private git(cwd?: string): SimpleGit {
const gitConfig = { ...(cwd ? { baseDir: cwd } : {})};
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;
}
/**
* Return the git version
* @returns {Promise<string | undefined>}
*/
async version(cwd: string): Promise<string | undefined> {
const rawOutput = await this.git(cwd).raw("version");
const match = rawOutput.match(/(\d+\.\d+(\.\d+)?)/);
return match ? match[1] : undefined;
}
/**
* Clone a git repository
* @param from url or path from which the repository should be cloned from
* @param to location at which the repository should be cloned at
* @param branch branch which should be cloned
*/
async clone(from: string, to: string, branch: string): Promise<void> {
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`);
}
}
/**
* Create a new branch starting from the current one and checkout in it
* @param cwd repository in which createBranch should be performed
* @param newBranch new branch name
*/
async createLocalBranch(cwd: string, newBranch: string): Promise<void> {
this.logger.info(`Creating branch ${newBranch}.`);
await this.git(cwd).checkoutLocalBranch(newBranch);
}
/**
* Add a new remote to the current repository
* @param cwd repository in which addRemote should be performed
* @param remote remote git link
* @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, this.remoteWithAuth(remote));
}
/**
* Git fetch from a particular branch
* @param cwd repository in which fetch should be performed
* @param branch fetch from the given branch
* @param remote [optional] the remote to fetch, by default origin
*/
async fetch(cwd: string, branch: string, remote = "origin"): Promise<void> {
this.logger.info(`Fetching ${remote} ${branch}.`);
await this.git(cwd).fetch(remote, branch, ["--quiet"]);
}
/**
* Get cherry-pick a specific sha
* @param cwd repository in which the sha should be cherry picked to
* @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", "-m", "1", "--strategy=recursive", "--strategy-option=theirs", sha]);
}
/**
* Push a branch to a remote
* @param cwd repository in which the push should be performed
* @param branch branch to be pushed
* @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}.`);
const options = ["--quiet"];
if (force) {
options.push("--force-with-lease");
}
await this.git(cwd).push(remote, branch, options);
}
}

View file

@ -0,0 +1,43 @@
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 {
if (!GitServiceFactory.instance) {
throw new Error("You must call `init` method first!");
}
return GitServiceFactory.instance;
}
/**
* Initialize the singleton git management service
* @param type git management service type
* @param auth authentication, like github token
*/
public static init(type: GitServiceType, auth: string): void {
if (GitServiceFactory.instance) {
GitServiceFactory.logger.warn("Git service already initialized!");
return;
}
switch(type) {
case GitServiceType.GITHUB:
GitServiceFactory.instance = new GitHubService(auth);
break;
default:
throw new Error(`Invalid git service type received: ${type}`);
}
}
}

View file

@ -0,0 +1,34 @@
import { BackportPullRequest, GitPullRequest } from "@bp/service/git/git.types";
/**
* Git management service interface, which provides a common API for interacting
* with several git management services like GitHub, Gitlab or Bitbucket.
*/
export default interface GitService {
// READ
/**
* Get a pull request object from the underneath git service
* @param owner repository's owner
* @param repo repository's name
* @param prNumber pull request number
* @returns {Promise<PullRequest>}
*/
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 backport backport pull request data
*/
createPullRequest(backport: BackportPullRequest): Promise<void>;
}

View file

@ -0,0 +1,36 @@
export interface GitPullRequest {
number?: number,
author: string,
url?: string,
htmlUrl?: string,
state?: "open" | "closed",
merged?: boolean,
mergedBy?: string,
title: string,
body: string,
reviewers: string[],
targetRepo: GitRepository,
sourceRepo: GitRepository,
nCommits: number, // number of commits in the pr
commits: string[] // merge commit or last one
}
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

@ -0,0 +1,33 @@
import { GitPullRequest } from "@bp/service/git/git.types";
import { PullRequest, User } from "@octokit/webhooks-types";
export default class GitHubMapper {
mapPullRequest(pr: PullRequest): GitPullRequest {
return {
number: pr.number,
author: pr.user.login,
url: pr.url,
htmlUrl: pr.html_url,
title: pr.title,
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: {
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
},
nCommits: pr.commits,
// if pr is open use latest commit sha otherwise use merge_commit_sha
commits: pr.state === "open" ? [pr.head.sha] : [pr.merge_commit_sha as string]
};
}
}

View file

@ -0,0 +1,83 @@
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();
}
// 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,
pull_number: prNumber
});
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
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

@ -0,0 +1,24 @@
import LoggerService from "@bp/service/logger/logger-service";
import LoggerServiceFactory from "@bp/service/logger/logger-service-factory";
import { Octokit } from "@octokit/rest";
/**
* Singleton factory class for {Octokit} instance
*/
export default class OctokitFactory {
private static logger: LoggerService = LoggerServiceFactory.getLogger();
private static octokit?: Octokit;
public static getOctokit(token: string): Octokit {
if (!OctokitFactory.octokit) {
OctokitFactory.logger.info("Creating octokit instance.");
OctokitFactory.octokit = new Octokit({
auth: token,
userAgent: "lampajr/backporting"
});
}
return OctokitFactory.octokit;
}
}