mirror of
https://code.forgejo.org/actions/git-backporting.git
synced 2025-05-18 11:39:12 -04:00
feat: pull request backporting
feat: backport still open pull requests
This commit is contained in:
commit
b3936e019a
53 changed files with 48467 additions and 0 deletions
12
src/bin/cli.ts
Normal file
12
src/bin/cli.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import CLIArgsParser from "@bp/service/args/cli/cli-args-parser";
|
||||
import Runner from "@bp/service/runner/runner";
|
||||
|
||||
// create CLI arguments parser
|
||||
const parser = new CLIArgsParser();
|
||||
|
||||
// create runner
|
||||
const runner = new Runner(parser);
|
||||
|
||||
runner.run();
|
12
src/bin/gha.ts
Normal file
12
src/bin/gha.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import GHAArgsParser from "@bp/service/args/gha/gha-args-parser";
|
||||
import Runner from "@bp/service/runner/runner";
|
||||
|
||||
// create CLI arguments parser
|
||||
const parser = new GHAArgsParser();
|
||||
|
||||
// create runner
|
||||
const runner = new Runner(parser);
|
||||
|
||||
runner.run();
|
10
src/service/args/args-parser.ts
Normal file
10
src/service/args/args-parser.ts
Normal 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;
|
||||
}
|
11
src/service/args/args.types.ts
Normal file
11
src/service/args/args.types.ts
Normal 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
|
||||
}
|
34
src/service/args/cli/cli-args-parser.ts
Normal file
34
src/service/args/cli/cli-args-parser.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import ArgsParser from "@bp/service/args/args-parser";
|
||||
import { Args } from "@bp/service/args/args.types";
|
||||
import { Command } from "commander";
|
||||
import { name, version, description } from "@bp/../package.json";
|
||||
|
||||
|
||||
export default class CLIArgsParser implements ArgsParser {
|
||||
|
||||
private getCommand(): Command {
|
||||
return new Command(name)
|
||||
.version(version)
|
||||
.description(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
|
||||
};
|
||||
}
|
||||
|
||||
}
|
17
src/service/args/gha/gha-args-parser.ts
Normal file
17
src/service/args/gha/gha-args-parser.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
}
|
37
src/service/configs/configs-parser.ts
Normal file
37
src/service/configs/configs-parser.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { Args } from "@bp/service/args/args.types";
|
||||
import { Configs } from "@bp/service/configs/configs.types";
|
||||
import LoggerService from "../logger/logger-service";
|
||||
import LoggerServiceFactory from "../logger/logger-service-factory";
|
||||
|
||||
/**
|
||||
* Abstract configuration parser class in charge to parse
|
||||
* Args and produces a common Configs object
|
||||
*/
|
||||
export default abstract class ConfigsParser {
|
||||
|
||||
private readonly logger: LoggerService;
|
||||
|
||||
constructor() {
|
||||
this.logger = LoggerServiceFactory.getLogger();
|
||||
}
|
||||
|
||||
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 pr is opened check if the there exists one single commit
|
||||
if (configs.originalPullRequest.state == "open") {
|
||||
this.logger.warn("Trying to backport an open pull request!");
|
||||
}
|
||||
|
||||
// if PR is closed and not merged log a warning
|
||||
if (configs.originalPullRequest.state == "closed" && !configs.originalPullRequest.merged) {
|
||||
throw new Error("Provided pull request is closed and not merged!");
|
||||
}
|
||||
|
||||
return Promise.resolve(configs);
|
||||
}
|
||||
}
|
17
src/service/configs/configs.types.ts
Normal file
17
src/service/configs/configs.types.ts
Normal 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
|
||||
}
|
||||
|
61
src/service/configs/pullrequest/pr-configs-parser.ts
Normal file
61
src/service/configs/pullrequest/pr-configs-parser.ts
Normal file
|
@ -0,0 +1,61 @@
|
|||
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,
|
||||
nCommits: 0, // TODO: needed?
|
||||
commits: [] // TODO needed?
|
||||
};
|
||||
}
|
||||
}
|
128
src/service/git/git-cli.ts
Normal file
128
src/service/git/git-cli.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
43
src/service/git/git-service-factory.ts
Normal file
43
src/service/git/git-service-factory.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
34
src/service/git/git-service.ts
Normal file
34
src/service/git/git-service.ts
Normal 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>;
|
||||
}
|
36
src/service/git/git.types.ts
Normal file
36
src/service/git/git.types.ts
Normal 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"
|
||||
}
|
33
src/service/git/github/github-mapper.ts
Normal file
33
src/service/git/github/github-mapper.ts
Normal 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]
|
||||
};
|
||||
}
|
||||
}
|
83
src/service/git/github/github-service.ts
Normal file
83
src/service/git/github/github-service.ts
Normal 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]
|
||||
};
|
||||
}
|
||||
}
|
24
src/service/git/github/octokit-factory.ts
Normal file
24
src/service/git/github/octokit-factory.ts
Normal 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;
|
||||
}
|
||||
}
|
32
src/service/logger/console-logger-service.ts
Normal file
32
src/service/logger/console-logger-service.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import Logger from "@bp/service/logger/logger";
|
||||
import LoggerService from "@bp/service/logger/logger-service";
|
||||
|
||||
export default class ConsoleLoggerService implements LoggerService {
|
||||
|
||||
private readonly logger;
|
||||
|
||||
constructor() {
|
||||
this.logger = new Logger();
|
||||
}
|
||||
|
||||
trace(message: string): void {
|
||||
this.logger.log("[TRACE]", message);
|
||||
}
|
||||
|
||||
debug(message: string): void {
|
||||
this.logger.log("[DEBUG]", message);
|
||||
}
|
||||
|
||||
info(message: string): void {
|
||||
this.logger.log("[INFO]", message);
|
||||
}
|
||||
|
||||
warn(message: string): void {
|
||||
this.logger.log("[WARN]", message);
|
||||
}
|
||||
|
||||
error(message: string): void {
|
||||
this.logger.log("[ERROR]", message);
|
||||
}
|
||||
|
||||
}
|
18
src/service/logger/logger-service-factory.ts
Normal file
18
src/service/logger/logger-service-factory.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import ConsoleLoggerService from "@bp/service/logger/console-logger-service";
|
||||
import LoggerService from "@bp/service/logger/logger-service";
|
||||
|
||||
/**
|
||||
* Singleton factory class
|
||||
*/
|
||||
export default class LoggerServiceFactory {
|
||||
|
||||
private static instance?: LoggerService;
|
||||
|
||||
public static getLogger(): LoggerService {
|
||||
if (!LoggerServiceFactory.instance) {
|
||||
LoggerServiceFactory.instance = new ConsoleLoggerService();
|
||||
}
|
||||
|
||||
return LoggerServiceFactory.instance;
|
||||
}
|
||||
}
|
15
src/service/logger/logger-service.ts
Normal file
15
src/service/logger/logger-service.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Logger service interface providing the most commong logging functionalities
|
||||
*/
|
||||
export default interface LoggerService {
|
||||
|
||||
trace(message: string): void;
|
||||
|
||||
debug(message: string): void;
|
||||
|
||||
info(message: string): void;
|
||||
|
||||
warn(message: string): void;
|
||||
|
||||
error(message: string): void;
|
||||
}
|
15
src/service/logger/logger.ts
Normal file
15
src/service/logger/logger.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
|
||||
/**
|
||||
* Common logger class based on the console.log functionality
|
||||
*/
|
||||
export default class Logger {
|
||||
|
||||
log(prefix: string, ...str: string[]) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log.apply(console, [prefix, ...str]);
|
||||
}
|
||||
|
||||
emptyLine() {
|
||||
this.log("", "");
|
||||
}
|
||||
}
|
126
src/service/runner/runner.ts
Normal file
126
src/service/runner/runner.ts
Normal file
|
@ -0,0 +1,126 @@
|
|||
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. 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") {
|
||||
await git.fetch(configs.folder, `pull/${configs.originalPullRequest.number}/head:pr/${configs.originalPullRequest.number}`);
|
||||
}
|
||||
|
||||
// 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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue