mirror of
https://code.forgejo.org/actions/git-backporting.git
synced 2025-05-17 02:59:13 -04:00
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:
parent
6042bcc40b
commit
2bb7f73112
28 changed files with 594 additions and 39 deletions
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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")),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>;
|
||||
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
export interface GitPullRequest {
|
||||
number?: number,
|
||||
author: string,
|
||||
url?: string,
|
||||
url: string,
|
||||
htmlUrl?: string,
|
||||
state?: GitRepoState,
|
||||
merged?: boolean,
|
||||
|
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -16,6 +16,10 @@ export default class ConsoleLoggerService implements LoggerService {
|
|||
this.context = newContext;
|
||||
}
|
||||
|
||||
getContext(): string | undefined {
|
||||
return this.context;
|
||||
}
|
||||
|
||||
clearContext() {
|
||||
this.context = undefined;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@ export default interface LoggerService {
|
|||
|
||||
setContext(newContext: string): void;
|
||||
|
||||
getContext(): string | undefined;
|
||||
|
||||
clearContext(): void;
|
||||
|
||||
trace(message: string): void;
|
||||
|
|
22
src/service/runner/runner-util.ts
Normal file
22
src/service/runner/runner-util.ts
Normal 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);
|
||||
};
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue