feat: implement error notification as pr comment (#124)

* feat: implement error notification as pr comment

* Update action.yml

Co-authored-by: Earl Warren <109468362+earl-warren@users.noreply.github.com>

* feat: implement gitlab client and surround with try catch

* docs: add error notification enablment in the doc

* feat: disable comment if dry-run

* feat: update the default comment on error

---------

Co-authored-by: Earl Warren <109468362+earl-warren@users.noreply.github.com>
This commit is contained in:
Andrea Lamparelli 2024-04-10 23:01:16 +02:00 committed by GitHub
parent 6042bcc40b
commit 2bb7f73112
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 594 additions and 39 deletions

View file

@ -48,7 +48,8 @@ export default abstract class ArgsParser {
strategy: this.getOrDefault(args.strategy),
strategyOption: this.getOrDefault(args.strategyOption),
cherryPickOptions: this.getOrDefault(args.cherryPickOptions),
comments: this.getOrDefault(args.comments)
comments: this.getOrDefault(args.comments),
enableErrorNotification: this.getOrDefault(args.enableErrorNotification, false),
};
}
}

View file

@ -50,7 +50,7 @@ export function getAsSemicolonSeparatedList(value: string): string[] | undefined
return trimmed !== "" ? trimmed.split(";").map(v => v.trim()) : undefined;
}
export function getAsBooleanOrDefault(value: string): boolean | undefined {
export function getAsBooleanOrUndefined(value: string): boolean | undefined {
const trimmed = value.trim();
return trimmed !== "" ? trimmed.toLowerCase() === "true" : undefined;
}

View file

@ -28,4 +28,5 @@ export interface Args {
strategyOption?: string, // cherry-pick merge strategy option
cherryPickOptions?: string, // additional cherry-pick options
comments?: string[], // additional comments to be posted
enableErrorNotification?: boolean, // enable the error notification on original pull request
}

View file

@ -28,12 +28,13 @@ export default class CLIArgsParser extends ArgsParser {
.option("--no-inherit-reviewers", "if provided and reviewers option is empty then inherit them from original pull request")
.option("--labels <labels>", "comma separated list of labels to be assigned to the backported pull request", getAsCommaSeparatedList)
.option("--inherit-labels", "if true the backported pull request will inherit labels from the original one")
.option("--no-squash", "Backport all commits found in the pull request. The default behavior is to only backport the first commit that was merged in the base branch")
.option("--auto-no-squash", "If the pull request was merged or is open, backport all commits. If the pull request commits were squashed, backport the squashed commit.")
.option("--no-squash", "backport all commits found in the pull request. The default behavior is to only backport the first commit that was merged in the base branch")
.option("--auto-no-squash", "if the pull request was merged or is open, backport all commits. If the pull request commits were squashed, backport the squashed commit.")
.option("--strategy <strategy>", "cherry-pick merge strategy, default to 'recursive'", undefined)
.option("--strategy-option <strategy-option>", "cherry-pick merge strategy option, default to 'theirs'")
.option("--cherry-pick-options <options>", "additional cherry-pick options")
.option("--comments <comments>", "semicolon separated list of additional comments to be posted to the backported pull request", getAsSemicolonSeparatedList)
.option("--enable-err-notification", "if true, enable the error notification as comment on the original pull request")
.option("-cf, --config-file <config-file>", "configuration file containing all valid options, the json must match Args interface");
}
@ -72,6 +73,7 @@ export default class CLIArgsParser extends ArgsParser {
strategyOption: opts.strategyOption,
cherryPickOptions: opts.cherryPickOptions,
comments: opts.comments,
enableErrorNotification: opts.enableErrNotification,
};
}

View file

@ -1,7 +1,7 @@
import ArgsParser from "@bp/service/args/args-parser";
import { Args } from "@bp/service/args/args.types";
import { getInput } from "@actions/core";
import { getAsBooleanOrDefault, getAsCleanedCommaSeparatedList, getAsCommaSeparatedList, getAsSemicolonSeparatedList, getOrUndefined, readConfigFile } from "@bp/service/args/args-utils";
import { getAsBooleanOrUndefined, getAsCleanedCommaSeparatedList, getAsCommaSeparatedList, getAsSemicolonSeparatedList, getOrUndefined, readConfigFile } from "@bp/service/args/args-utils";
export default class GHAArgsParser extends ArgsParser {
@ -13,7 +13,7 @@ export default class GHAArgsParser extends ArgsParser {
args = readConfigFile(configFile);
} else {
args = {
dryRun: getAsBooleanOrDefault(getInput("dry-run")),
dryRun: getAsBooleanOrUndefined(getInput("dry-run")),
auth: getOrUndefined(getInput("auth")),
pullRequest: getInput("pull-request"),
targetBranch: getOrUndefined(getInput("target-branch")),
@ -28,15 +28,16 @@ export default class GHAArgsParser extends ArgsParser {
bpBranchName: getOrUndefined(getInput("bp-branch-name")),
reviewers: getAsCleanedCommaSeparatedList(getInput("reviewers")),
assignees: getAsCleanedCommaSeparatedList(getInput("assignees")),
inheritReviewers: !getAsBooleanOrDefault(getInput("no-inherit-reviewers")),
inheritReviewers: !getAsBooleanOrUndefined(getInput("no-inherit-reviewers")),
labels: getAsCommaSeparatedList(getInput("labels")),
inheritLabels: getAsBooleanOrDefault(getInput("inherit-labels")),
squash: !getAsBooleanOrDefault(getInput("no-squash")),
autoNoSquash: getAsBooleanOrDefault(getInput("auto-no-squash")),
inheritLabels: getAsBooleanOrUndefined(getInput("inherit-labels")),
squash: !getAsBooleanOrUndefined(getInput("no-squash")),
autoNoSquash: getAsBooleanOrUndefined(getInput("auto-no-squash")),
strategy: getOrUndefined(getInput("strategy")),
strategyOption: getOrUndefined(getInput("strategy-option")),
cherryPickOptions: getOrUndefined(getInput("cherry-pick-options")),
comments: getAsSemicolonSeparatedList(getInput("comments")),
enableErrorNotification: getAsBooleanOrUndefined(getInput("enable-err-notification")),
};
}

View file

@ -2,11 +2,19 @@
import { BackportPullRequest, GitPullRequest } from "@bp/service/git/git.types";
export const MESSAGE_ERROR_PLACEHOLDER = "{{error}}";
export const MESSAGE_TARGET_BRANCH_PLACEHOLDER = "{{target-branch}}";
export interface LocalGit {
user: string, // local git user
email: string, // local git email
}
export interface ErrorNotification {
enabled: boolean, // if the error notification is enabled
message: string, // notification message, placeholder {{error}} will be replaced with actual error
}
/**
* Internal configuration object
*/
@ -20,6 +28,7 @@ export interface Configs {
cherryPickOptions?: string, // additional cherry-pick options
originalPullRequest: GitPullRequest,
backportPullRequests: BackportPullRequest[],
errorNotification: ErrorNotification,
}
export enum AuthTokenId {

View file

@ -1,7 +1,7 @@
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";
import { Configs, MESSAGE_TARGET_BRANCH_PLACEHOLDER } from "@bp/service/configs/configs.types";
import GitClient from "@bp/service/git/git-client";
import GitClientFactory from "@bp/service/git/git-client-factory";
import { BackportPullRequest, GitPullRequest } from "@bp/service/git/git.types";
@ -58,7 +58,11 @@ export default class PullRequestConfigsParser extends ConfigsParser {
git: {
user: args.gitUser ?? this.gitClient.getDefaultGitUser(),
email: args.gitEmail ?? this.gitClient.getDefaultGitEmail(),
}
},
errorNotification: {
enabled: args.enableErrorNotification ?? false,
message: this.getDefaultErrorComment(),
},
};
}
@ -66,6 +70,11 @@ export default class PullRequestConfigsParser extends ConfigsParser {
return "bp";
}
private getDefaultErrorComment(): string {
// TODO: fetch from arg or set default with placeholder {{error}}
return `The backport to \`${MESSAGE_TARGET_BRANCH_PLACEHOLDER}\` failed. Check the latest run for more details.`;
}
/**
* Parse the provided labels and return a list of target branches
* obtained by applying the provided pattern as regular expression extractor

View file

@ -44,4 +44,11 @@ import { BackportPullRequest, GitClientType, GitPullRequest } from "@bp/service/
*/
createPullRequest(backport: BackportPullRequest): Promise<string>;
/**
* Create a new comment on the provided pull request
* @param prUrl pull request's URL
* @param comment comment body
*/
createPullRequestComment(prUrl: string, comment: string): Promise<string | undefined>;
}

View file

@ -1,7 +1,7 @@
export interface GitPullRequest {
number?: number,
author: string,
url?: string,
url: string,
htmlUrl?: string,
state?: GitRepoState,
merged?: boolean,

View file

@ -158,6 +158,29 @@ export default class GitHubClient implements GitClient {
return data.html_url;
}
async createPullRequestComment(prUrl: string, comment: string): Promise<string | undefined> {
let commentUrl: string | undefined = undefined;
try {
const { owner, project, id } = this.extractPullRequestData(prUrl);
const { data } = await this.octokit.issues.createComment({
owner: owner,
repo: project,
issue_number: id,
body: comment
});
if (!data) {
throw new Error("Pull request comment creation failed");
}
commentUrl = data.url;
} catch (error) {
this.logger.error(`Error creating comment on pull request ${prUrl}: ${error}`);
}
return commentUrl;
}
// UTILS
/**

View file

@ -162,6 +162,29 @@ export default class GitLabClient implements GitClient {
return mr.web_url;
}
// https://docs.gitlab.com/ee/api/notes.html#create-new-issue-note
async createPullRequestComment(mrUrl: string, comment: string): Promise<string | undefined> {
const commentUrl: string | undefined = undefined;
try{
const { namespace, project, id } = this.extractMergeRequestData(mrUrl);
const projectId = this.getProjectId(namespace, project);
const { data } = await this.client.post(`/projects/${projectId}/issues/${id}/notes`, {
body: comment,
});
if (!data) {
throw new Error("Merge request comment creation failed");
}
} catch(error) {
this.logger.error(`Error creating comment on merge request ${mrUrl}: ${error}`);
}
return commentUrl;
}
// UTILS
/**
* Retrieve a gitlab user given its username
* @param username

View file

@ -16,6 +16,10 @@ export default class ConsoleLoggerService implements LoggerService {
this.context = newContext;
}
getContext(): string | undefined {
return this.context;
}
clearContext() {
this.context = undefined;
}

View file

@ -5,6 +5,8 @@ export default interface LoggerService {
setContext(newContext: string): void;
getContext(): string | undefined;
clearContext(): void;
trace(message: string): void;

View file

@ -0,0 +1,22 @@
import { MESSAGE_ERROR_PLACEHOLDER, MESSAGE_TARGET_BRANCH_PLACEHOLDER } from "@bp/service/configs/configs.types";
/**
* Inject the error message in the provided `message`.
* This is injected in place of the MESSAGE_ERROR_PLACEHOLDER placeholder
* @param message string that needs to be updated
* @param errMsg the error message that needs to be injected
*/
export const injectError = (message: string, errMsg: string): string => {
return message.replace(MESSAGE_ERROR_PLACEHOLDER, errMsg);
};
/**
* Inject the target branch into the provided `message`.
* This is injected in place of the MESSAGE_TARGET_BRANCH_PLACEHOLDER placeholder
* @param message string that needs to be updated
* @param targetBranch the target branch to inject
* @returns
*/
export const injectTargetBranch = (message: string, targetBranch: string): string => {
return message.replace(MESSAGE_TARGET_BRANCH_PLACEHOLDER, targetBranch);
};

View file

@ -9,6 +9,7 @@ import { BackportPullRequest, GitClientType, GitPullRequest } from "@bp/service/
import LoggerService from "@bp/service/logger/logger-service";
import LoggerServiceFactory from "@bp/service/logger/logger-service-factory";
import { inferGitClient, inferGitApiUrl, getGitTokenFromEnv } from "@bp/service/git/git-util";
import { injectError, injectTargetBranch } from "./runner-util";
interface Git {
gitClientType: GitClientType;
@ -92,6 +93,12 @@ export default class Runner {
});
} catch(error) {
this.logger.error(`Something went wrong backporting to ${pr.base}: ${error}`);
if (!configs.dryRun && configs.errorNotification.enabled && configs.errorNotification.message.length > 0) {
// notify the failure as comment in the original pull request
let comment = injectError(configs.errorNotification.message, error as string);
comment = injectTargetBranch(comment, pr.base);
await gitApi.createPullRequestComment(configs.originalPullRequest.url, comment);
}
failures.push(error as string);
}
}
@ -133,13 +140,12 @@ export default class Runner {
// 5. create new branch from target one and checkout
this.logger.debug("Creating local branch..");
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..");
this.logger.debug("Fetching pull request remote..");
const prefix = git.gitClientType === GitClientType.GITLAB ? "merge-requests" : "pull" ; // default is for gitlab
await git.gitCli.fetch(configs.folder, `${prefix}/${configs.originalPullRequest.number}/head:pr/${configs.originalPullRequest.number}`);
}
@ -165,4 +171,4 @@ export default class Runner {
this.logger.clearContext();
}
}
}