feat: integrate tool with gitlab service (#39)

* feat: integrate tool with gitlab service

Fix https://github.com/lampajr/backporting/issues/30
This commit is contained in:
Andrea Lamparelli 2023-07-02 00:05:17 +02:00 committed by GitHub
parent 8a007941d1
commit 107f5e52d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 17821 additions and 1553 deletions

View file

@ -0,0 +1,56 @@
import GitClient from "@bp/service/git/git-client";
import { GitClientType } from "@bp/service/git/git.types";
import GitHubService from "@bp/service/git/github/github-client";
import LoggerService from "@bp/service/logger/logger-service";
import LoggerServiceFactory from "@bp/service/logger/logger-service-factory";
import GitLabClient from "./gitlab/gitlab-client";
/**
* Singleton git service factory class
*/
export default class GitClientFactory {
private static logger: LoggerService = LoggerServiceFactory.getLogger();
private static instance?: GitClient;
public static getClient(): GitClient {
if (!GitClientFactory.instance) {
throw new Error("You must call `getOrCreate` method first!");
}
return GitClientFactory.instance;
}
/**
* Initialize the singleton git management service
* @param type git management service type
* @param authToken authentication token, like github/gitlab token
*/
public static getOrCreate(type: GitClientType, authToken: string, apiUrl: string): GitClient {
if (GitClientFactory.instance) {
GitClientFactory.logger.warn("Git service already initialized!");
return GitClientFactory.instance;
}
this.logger.debug(`Setting up ${type} client: apiUrl=${apiUrl}, token=****`);
switch(type) {
case GitClientType.GITHUB:
GitClientFactory.instance = new GitHubService(authToken, apiUrl);
break;
case GitClientType.GITLAB:
GitClientFactory.instance = new GitLabClient(authToken, apiUrl);
break;
default:
throw new Error(`Invalid git service type received: ${type}`);
}
return GitClientFactory.instance;
}
public static reset(): void {
GitClientFactory.logger.warn("Resetting git service!");
GitClientFactory.instance = undefined;
}
}

View file

@ -4,7 +4,7 @@ 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 {
export default interface GitClient {
// READ
@ -29,6 +29,7 @@ import { BackportPullRequest, GitPullRequest } from "@bp/service/git/git.types";
/**
* Create a new pull request on the underneath git service
* @param backport backport pull request data
* @returns {Promise<string>} the pull request url
*/
createPullRequest(backport: BackportPullRequest): Promise<void>;
createPullRequest(backport: BackportPullRequest): Promise<string>;
}

View file

@ -0,0 +1,20 @@
import { GitPullRequest, GitRepoState, GitRepository } from "@bp/service/git/git.types";
/**
* Generic git client response mapper
*
* PR - full pull request schema type
* S - pull request state type
*/
export default interface GitResponseMapper<PR, S> {
mapPullRequest(
pr: PR,
): Promise<GitPullRequest>;
mapGitState(state: S): GitRepoState;
mapSourceRepo(pull: PR): Promise<GitRepository>;
mapTargetRepo (pull: PR): Promise<GitRepository>;
}

View file

@ -1,43 +0,0 @@
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 getOrCreate(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,39 @@
import { GitClientType } from "@bp/service/git/git.types";
const PUBLIC_GITHUB_URL = "https://github.com";
const PUBLIC_GITHUB_API = "https://api.github.com";
/**
* Infer the remote GIT service to interact with based on the provided
* pull request URL
* @param prUrl provided pull request URL
* @returns {GitClientType}
*/
export const inferGitClient = (prUrl: string): GitClientType => {
const stdPrUrl = prUrl.toLowerCase().trim();
if (stdPrUrl.includes(GitClientType.GITHUB.toString())) {
return GitClientType.GITHUB;
} else if (stdPrUrl.includes(GitClientType.GITLAB.toString())) {
return GitClientType.GITLAB;
}
throw new Error(`Remote git service not recognized from pr url: ${prUrl}`);
};
/**
* Infer the host git service from the pull request url
* @param prUrl pull/merge request url
* @param apiVersion the api version, ignored in case of public github
* @returns api URL like https://api.github.com or https://gitlab.com/api/v4
*/
export const inferGitApiUrl = (prUrl: string, apiVersion = "v4"): string => {
const url = new URL(prUrl);
const baseUrl = `${url.protocol}//${url.host}`;
if (baseUrl.includes(PUBLIC_GITHUB_URL)) {
return PUBLIC_GITHUB_API;
}
return `${baseUrl}/api/${apiVersion}`;
};

View file

@ -3,7 +3,7 @@ export interface GitPullRequest {
author: string,
url?: string,
htmlUrl?: string,
state?: "open" | "closed",
state?: GitRepoState,
merged?: boolean,
mergedBy?: string,
title: string,
@ -35,6 +35,14 @@ export interface BackportPullRequest {
branchName?: string,
}
export enum GitServiceType {
GITHUB = "github"
export enum GitClientType {
GITHUB = "github",
GITLAB = "gitlab",
}
export enum GitRepoState {
OPEN = "open",
CLOSED = "closed",
LOCKED = "locked", // just on gitlab
MERGED = "merged", // just on gitlab
}

View file

@ -1,4 +1,4 @@
import GitService from "@bp/service/git/git-service";
import GitClient from "@bp/service/git/git-client";
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";
@ -7,15 +7,17 @@ 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 {
export default class GitHubClient implements GitClient {
private logger: LoggerService;
private apiUrl: string;
private octokit: Octokit;
private mapper: GitHubMapper;
constructor(token: string) {
constructor(token: string, apiUrl: string) {
this.apiUrl = apiUrl;
this.logger = LoggerServiceFactory.getLogger();
this.octokit = OctokitFactory.getOctokit(token);
this.octokit = OctokitFactory.getOctokit(token, this.apiUrl);
this.mapper = new GitHubMapper();
}
@ -33,13 +35,13 @@ export default class GitHubService implements GitService {
}
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)));
const { owner, project, id } = this.extractPullRequestData(prUrl);
return this.getPullRequest(owner, project, id);
}
// WRITE
async createPullRequest(backport: BackportPullRequest): Promise<void> {
async createPullRequest(backport: BackportPullRequest): Promise<string> {
this.logger.info(`Creating pull request ${backport.head} -> ${backport.base}.`);
this.logger.info(`${JSON.stringify(backport, null, 2)}`);
@ -52,6 +54,10 @@ export default class GitHubService implements GitService {
body: backport.body
});
if (!data) {
throw new Error("Pull request creation failed");
}
if (backport.reviewers.length > 0) {
try {
await this.octokit.pulls.requestReviewers({
@ -77,6 +83,8 @@ export default class GitHubService implements GitService {
this.logger.error(`Error setting assignees: ${error}`);
}
}
return data.html_url;
}
// UTILS
@ -86,11 +94,12 @@ export default class GitHubService implements GitService {
* @param prUrl pull request url
* @returns {{owner: string, project: string}}
*/
private getRepositoryFromPrUrl(prUrl: string): {owner: string, project: string} {
private extractPullRequestData(prUrl: string): {owner: string, project: string, id: number} {
const elems: string[] = prUrl.split("/");
return {
owner: elems[elems.length - 4],
project: elems[elems.length - 3]
project: elems[elems.length - 3],
id: parseInt(prUrl.substring(prUrl.lastIndexOf("/") + 1, prUrl.length)),
};
}
}
}

View file

@ -1,9 +1,19 @@
import { GitPullRequest } from "@bp/service/git/git.types";
import { GitPullRequest, GitRepoState, GitRepository } from "@bp/service/git/git.types";
import { PullRequest, User } from "@octokit/webhooks-types";
import GitResponseMapper from "@bp/service/git/git-mapper";
export default class GitHubMapper {
export default class GitHubMapper implements GitResponseMapper<PullRequest, "open" | "closed"> {
mapPullRequest(pr: PullRequest): GitPullRequest {
mapGitState(state: "open" | "closed"): GitRepoState {
switch (state) {
case "open":
return GitRepoState.OPEN;
default:
return GitRepoState.CLOSED;
}
}
async mapPullRequest(pr: PullRequest): Promise<GitPullRequest> {
return {
number: pr.number,
author: pr.user.login,
@ -11,24 +21,32 @@ export default class GitHubMapper {
htmlUrl: pr.html_url,
title: pr.title,
body: pr.body ?? "",
state: pr.state,
state: this.mapGitState(pr.state), // TODO fix using custom mapper
merged: pr.merged ?? false,
mergedBy: pr.merged_by?.login,
reviewers: pr.requested_reviewers.filter(r => "login" in r).map((r => (r as User)?.login)),
assignees: pr.assignees.filter(r => "login" in r).map(r => r.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
},
sourceRepo: await this.mapSourceRepo(pr),
targetRepo: await this.mapTargetRepo(pr),
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]
};
}
async mapSourceRepo(pr: PullRequest): Promise<GitRepository> {
return Promise.resolve({
owner: pr.head.repo.full_name.split("/")[0],
project: pr.head.repo.full_name.split("/")[1],
cloneUrl: pr.head.repo.clone_url
});
}
async mapTargetRepo(pr: PullRequest): Promise<GitRepository> {
return Promise.resolve({
owner: pr.base.repo.full_name.split("/")[0],
project: pr.base.repo.full_name.split("/")[1],
cloneUrl: pr.base.repo.clone_url
});
}
}

View file

@ -10,12 +10,13 @@ export default class OctokitFactory {
private static logger: LoggerService = LoggerServiceFactory.getLogger();
private static octokit?: Octokit;
public static getOctokit(token: string): Octokit {
public static getOctokit(token: string, apiUrl: string): Octokit {
if (!OctokitFactory.octokit) {
OctokitFactory.logger.info("Creating octokit instance.");
OctokitFactory.octokit = new Octokit({
auth: token,
userAgent: "lampajr/backporting"
userAgent: "lampajr/backporting",
baseUrl: apiUrl
});
}

View file

@ -0,0 +1,157 @@
import LoggerService from "@bp/service/logger/logger-service";
import GitClient from "@bp/service/git/git-client";
import { GitPullRequest, BackportPullRequest } from "@bp/service/git/git.types";
import LoggerServiceFactory from "@bp/service/logger/logger-service-factory";
import { MergeRequestSchema, UserSchema } from "@gitbeaker/rest";
import GitLabMapper from "@bp/service/git/gitlab/gitlab-mapper";
import axios, { Axios } from "axios";
import https from "https";
export default class GitLabClient implements GitClient {
private readonly logger: LoggerService;
private readonly apiUrl: string;
private readonly mapper: GitLabMapper;
private readonly client: Axios;
constructor(token: string, apiUrl: string, rejectUnauthorized = false) {
this.logger = LoggerServiceFactory.getLogger();
this.apiUrl = apiUrl;
this.client = axios.create({
baseURL: this.apiUrl,
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": "lampajr/backporting",
},
httpsAgent: new https.Agent({
rejectUnauthorized
})
});
this.mapper = new GitLabMapper(this.client);
}
// READ
// example: <host>/api/v4/projects/alampare%2Fbackporting-example/merge_requests/1
async getPullRequest(namespace: string, repo: string, mrNumber: number): Promise<GitPullRequest> {
const projectId = this.getProjectId(namespace, repo);
const { data } = await this.client.get(`/projects/${projectId}/merge_requests/${mrNumber}`);
return this.mapper.mapPullRequest(data as MergeRequestSchema);
}
getPullRequestFromUrl(mrUrl: string): Promise<GitPullRequest> {
const { namespace, project, id } = this.extractMergeRequestData(mrUrl);
return this.getPullRequest(namespace, project, id);
}
// WRITE
async createPullRequest(backport: BackportPullRequest): Promise<string> {
this.logger.info(`Creating pull request ${backport.head} -> ${backport.base}.`);
this.logger.info(`${JSON.stringify(backport, null, 2)}`);
const projectId = this.getProjectId(backport.owner, backport.repo);
const { data } = await this.client.post(`/projects/${projectId}/merge_requests`, {
source_branch: backport.head,
target_branch: backport.base,
title: backport.title,
description: backport.body,
reviewer_ids: [],
assignee_ids: [],
});
const mr = data as MergeRequestSchema;
// reviewers
const reviewerIds: number[] = [];
for(const r of backport.reviewers) {
try {
this.logger.debug("Retrieving user: " + r);
const user = await this.getUser(r);
reviewerIds.push(user.id);
} catch(error) {
this.logger.warn(`Failed to retrieve reviewer ${r}`);
}
}
if (reviewerIds.length > 0) {
try {
this.logger.info("Setting reviewers: " + reviewerIds);
await this.client.put(`/projects/${projectId}/merge_requests/${mr.iid}`, {
reviewer_ids: reviewerIds.filter(r => r !== undefined),
});
} catch(error) {
this.logger.warn("Failure trying to update reviewers. " + error);
}
}
// assignees
const assigneeIds: number[] = [];
for(const a of backport.assignees) {
try {
this.logger.debug("Retrieving user: " + a);
const user = await this.getUser(a);
assigneeIds.push(user.id);
} catch(error) {
this.logger.warn(`Failed to retrieve assignee ${a}`);
}
}
if (assigneeIds.length > 0) {
try {
this.logger.info("Setting assignees: " + assigneeIds);
await this.client.put(`/projects/${projectId}/merge_requests/${mr.iid}`, {
assignee_ids: assigneeIds.filter(a => a !== undefined),
});
} catch(error) {
this.logger.warn("Failure trying to update assignees. " + error);
}
}
return mr.web_url;
}
/**
* Retrieve a gitlab user given its username
* @param username
* @returns UserSchema
*/
private async getUser(username: string): Promise<UserSchema> {
const { data } = await this.client.get(`/users?username=${username}`);
const users = data as UserSchema[];
if (users.length > 1) {
throw new Error("Too many users found with username=" + username);
}
if (users.length == 0) {
throw new Error("User " + username + " not found");
}
return users[0];
}
/**
* Extract repository namespace, project and mr number from the merge request url
* example: <host>/alampare/backporting-example/-/merge_requests/1
* note: "-/" could be omitted
* @param mrUrl merge request url
* @returns {{owner: string, project: string}}
*/
private extractMergeRequestData(mrUrl: string): {namespace: string, project: string, id: number} {
const elems: string[] = mrUrl.replace("/-/", "/").split("/");
return {
namespace: elems[elems.length - 4],
project: elems[elems.length - 3],
id: parseInt(mrUrl.substring(mrUrl.lastIndexOf("/") + 1, mrUrl.length)),
};
}
private getProjectId(namespace: string, repo: string) {
// e.g., <namespace>%2F<repo>
return encodeURIComponent(`${namespace}/${repo}`);
}
}

View file

@ -0,0 +1,83 @@
import { GitPullRequest, GitRepoState, GitRepository } from "@bp/service/git/git.types";
import GitResponseMapper from "@bp/service/git/git-mapper";
import { MergeRequestSchema, ProjectSchema } from "@gitbeaker/rest";
import { Axios } from "axios";
export default class GitLabMapper implements GitResponseMapper<MergeRequestSchema, string> {
private readonly client;
// needs client to perform additional requests
constructor(client: Axios) {
this.client = client;
}
mapGitState(state: string): GitRepoState {
switch (state) {
case "opened":
return GitRepoState.OPEN;
case "closed":
return GitRepoState.CLOSED;
case "merged":
return GitRepoState.MERGED;
default:
return GitRepoState.LOCKED;
}
}
async mapPullRequest(mr: MergeRequestSchema): Promise<GitPullRequest> {
// throw new Error("Method not implemented.");
return {
number: mr.iid,
author: mr.author.username,
url: mr.web_url,
htmlUrl: mr.web_url,
title: mr.title,
body: mr.description,
state: this.mapGitState(mr.state),
merged: this.isMerged(mr),
mergedBy: mr.merged_by?.username,
reviewers: mr.reviewers?.map((r => r.username)) ?? [],
assignees: mr.assignees?.map((r => r.username)) ?? [],
sourceRepo: await this.mapSourceRepo(mr),
targetRepo: await this.mapTargetRepo(mr),
nCommits: 1, // info not present on mr
// if mr is merged, use merge_commit_sha otherwise use sha
// what is the difference between sha and diff_refs.head_sha?
commits: this.isMerged(mr) ? [mr.squash_commit_sha ? mr.squash_commit_sha : mr.merge_commit_sha as string] : [mr.sha]
};
}
async mapSourceRepo(mr: MergeRequestSchema): Promise<GitRepository> {
const project: ProjectSchema = await this.getProject(mr.source_project_id);
return {
owner: project.namespace.full_path, // or just proj.path?
project: project.path,
cloneUrl: project.http_url_to_repo,
};
}
async mapTargetRepo(mr: MergeRequestSchema): Promise<GitRepository> {
const project: ProjectSchema = await this.getProject(mr.target_project_id);
return {
owner: project.namespace.full_path, // or just proj.path?
project: project.path,
cloneUrl: project.http_url_to_repo,
};
}
private isMerged(mr: MergeRequestSchema) {
return this.mapGitState(mr.state) === GitRepoState.MERGED;
}
private async getProject(projectId: number): Promise<ProjectSchema> {
const { data } = await this.client.get(`/projects/${projectId}`);
if (!data) {
throw new Error(`Project ${projectId} not found`);
}
return data as ProjectSchema;
}
}