mirror of
https://github.com/makeplane/plane.git
synced 2025-12-29 00:24:56 +01:00
[SILO-137] fix: refactored pull-request handler into common behaviour (#2802)
* fix: refactored pull-request handler into common behaviour * fix: refactored gitlab integration to use common pr behaviour * chore: added test to support the pull request behaviour * chore: transformed to composition from inheritance * fix: PR comments * fix: extended type annotations and behaviour
This commit is contained in:
@@ -107,14 +107,6 @@ export class GithubService {
|
||||
return data;
|
||||
}
|
||||
|
||||
async getPullRequest(owner: string, repo: string, pull_number: number) {
|
||||
return this.client.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
});
|
||||
}
|
||||
|
||||
async getPullRequestWithClosingReference(
|
||||
owner: string,
|
||||
repo: string,
|
||||
|
||||
@@ -12,6 +12,7 @@ export type GithubInstallation = RestEndpointMethodTypes["apps"]["getInstallatio
|
||||
export type GithubRepository =
|
||||
RestEndpointMethodTypes["apps"]["listReposAccessibleToInstallation"]["response"]["data"]["repositories"];
|
||||
export type GithubIssue = RestEndpointMethodTypes["issues"]["create"]["parameters"];
|
||||
export type GithubIssueComment = RestEndpointMethodTypes["issues"]["createComment"]["response"]["data"];
|
||||
export type GithubPullRequest = RestEndpointMethodTypes["pulls"]["get"]["response"]["data"];
|
||||
|
||||
export type WebhookGitHubIssue = components["schemas"]["webhooks_issue"];
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { GitlabUser } from "@/gitlab/types";
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { GitlabUser } from "@/gitlab/types";
|
||||
|
||||
export class GitLabService {
|
||||
private client: AxiosInstance;
|
||||
client: AxiosInstance;
|
||||
|
||||
constructor(
|
||||
access_token: string,
|
||||
@@ -114,10 +114,10 @@ export class GitLabService {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param projectId - entityId or gitlab project id
|
||||
* @param hookId - webhookId or gitlab hook id
|
||||
* @returns
|
||||
* @returns
|
||||
*/
|
||||
async removeWebhookFromProject(projectId: string, hookId: string) {
|
||||
try {
|
||||
@@ -143,10 +143,10 @@ export class GitLabService {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param groupId - entityId or gitlab group id
|
||||
* @param hookId - webhookId or gitlab hook id
|
||||
* @returns
|
||||
* @returns
|
||||
*/
|
||||
async removeWebhookFromGroup(groupId: string, hookId: string) {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} **/
|
||||
export default {
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+\.tsx?$": ["ts-jest",{}],
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1'
|
||||
},
|
||||
};
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.tsx?$': 'ts-jest'
|
||||
}
|
||||
};
|
||||
|
||||
126
silo/src/apps/github/services/github.service.ts
Normal file
126
silo/src/apps/github/services/github.service.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { GithubApiProps, GithubService as GithubAPIService, GithubIssueComment } from "@plane/etl/github";
|
||||
import { IGitComment, IPullRequestDetails, IPullRequestService } from "@/types/behaviours/git";
|
||||
|
||||
/**
|
||||
* Service connected with octokit and facilitating github data
|
||||
*/
|
||||
export class GithubIntegrationService implements IPullRequestService {
|
||||
private apiService: GithubAPIService;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
* @param params - The parameters
|
||||
*/
|
||||
constructor(params: GithubApiProps) {
|
||||
this.apiService = new GithubAPIService(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a pull request
|
||||
* @param owner - The owner of the repository
|
||||
* @param repo - The repository name
|
||||
* @param pullRequestIdentifier - The pull request identifier
|
||||
* @returns The pull request details
|
||||
*/
|
||||
async getPullRequest(owner: string, repo: string, pullRequestIdentifier: string): Promise<IPullRequestDetails> {
|
||||
const pullRequest = await this.apiService.client.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: Number(pullRequestIdentifier),
|
||||
});
|
||||
|
||||
return {
|
||||
title: pullRequest.data.title,
|
||||
description: pullRequest.data.body || "",
|
||||
number: pullRequest.data.number,
|
||||
url: pullRequest.data.html_url,
|
||||
state: pullRequest.data.state,
|
||||
merged: pullRequest.data.merged,
|
||||
draft: pullRequest.data.draft || false,
|
||||
mergeable: pullRequest.data.mergeable,
|
||||
mergeable_state: pullRequest.data.mergeable_state,
|
||||
repository: {
|
||||
owner,
|
||||
name: repo,
|
||||
id: pullRequest.data.number,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pull request comment
|
||||
* @param owner - The owner of the repository
|
||||
* @param repo - The repository name
|
||||
* @param pullRequestIdentifier - The pull request identifier
|
||||
* @param body - The body of the comment
|
||||
* @returns The comment
|
||||
*/
|
||||
async createPullRequestComment(owner: string, repo: string, pullRequestIdentifier: string, body: string): Promise<IGitComment> {
|
||||
const comment = await this.apiService.client.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: Number(pullRequestIdentifier),
|
||||
body,
|
||||
});
|
||||
|
||||
return this.transformComment(comment.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pull request comments
|
||||
* @param owner - The owner of the repository
|
||||
* @param repo - The repository name
|
||||
* @param pullRequestIdentifier - The pull request identifier
|
||||
* @returns The comments
|
||||
*/
|
||||
async getPullRequestComments(owner: string, repo: string, pullRequestIdentifier: string): Promise<IGitComment[]> {
|
||||
const comments = await this.apiService.client.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: Number(pullRequestIdentifier),
|
||||
});
|
||||
|
||||
if (comments.data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return comments.data.map(this.transformComment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a pull request comment
|
||||
* @param owner - The owner of the repository
|
||||
* @param repo - The repository name
|
||||
* @param commentId - The comment identifier
|
||||
* @param body - The body of the comment
|
||||
* @returns The comment
|
||||
*/
|
||||
async updatePullRequestComment(owner: string, repo: string, commentId: string, body: string): Promise<IGitComment> {
|
||||
const comment = await this.apiService.client.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: Number(commentId),
|
||||
body,
|
||||
});
|
||||
|
||||
return this.transformComment(comment.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a comment
|
||||
* @param comment - The comment
|
||||
* @returns The transformed comment
|
||||
*/
|
||||
private transformComment(comment: GithubIssueComment): IGitComment {
|
||||
return {
|
||||
id: comment.id,
|
||||
body: comment.body || "",
|
||||
created_at: comment.created_at,
|
||||
user: {
|
||||
id: comment.user?.id || "",
|
||||
login: comment.user?.login || "",
|
||||
name: comment.user?.name || "",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,15 @@
|
||||
import { E_INTEGRATION_KEYS, TServiceCredentials } from "@plane/etl/core";
|
||||
import { createGithubService, GithubPullRequestDedupPayload, GithubService } from "@plane/etl/github";
|
||||
import { MergeRequestEvent } from "@plane/etl/gitlab";
|
||||
import { Client, ExIssue, Client as PlaneClient } from "@plane/sdk";
|
||||
import { classifyPullRequestEvent, getConnectionDetails } from "@/apps/github/helpers/helpers";
|
||||
import { GithubPullRequestDedupPayload } from "@plane/etl/github";
|
||||
import { Client as PlaneClient } from "@plane/sdk";
|
||||
import { getConnectionDetails } from "@/apps/github/helpers/helpers";
|
||||
import { GithubIntegrationService } from "@/apps/github/services/github.service";
|
||||
import {
|
||||
GithubEntityConnection,
|
||||
githubEntityConnectionSchema,
|
||||
GithubWorkspaceConnection,
|
||||
PullRequestWebhookActions,
|
||||
PullRequestWebhookActions
|
||||
} from "@/apps/github/types";
|
||||
import { env } from "@/env";
|
||||
import { CONSTANTS } from "@/helpers/constants";
|
||||
import { getReferredIssues, IssueReference, IssueWithReference } from "@/helpers/parser";
|
||||
import { PullRequestBehaviour } from "@/lib/behaviours";
|
||||
import { logger } from "@/logger";
|
||||
import { getAPIClient } from "@/services/client";
|
||||
import { verifyEntityConnection } from "@/types";
|
||||
|
||||
const apiClient = getAPIClient();
|
||||
|
||||
@@ -47,196 +42,32 @@ const handlePullRequestOpened = async (data: GithubPullRequestDedupPayload) => {
|
||||
// Get the workspace connection for the installation
|
||||
const accountId = data.accountId;
|
||||
|
||||
const { workspaceConnection } = await getConnectionDetails({
|
||||
const { workspaceConnection, entityConnection } = await getConnectionDetails({
|
||||
accountId: accountId.toString(),
|
||||
credentials: planeCredentials as TServiceCredentials,
|
||||
installationId: data.installationId.toString(),
|
||||
repositoryId: data.repositoryId.toString(),
|
||||
});
|
||||
|
||||
const ghService = createGithubService(env.GITHUB_APP_ID, env.GITHUB_PRIVATE_KEY, data.installationId.toString());
|
||||
const ghPullRequest = await ghService.getPullRequest(data.owner, data.repositoryName, Number(data.pullRequestNumber));
|
||||
|
||||
const planeClient = new PlaneClient({
|
||||
baseURL: env.API_BASE_URL,
|
||||
apiToken: planeCredentials.target_access_token,
|
||||
});
|
||||
|
||||
if (data.owner && data.repositoryName && data.pullRequestNumber) {
|
||||
const { closingReferences, nonClosingReferences } = getReferredIssues(
|
||||
ghPullRequest.data.title,
|
||||
ghPullRequest.data.body || ""
|
||||
);
|
||||
const stateEvent = classifyPullRequestEvent(ghPullRequest.data);
|
||||
|
||||
let entityConnection: GithubEntityConnection | undefined;
|
||||
|
||||
try {
|
||||
const targetEntityConnection = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({
|
||||
workspace_id: workspaceConnection.workspace_id,
|
||||
entity_type: E_INTEGRATION_KEYS.GITHUB,
|
||||
entity_id: data.repositoryId.toString(),
|
||||
});
|
||||
|
||||
if (targetEntityConnection.length > 0) {
|
||||
entityConnection = verifyEntityConnection(githubEntityConnectionSchema, targetEntityConnection[0] as any);
|
||||
}
|
||||
} catch {
|
||||
logger.error(
|
||||
`[GITHUB] Error while verifying entity connection for pull request ${data.pullRequestNumber} in repo ${data.owner}/${data.repositoryName}`
|
||||
);
|
||||
}
|
||||
|
||||
const targetState = getTargetState(stateEvent, entityConnection);
|
||||
|
||||
const referredIssues =
|
||||
stateEvent && ["MR_CLOSED", "MR_MERGED"].includes(stateEvent)
|
||||
? closingReferences
|
||||
: [...closingReferences, ...nonClosingReferences];
|
||||
|
||||
const updatedIssues = await Promise.all(
|
||||
referredIssues.map((reference) =>
|
||||
updateIssue(
|
||||
planeClient,
|
||||
workspaceConnection,
|
||||
reference,
|
||||
targetState,
|
||||
ghPullRequest.data.title,
|
||||
ghPullRequest.data.number,
|
||||
ghPullRequest.data.html_url
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const validIssues = updatedIssues.filter((issue): issue is IssueWithReference => issue !== null);
|
||||
|
||||
if (validIssues.length > 0) {
|
||||
const body = createCommentBody(validIssues, nonClosingReferences, workspaceConnection);
|
||||
await handleComment(ghService, data.owner, data.repositoryName, Number(data.pullRequestNumber), body);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleComment = async (
|
||||
ghService: GithubService,
|
||||
owner: string,
|
||||
repo: string,
|
||||
pullNumber: number,
|
||||
body: string
|
||||
) => {
|
||||
const commentPrefix = "Pull Request Linked with Plane Issues";
|
||||
const newCommentPrefix = "Pull Request Linked with Plane Work Items";
|
||||
|
||||
const existingComments = await ghService.getPullRequestComments(owner, repo, pullNumber);
|
||||
const existingComment = existingComments.data.find(
|
||||
(comment) => comment.body?.startsWith(commentPrefix) || comment.body?.startsWith(newCommentPrefix)
|
||||
const pullRequestBehaviour = new PullRequestBehaviour(
|
||||
E_INTEGRATION_KEYS.GITHUB,
|
||||
workspaceConnection.workspace_slug,
|
||||
new GithubIntegrationService({
|
||||
appId: env.GITHUB_APP_ID!,
|
||||
privateKey: env.GITHUB_PRIVATE_KEY!,
|
||||
installationId: data.installationId.toString(),
|
||||
}),
|
||||
planeClient,
|
||||
entityConnection?.config || {}
|
||||
);
|
||||
|
||||
if (existingComment) {
|
||||
await ghService.updatePullRequestComment(owner, repo, existingComment.id, body);
|
||||
logger.info(`[GITHUB] Updated comment for pull request ${pullNumber} in repo ${owner}/${repo}`);
|
||||
} else {
|
||||
await ghService.createPullRequestComment(owner, repo, pullNumber, body);
|
||||
logger.info(`[GITHUB] Created new comment for pull request ${pullNumber} in repo ${owner}/${repo}`);
|
||||
}
|
||||
};
|
||||
|
||||
const createCommentBody = (
|
||||
issues: IssueWithReference[],
|
||||
nonClosingReferences: IssueReference[],
|
||||
workspaceConnection: GithubWorkspaceConnection
|
||||
) => {
|
||||
const commentPrefix = "Pull Request Linked with Plane Work Items";
|
||||
let body = `${commentPrefix}\n\n`;
|
||||
|
||||
const closingIssues = issues.filter(
|
||||
({ reference }) =>
|
||||
!nonClosingReferences.some(
|
||||
(ref) => ref.identifier === reference.identifier && ref.sequence === reference.sequence
|
||||
)
|
||||
);
|
||||
|
||||
const nonClosingIssues = issues.filter(({ reference }) =>
|
||||
nonClosingReferences.some((ref) => ref.identifier === reference.identifier && ref.sequence === reference.sequence)
|
||||
);
|
||||
|
||||
for (const { reference, issue } of closingIssues) {
|
||||
body += `- [${reference.identifier}-${reference.sequence}] [${issue.name}](${env.APP_BASE_URL}/${workspaceConnection.workspace_slug}/projects/${issue.project}/issues/${issue.id})\n`;
|
||||
}
|
||||
|
||||
if (nonClosingIssues.length > 0) {
|
||||
body += `\n\nReferences\n\n`;
|
||||
for (const { reference, issue } of nonClosingIssues) {
|
||||
body += `- [${reference.identifier}-${reference.sequence}] [${issue.name}](${env.APP_BASE_URL}/${workspaceConnection.workspace_slug}/projects/${issue.project}/issues/${issue.id})\n`;
|
||||
}
|
||||
}
|
||||
|
||||
body += `\n\nComment Automatically Generated by [Plane](https://plane.so)\n`;
|
||||
return body;
|
||||
};
|
||||
|
||||
const getTargetState = (event: MergeRequestEvent | undefined, entityConnection: GithubEntityConnection | undefined) => {
|
||||
if (!event || !entityConnection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetState = entityConnection.config.states.mergeRequestEventMapping[event];
|
||||
if (!targetState) {
|
||||
logger.error(`[GITHUB] Target state not found for event ${event}, skipping...`);
|
||||
return null;
|
||||
}
|
||||
return targetState;
|
||||
};
|
||||
|
||||
const updateIssue = async (
|
||||
planeClient: Client,
|
||||
workspaceConnection: GithubWorkspaceConnection,
|
||||
reference: IssueReference,
|
||||
targetState: { name: string; id: string } | null,
|
||||
prTitle: string,
|
||||
prNumber: number,
|
||||
prUrl: string
|
||||
): Promise<IssueWithReference | null> => {
|
||||
let issue: ExIssue | null = null;
|
||||
|
||||
try {
|
||||
issue = await planeClient.issue.getIssueByIdentifier(
|
||||
workspaceConnection.workspace_slug,
|
||||
reference.identifier,
|
||||
reference.sequence
|
||||
);
|
||||
|
||||
if (targetState) {
|
||||
await planeClient.issue.update(workspaceConnection.workspace_slug, issue.project, issue.id, {
|
||||
state: targetState.id,
|
||||
});
|
||||
|
||||
logger.info(`[GITHUB] Issue ${reference.identifier} updated to state ${targetState.name}`);
|
||||
}
|
||||
|
||||
// create link to the pull request to the issue
|
||||
const linkTitle = `[${prNumber}] ${prTitle}`;
|
||||
await planeClient.issue.createLink(workspaceConnection.workspace_slug, issue.project, issue.id, linkTitle, prUrl);
|
||||
return { reference, issue };
|
||||
} catch (error: any) {
|
||||
if (error?.detail && error?.detail.includes(CONSTANTS.NO_PERMISSION_ERROR)) {
|
||||
logger.info(
|
||||
`[GITHUB] No permission to process event: ${error.detail} ${reference.identifier}-${reference.sequence}`
|
||||
);
|
||||
|
||||
if (issue) {
|
||||
return { reference, issue };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.error(`[GITHUB] Error updating issue ${reference.identifier}-${reference.sequence}: ${error}`);
|
||||
|
||||
if (issue) {
|
||||
return { reference, issue };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
await pullRequestBehaviour.handleEvent({
|
||||
...data,
|
||||
pullRequestIdentifier: data.pullRequestNumber.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -216,6 +216,7 @@ export default class GitlabController {
|
||||
|
||||
return res.redirect(redirectUri);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return res.redirect(`${redirectUri}?error=${E_SILO_ERROR_CODES.GENERIC_ERROR}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
GitlabMergeRequestEvent,
|
||||
gitlabWorkspaceConnectionSchema,
|
||||
} from "@plane/etl/gitlab";
|
||||
import { GitlabConnectionDetails } from "../types";
|
||||
import { logger } from "@/logger";
|
||||
import { verifyEntityConnection, verifyEntityConnections, verifyWorkspaceConnection } from "@/types";
|
||||
import { getAPIClient } from "@/services/client";
|
||||
import { verifyEntityConnection, verifyEntityConnections, verifyWorkspaceConnection } from "@/types";
|
||||
import { GitlabConnectionDetails } from "../types";
|
||||
|
||||
const apiClient = getAPIClient();
|
||||
|
||||
@@ -35,6 +35,8 @@ export const getGitlabConnectionDetails = async (
|
||||
return;
|
||||
}
|
||||
|
||||
const verifiedEntityConnection = verifyEntityConnection(gitlabEntityConnectionSchema, entityConnection as any);
|
||||
|
||||
// Find the workspace connection for the project
|
||||
const workspaceConnection = await apiClient.workspaceConnection.getWorkspaceConnection(
|
||||
entityConnection.workspace_connection_id
|
||||
@@ -45,6 +47,11 @@ export const getGitlabConnectionDetails = async (
|
||||
return;
|
||||
}
|
||||
|
||||
const verifiedWorkspaceConnection = verifyWorkspaceConnection(
|
||||
gitlabWorkspaceConnectionSchema,
|
||||
workspaceConnection as any
|
||||
);
|
||||
|
||||
// project connections for this workspace connection for target state mapping
|
||||
const projectConnectionSet = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({
|
||||
workspace_connection_id: workspaceConnection.id,
|
||||
@@ -53,16 +60,13 @@ export const getGitlabConnectionDetails = async (
|
||||
|
||||
if (projectConnectionSet.length === 0) {
|
||||
logger.error(`[GITLAB] Plane Project connection not found for project ${data.project.id}, skipping...`);
|
||||
return;
|
||||
|
||||
return {
|
||||
workspaceConnection: verifiedWorkspaceConnection,
|
||||
entityConnection: verifiedEntityConnection,
|
||||
};
|
||||
}
|
||||
|
||||
const verifiedWorkspaceConnection = verifyWorkspaceConnection(
|
||||
gitlabWorkspaceConnectionSchema,
|
||||
workspaceConnection as any
|
||||
);
|
||||
|
||||
const verifiedEntityConnection = verifyEntityConnection(gitlabEntityConnectionSchema, entityConnection as any);
|
||||
|
||||
const verifiedProjectConnection = verifyEntityConnections(gitlabEntityConnectionSchema, projectConnectionSet as any);
|
||||
|
||||
return {
|
||||
|
||||
125
silo/src/apps/gitlab/services/gitlab.service.ts
Normal file
125
silo/src/apps/gitlab/services/gitlab.service.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { GitLabService as GitLabAPIService } from "@plane/etl/gitlab";
|
||||
import { logger } from "@/logger";
|
||||
import { IGitComment, IPullRequestDetails, IPullRequestService } from "@/types/behaviours/git";
|
||||
|
||||
export class GitlabIntegrationService implements IPullRequestService {
|
||||
private apiService: GitLabAPIService;
|
||||
private projectId: string;
|
||||
|
||||
constructor(
|
||||
access_token: string,
|
||||
refresh_token: string,
|
||||
refresh_callback: (access_token: string, refresh_token: string) => Promise<void>,
|
||||
hostname: string = "gitlab.com",
|
||||
projectId: string
|
||||
) {
|
||||
this.apiService = new GitLabAPIService(access_token, refresh_token, refresh_callback, hostname);
|
||||
this.projectId = projectId;
|
||||
}
|
||||
|
||||
async getPullRequest(owner: string, repositoryName: string, pullRequestIdentifier: string): Promise<IPullRequestDetails> {
|
||||
try {
|
||||
const response = await this.apiService.client.get(
|
||||
`/projects/${encodeURIComponent(this.projectId)}/merge_requests/${pullRequestIdentifier}`
|
||||
);
|
||||
const mergeRequest = response.data;
|
||||
|
||||
return this.transformMergeRequestToPR(mergeRequest, owner, repositoryName);
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching pull request: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getPullRequestComments(owner: string, repo: string, pullRequestIdentifier: string): Promise<IGitComment[]> {
|
||||
try {
|
||||
const comments = await this.apiService.getMergeRequestComments(
|
||||
Number(this.projectId),
|
||||
Number(pullRequestIdentifier)
|
||||
);
|
||||
|
||||
return comments.map(this.transformComment);
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching pull request comments: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createPullRequestComment(owner: string, repo: string, pullRequestIdentifier: string, body: string): Promise<IGitComment> {
|
||||
try {
|
||||
const comment = await this.apiService.createMergeRequestComment(
|
||||
Number(this.projectId),
|
||||
Number(pullRequestIdentifier),
|
||||
body
|
||||
);
|
||||
|
||||
return this.transformComment(comment);
|
||||
} catch (error) {
|
||||
logger.error(`Error creating pull request comment: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updatePullRequestComment(owner: string, repo: string, commentId: number | string, body: string): Promise<IGitComment> {
|
||||
try {
|
||||
const mergeRequestIid = await this.getMergeRequestIidForComment(commentId);
|
||||
|
||||
const comment = await this.apiService.updateMergeRequestComment(
|
||||
Number(this.projectId),
|
||||
mergeRequestIid,
|
||||
Number(commentId),
|
||||
body
|
||||
);
|
||||
|
||||
return this.transformComment(comment);
|
||||
} catch (error) {
|
||||
logger.error(`Error updating pull request comment: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async getMergeRequestIidForComment(commentId: number | string): Promise<number> {
|
||||
try {
|
||||
const response = await this.apiService.client.get(
|
||||
`/projects/${this.projectId}/notes/${commentId}`
|
||||
);
|
||||
return response.data.noteable_iid;
|
||||
} catch (error) {
|
||||
logger.error(`Error getting merge request IID for comment: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private transformMergeRequestToPR(mergeRequest: any, owner: string, repositoryName: string): IPullRequestDetails {
|
||||
return {
|
||||
title: mergeRequest.title,
|
||||
description: mergeRequest.description || "",
|
||||
number: mergeRequest.iid,
|
||||
url: mergeRequest.web_url,
|
||||
repository: {
|
||||
owner,
|
||||
name: repositoryName,
|
||||
id: mergeRequest.project_id
|
||||
},
|
||||
state: mergeRequest.state === "opened" ? "open" : "closed",
|
||||
merged: mergeRequest.state === "merged",
|
||||
draft: mergeRequest.work_in_progress || false,
|
||||
mergeable: mergeRequest.mergeable || null,
|
||||
mergeable_state: mergeRequest.merge_status || null
|
||||
};
|
||||
}
|
||||
|
||||
private transformComment(comment: any): IGitComment {
|
||||
return {
|
||||
id: comment.id,
|
||||
body: comment.body,
|
||||
created_at: comment.created_at,
|
||||
updated_at: comment.updated_at,
|
||||
user: {
|
||||
id: comment.author.id,
|
||||
username: comment.author.username,
|
||||
name: comment.author.name
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,6 @@ export type GitlabEntityConnection = TWorkspaceEntityConnection<typeof gitlabEnt
|
||||
|
||||
export type GitlabConnectionDetails = {
|
||||
workspaceConnection: GitlabWorkspaceConnection;
|
||||
entityConnection: GitlabEntityConnection;
|
||||
projectConnections: GitlabEntityConnection[];
|
||||
entityConnection?: GitlabEntityConnection;
|
||||
projectConnections?: GitlabEntityConnection[];
|
||||
};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { E_INTEGRATION_KEYS } from "@plane/etl/core";
|
||||
import { createGitLabService, GitlabMergeRequestEvent, GitlabNote, GitLabService } from "@plane/etl/gitlab";
|
||||
import { GitlabMergeRequestEvent } from "@plane/etl/gitlab";
|
||||
import { Client } from "@plane/sdk";
|
||||
import { TWorkspaceCredential } from "@plane/types";
|
||||
import { classifyMergeRequestEvent } from "@/apps/gitlab/helpers";
|
||||
import { getGitlabConnectionDetails } from "@/apps/gitlab/helpers/connection-details";
|
||||
import { GitlabConnectionDetails, GitlabEntityConnection } from "@/apps/gitlab/types";
|
||||
import { GitlabIntegrationService } from "@/apps/gitlab/services/gitlab.service";
|
||||
import { GitlabConnectionDetails } from "@/apps/gitlab/types";
|
||||
import { env } from "@/env";
|
||||
import { getReferredIssues, IssueReference, IssueWithReference } from "@/helpers/parser";
|
||||
import { PullRequestBehaviour } from "@/lib/behaviours";
|
||||
import { logger } from "@/logger";
|
||||
import { getAPIClient } from "@/services/client";
|
||||
|
||||
@@ -15,6 +15,7 @@ const apiClient = getAPIClient();
|
||||
const getConnectionAndCredentials = async (
|
||||
data: GitlabMergeRequestEvent
|
||||
): Promise<[GitlabConnectionDetails, TWorkspaceCredential] | null> => {
|
||||
console.log(data);
|
||||
const connectionDetails = await getGitlabConnectionDetails(data);
|
||||
if (!connectionDetails) {
|
||||
logger.error(`[GITLAB] Connection details not found for project ${data.project.id}, skipping...`);
|
||||
@@ -32,141 +33,16 @@ const getConnectionAndCredentials = async (
|
||||
return [connectionDetails, credentials];
|
||||
};
|
||||
|
||||
const getTargetState = (data: GitlabMergeRequestEvent, entityConnection: any) => {
|
||||
const event = classifyMergeRequestEvent(data);
|
||||
|
||||
if (!event) return null;
|
||||
|
||||
const targetState = entityConnection.config.states.mergeRequestEventMapping[event];
|
||||
if (!targetState) {
|
||||
logger.error(`[GITLAB] Target state not found for event ${event}, skipping...`);
|
||||
return null;
|
||||
}
|
||||
return targetState;
|
||||
};
|
||||
|
||||
const updateIssue = async (
|
||||
planeClient: Client,
|
||||
entityConnection: GitlabEntityConnection,
|
||||
reference: IssueReference,
|
||||
targetState: any,
|
||||
projectId: number,
|
||||
prTitle: string,
|
||||
prNumber: number,
|
||||
prUrl: string
|
||||
): Promise<IssueWithReference | null> => {
|
||||
try {
|
||||
const issue = await planeClient.issue.getIssueByIdentifier(
|
||||
entityConnection.workspace_slug,
|
||||
reference.identifier,
|
||||
reference.sequence
|
||||
);
|
||||
|
||||
await planeClient.issue.update(entityConnection.workspace_slug, issue.project, issue.id, {
|
||||
state: targetState.id,
|
||||
});
|
||||
|
||||
// create link to the pull request to the issue
|
||||
const linkTitle = `[${prNumber}] ${prTitle}`;
|
||||
try {
|
||||
await planeClient.issue.createLink(entityConnection.workspace_slug, issue.project, issue.id, linkTitle, prUrl);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
logger.info(`[GITLAB] Issue ${reference.identifier} updated to state ${targetState.name} for project ${projectId}`);
|
||||
return { reference, issue };
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[GITLAB] Error updating issue ${reference.identifier}-${reference.sequence} for project ${projectId}: ${error}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const createCommentBody = (
|
||||
issues: IssueWithReference[],
|
||||
nonClosingReferences: IssueReference[],
|
||||
workspaceConnection: any
|
||||
) => {
|
||||
const commentPrefix = "Merge Request Linked with Plane Issues";
|
||||
let body = `${commentPrefix}\n\n`;
|
||||
|
||||
const closingIssues = issues.filter(
|
||||
({ reference }) =>
|
||||
!nonClosingReferences.some(
|
||||
(ref) => ref.identifier === reference.identifier && ref.sequence === reference.sequence
|
||||
)
|
||||
);
|
||||
|
||||
const nonClosingIssues = issues.filter(({ reference }) =>
|
||||
nonClosingReferences.some((ref) => ref.identifier === reference.identifier && ref.sequence === reference.sequence)
|
||||
);
|
||||
|
||||
for (const { reference, issue } of closingIssues) {
|
||||
body += `- [${reference.identifier}-${reference.sequence}] [${issue.name}](${env.APP_BASE_URL}/${workspaceConnection.workspaceSlug}/projects/${issue.project}/issues/${issue.id})\n`;
|
||||
}
|
||||
|
||||
if (nonClosingIssues.length > 0) {
|
||||
body += `\n\nReferences\n\n`;
|
||||
for (const { reference, issue } of nonClosingIssues) {
|
||||
body += `- [${reference.identifier}-${reference.sequence}] [${issue.name}](${env.APP_BASE_URL}/${workspaceConnection.workspaceSlug}/projects/${issue.project}/issues/${issue.id})\n`;
|
||||
}
|
||||
}
|
||||
|
||||
body += `\n\nComment Automatically Generated by [Plane](https://plane.so)\n`;
|
||||
return body;
|
||||
};
|
||||
|
||||
const handleComment = async (
|
||||
gitlabService: GitLabService,
|
||||
projectId: number,
|
||||
mergeRequestIid: number,
|
||||
body: string
|
||||
) => {
|
||||
const commentPrefix = "Merge Request Linked with Plane Issues";
|
||||
const existingComments = await gitlabService.getMergeRequestComments(projectId, mergeRequestIid);
|
||||
const existingComment = existingComments.find((comment: GitlabNote) => comment.body.startsWith(commentPrefix));
|
||||
|
||||
if (existingComment) {
|
||||
await gitlabService.updateMergeRequestComment(projectId, mergeRequestIid, existingComment.id, body);
|
||||
logger.info(`[GITLAB] Updated comment for merge request ${mergeRequestIid} in project ${projectId}`);
|
||||
} else {
|
||||
await gitlabService.createMergeRequestComment(projectId, mergeRequestIid, body);
|
||||
logger.info(`[GITLAB] Created new comment for merge request ${mergeRequestIid} in project ${projectId}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleMergeRequest = async (data: GitlabMergeRequestEvent) => {
|
||||
try {
|
||||
const result = await getConnectionAndCredentials(data);
|
||||
if (!result) return;
|
||||
|
||||
const [{ workspaceConnection, entityConnection, projectConnections }, credentials] = result;
|
||||
|
||||
const { closingReferences, nonClosingReferences } = getReferredIssues(
|
||||
data.object_attributes.title,
|
||||
data.object_attributes.description
|
||||
);
|
||||
if (closingReferences.length === 0 && nonClosingReferences.length === 0) {
|
||||
logger.info(`[GITLAB] No issue references found for project ${data.project.id}, skipping...`);
|
||||
return;
|
||||
}
|
||||
|
||||
const event = classifyMergeRequestEvent(data);
|
||||
if (!event) return;
|
||||
const referredIssues = ["MR_CLOSED", "MR_MERGED"].includes(event)
|
||||
? closingReferences
|
||||
: [...closingReferences, ...nonClosingReferences];
|
||||
|
||||
if (!workspaceConnection.target_hostname) {
|
||||
logger.error("Target hostname not found");
|
||||
return;
|
||||
}
|
||||
const [{ workspaceConnection }, credentials] = result;
|
||||
|
||||
const planeClient = new Client({
|
||||
apiToken: credentials.target_access_token!,
|
||||
baseURL: workspaceConnection.target_hostname,
|
||||
baseURL: env.API_BASE_URL
|
||||
});
|
||||
|
||||
const refreshTokenCallback = async (access_token: string, refresh_token: string) => {
|
||||
@@ -180,55 +56,28 @@ export const handleMergeRequest = async (data: GitlabMergeRequestEvent) => {
|
||||
});
|
||||
};
|
||||
|
||||
const gitlabService = createGitLabService(
|
||||
const gitlabService = new GitlabIntegrationService(
|
||||
credentials.source_access_token!,
|
||||
credentials.source_refresh_token!,
|
||||
refreshTokenCallback,
|
||||
workspaceConnection.source_hostname!
|
||||
workspaceConnection.source_hostname!,
|
||||
data.project.id.toString()
|
||||
);
|
||||
|
||||
// we need to get the plane project attached to referred issues and then get target state for each and then do the updates
|
||||
// get the exissues from identifiers it'll have the project attached
|
||||
// loop through the referred issues, check if it has a plane project attached and then update the state using project connection target state
|
||||
|
||||
const allReferredIssues = await Promise.all(
|
||||
referredIssues.map(async (reference) => {
|
||||
const issue = await planeClient.issue.getIssueByIdentifier(
|
||||
entityConnection.workspace_slug,
|
||||
reference.identifier,
|
||||
reference.sequence
|
||||
);
|
||||
return { reference, issue };
|
||||
})
|
||||
const pullRequestBehaviour = new PullRequestBehaviour(
|
||||
"gitlab",
|
||||
workspaceConnection.workspace_slug,
|
||||
gitlabService,
|
||||
planeClient,
|
||||
);
|
||||
|
||||
const updatedIssues = await Promise.all(
|
||||
allReferredIssues.map(async (referredIssue) => {
|
||||
const targetProject = projectConnections.find((project) => project.project_id === referredIssue.issue.project);
|
||||
if (targetProject) {
|
||||
const targetState = getTargetState(data, targetProject);
|
||||
if (!targetState) return null;
|
||||
return updateIssue(
|
||||
planeClient,
|
||||
entityConnection,
|
||||
referredIssue.reference,
|
||||
targetState,
|
||||
data.project.id,
|
||||
data.object_attributes.title,
|
||||
data.object_attributes.iid,
|
||||
data.object_attributes.url
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})
|
||||
);
|
||||
console.log(data);
|
||||
|
||||
const validIssues = updatedIssues.filter((issue): issue is IssueWithReference => issue !== null);
|
||||
|
||||
if (validIssues.length > 0) {
|
||||
const body = createCommentBody(validIssues, nonClosingReferences, workspaceConnection);
|
||||
await handleComment(gitlabService, data.project.id, data.object_attributes.iid, body);
|
||||
}
|
||||
await pullRequestBehaviour.handleEvent({
|
||||
owner: data.project.path_with_namespace,
|
||||
repositoryName: data.project.name,
|
||||
pullRequestIdentifier: data.object_attributes.iid.toString(),
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[GITLAB] Error handling merge request: ${(error as Error)?.stack}`);
|
||||
throw error;
|
||||
|
||||
@@ -3,3 +3,12 @@ export enum CONSTANTS {
|
||||
NO_PERMISSION_ERROR_MESSAGE = "You don't have permission to access this resource.",
|
||||
SOMETHING_WENT_WRONG = "Something went wrong. Please try again.",
|
||||
}
|
||||
|
||||
export enum E_STATE_MAP_KEYS {
|
||||
DRAFT_MR_OPENED = "DRAFT_MR_OPENED",
|
||||
MR_OPENED = "MR_OPENED",
|
||||
MR_REVIEW_REQUESTED = "MR_REVIEW_REQUESTED",
|
||||
MR_READY_FOR_MERGE = "MR_READY_FOR_MERGE",
|
||||
MR_MERGED = "MR_MERGED",
|
||||
MR_CLOSED = "MR_CLOSED",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import { Client as PlaneClient } from "@plane/sdk";
|
||||
import { CONSTANTS } from "@/helpers/constants";
|
||||
import { logger } from "@/logger";
|
||||
import { IGitComment, IPullRequestDetails } from "@/types/behaviours/git";
|
||||
import { PullRequestBehaviour } from "../pull-request.behaviour";
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("@/logger");
|
||||
jest.mock("@/env", () => ({
|
||||
env: {
|
||||
APP_BASE_URL: "https://app.plane.so"
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock types and data
|
||||
type MockPullRequestData = {
|
||||
owner: string;
|
||||
repositoryName: string;
|
||||
pullRequestIdentifier: string;
|
||||
};
|
||||
|
||||
// Helper function to create a mock PR service
|
||||
const createMockPullRequestService = () => ({
|
||||
getPullRequest: jest.fn(),
|
||||
getPullRequestComments: jest.fn(),
|
||||
createPullRequestComment: jest.fn(),
|
||||
updatePullRequestComment: jest.fn(),
|
||||
});
|
||||
|
||||
// Helper function to create a mock Plane client
|
||||
const createMockPlaneClient = () => ({
|
||||
issue: {
|
||||
getIssueByIdentifier: jest.fn(),
|
||||
update: jest.fn(),
|
||||
createLink: jest.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
describe("PullRequestBehaviour", () => {
|
||||
let service: ReturnType<typeof createMockPullRequestService>;
|
||||
let planeClient: ReturnType<typeof createMockPlaneClient>;
|
||||
let behaviour: PullRequestBehaviour;
|
||||
|
||||
const mockConfig = {
|
||||
states: {
|
||||
mergeRequestEventMapping: {
|
||||
"MR_MERGED": { id: "merged-state", name: "Merged" },
|
||||
"MR_CLOSED": { id: "closed-state", name: "Closed" },
|
||||
"MR_OPENED": { id: "open-state", name: "Open" },
|
||||
"DRAFT_MR_OPENED": { id: "draft-state", name: "Draft" },
|
||||
"MR_READY_FOR_MERGE": { id: "ready-state", name: "Ready" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
service = createMockPullRequestService();
|
||||
planeClient = createMockPlaneClient();
|
||||
behaviour = new PullRequestBehaviour(
|
||||
"test-provider",
|
||||
"test-workspace",
|
||||
service,
|
||||
planeClient as unknown as PlaneClient,
|
||||
mockConfig
|
||||
);
|
||||
|
||||
// Clear all mocks before each test
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("handleEvent", () => {
|
||||
const mockPullRequestData: MockPullRequestData = {
|
||||
owner: "test-owner",
|
||||
repositoryName: "test-repo",
|
||||
pullRequestIdentifier: "123"
|
||||
};
|
||||
|
||||
const mockPullRequest: IPullRequestDetails = {
|
||||
title: "Fix bug PL-123",
|
||||
description: "Fixes issue PL-123",
|
||||
number: 123,
|
||||
url: "https://github.com/test-owner/test-repo/pull/123",
|
||||
repository: {
|
||||
owner: "test-owner",
|
||||
name: "test-repo",
|
||||
id: "repo-id"
|
||||
},
|
||||
state: "open",
|
||||
merged: false,
|
||||
draft: false,
|
||||
mergeable: true,
|
||||
mergeable_state: "clean"
|
||||
};
|
||||
|
||||
const mockIssue = {
|
||||
id: "issue-id",
|
||||
project: "project-id",
|
||||
name: "Test Issue",
|
||||
sequence: 123,
|
||||
};
|
||||
|
||||
it("should handle a pull request with issue references successfully", async () => {
|
||||
// Setup mocks
|
||||
service.getPullRequest.mockImplementation(() => Promise.resolve(mockPullRequest));
|
||||
service.getPullRequestComments.mockImplementation(() => Promise.resolve([]));
|
||||
service.createPullRequestComment.mockImplementation(() => Promise.resolve({} as IGitComment));
|
||||
planeClient.issue.getIssueByIdentifier.mockImplementation(() => Promise.resolve(mockIssue));
|
||||
planeClient.issue.update.mockImplementation(() => Promise.resolve({} as any));
|
||||
planeClient.issue.createLink.mockImplementation(() => Promise.resolve({} as any));
|
||||
|
||||
// Execute
|
||||
await behaviour.handleEvent(mockPullRequestData);
|
||||
|
||||
// Verify
|
||||
expect(service.getPullRequest).toHaveBeenCalledWith(
|
||||
mockPullRequestData.owner,
|
||||
mockPullRequestData.repositoryName,
|
||||
mockPullRequestData.pullRequestIdentifier
|
||||
);
|
||||
expect(planeClient.issue.getIssueByIdentifier).toHaveBeenCalled();
|
||||
expect(service.createPullRequestComment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle pull request not found error gracefully", async () => {
|
||||
// Setup mock to simulate PR not found
|
||||
service.getPullRequest.mockImplementation(() => Promise.reject(new Error("Not found")));
|
||||
|
||||
// Execute
|
||||
await behaviour.handleEvent(mockPullRequestData);
|
||||
|
||||
// Verify
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
expect(planeClient.issue.getIssueByIdentifier).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should skip processing when no issue references found", async () => {
|
||||
// Setup mock PR with no issue references
|
||||
const prWithoutRefs = {
|
||||
...mockPullRequest,
|
||||
title: "Update readme",
|
||||
description: "Documentation update"
|
||||
};
|
||||
service.getPullRequest.mockImplementation(() => Promise.resolve(prWithoutRefs));
|
||||
|
||||
// Execute
|
||||
await behaviour.handleEvent(mockPullRequestData);
|
||||
|
||||
// Verify
|
||||
expect(planeClient.issue.getIssueByIdentifier).not.toHaveBeenCalled();
|
||||
expect(service.createPullRequestComment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyPullRequestEvent", () => {
|
||||
it.each([
|
||||
[{ state: "closed", merged: true }, "MR_MERGED"],
|
||||
[{ state: "closed", merged: false }, "MR_CLOSED"],
|
||||
[{ state: "open", draft: true }, "DRAFT_MR_OPENED"],
|
||||
[{ state: "open", draft: false, mergeable: true, mergeable_state: "clean" }, "MR_READY_FOR_MERGE"],
|
||||
[{ state: "open", draft: false, mergeable: false }, "MR_OPENED"],
|
||||
])("should classify PR state correctly", (prState, expectedEvent) => {
|
||||
const mockPR = {
|
||||
title: "",
|
||||
description: "",
|
||||
number: 1,
|
||||
url: "",
|
||||
repository: { owner: "", name: "", id: "" },
|
||||
...prState,
|
||||
} as IPullRequestDetails;
|
||||
|
||||
const event = behaviour["classifyPullRequestEvent"](mockPR);
|
||||
expect(event).toBe(expectedEvent);
|
||||
});
|
||||
});
|
||||
|
||||
describe("comment management", () => {
|
||||
const mockPR: IPullRequestDetails = {
|
||||
title: "Test PR",
|
||||
description: "Test description",
|
||||
number: 1,
|
||||
url: "https://test.com/pr/1",
|
||||
repository: {
|
||||
owner: "test-owner",
|
||||
name: "test-repo",
|
||||
id: "1"
|
||||
},
|
||||
state: "open",
|
||||
merged: false,
|
||||
draft: false,
|
||||
mergeable: true,
|
||||
mergeable_state: "clean"
|
||||
};
|
||||
|
||||
it("should create a new comment when none exists", async () => {
|
||||
service.getPullRequestComments.mockImplementation(() => Promise.resolve([]));
|
||||
service.createPullRequestComment.mockImplementation(() => Promise.resolve({} as IGitComment));
|
||||
|
||||
await behaviour["manageCommentOnPullRequest"](
|
||||
mockPR,
|
||||
[{ reference: { identifier: "PL", sequence: 123 }, issue: { name: "Test Issue" } as any }],
|
||||
[]
|
||||
);
|
||||
|
||||
expect(service.createPullRequestComment).toHaveBeenCalled();
|
||||
expect(service.updatePullRequestComment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should update existing comment when found", async () => {
|
||||
const existingComment: IGitComment = {
|
||||
id: "comment-1",
|
||||
body: "Pull Request Linked with Plane\nOld content",
|
||||
created_at: "2023-01-01",
|
||||
user: { id: "user-1" }
|
||||
};
|
||||
|
||||
service.getPullRequestComments.mockImplementation(() => Promise.resolve([existingComment]));
|
||||
service.updatePullRequestComment.mockImplementation(() => Promise.resolve({} as IGitComment));
|
||||
|
||||
await behaviour["manageCommentOnPullRequest"](
|
||||
mockPR,
|
||||
[{ reference: { identifier: "PL", sequence: 123 }, issue: { name: "Test Issue" } as any }],
|
||||
[]
|
||||
);
|
||||
|
||||
expect(service.updatePullRequestComment).toHaveBeenCalled();
|
||||
expect(service.createPullRequestComment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue updates", () => {
|
||||
const mockPR: IPullRequestDetails = {
|
||||
title: "Fix PL-123",
|
||||
description: "Fixes issue PL-123",
|
||||
number: 1,
|
||||
url: "https://test.com/pr/1",
|
||||
repository: {
|
||||
owner: "test-owner",
|
||||
name: "test-repo",
|
||||
id: "1"
|
||||
},
|
||||
state: "open",
|
||||
merged: false,
|
||||
draft: false,
|
||||
mergeable: true,
|
||||
mergeable_state: "clean"
|
||||
};
|
||||
|
||||
it("should handle permission errors gracefully", async () => {
|
||||
const mockError = new Error("Permission denied");
|
||||
(mockError as any).detail = CONSTANTS.NO_PERMISSION_ERROR;
|
||||
planeClient.issue.getIssueByIdentifier.mockImplementation(() => Promise.reject(mockError));
|
||||
|
||||
const result = await behaviour["updateSingleIssue"](
|
||||
{ identifier: "PL", sequence: 123 },
|
||||
{ id: "state-1", name: "Open" },
|
||||
mockPR
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\[TEST-PROVIDER\] No permission to process event/)
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle not found errors gracefully", async () => {
|
||||
const mockError = new Error("Not found");
|
||||
(mockError as any).status = 404;
|
||||
planeClient.issue.getIssueByIdentifier.mockImplementation(() => Promise.reject(mockError));
|
||||
|
||||
const result = await behaviour["updateSingleIssue"](
|
||||
{ identifier: "PL", sequence: 123 },
|
||||
{ id: "state-1", name: "Open" },
|
||||
mockPR
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
"[TEST-PROVIDER] Issue not found: PL-123"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle generic errors gracefully", async () => {
|
||||
const mockError = new Error("Generic error");
|
||||
planeClient.issue.getIssueByIdentifier.mockImplementation(() => Promise.reject(mockError));
|
||||
|
||||
const result = await behaviour["updateSingleIssue"](
|
||||
{ identifier: "PL", sequence: 123 },
|
||||
{ id: "state-1", name: "Open" },
|
||||
mockPR
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
"[TEST-PROVIDER] Error updating issue PL-123",
|
||||
mockError
|
||||
);
|
||||
});
|
||||
|
||||
it("should update issue state and create link successfully", async () => {
|
||||
const mockIssue = {
|
||||
id: "issue-1",
|
||||
project: "project-1",
|
||||
name: "Test Issue"
|
||||
};
|
||||
|
||||
planeClient.issue.getIssueByIdentifier.mockImplementation(() => Promise.resolve(mockIssue));
|
||||
planeClient.issue.update.mockImplementation(() => Promise.resolve({} as any));
|
||||
planeClient.issue.createLink.mockImplementation(() => Promise.resolve({} as any));
|
||||
|
||||
const result = await behaviour["updateSingleIssue"](
|
||||
{ identifier: "PL", sequence: 123 },
|
||||
{ id: "state-1", name: "Open" },
|
||||
mockPR
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
reference: { identifier: "PL", sequence: 123 },
|
||||
issue: mockIssue
|
||||
});
|
||||
expect(planeClient.issue.update).toHaveBeenCalled();
|
||||
expect(planeClient.issue.createLink).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
1
silo/src/lib/behaviours/git/index.ts
Normal file
1
silo/src/lib/behaviours/git/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./pull-request.behaviour";
|
||||
391
silo/src/lib/behaviours/git/pull-request.behaviour.ts
Normal file
391
silo/src/lib/behaviours/git/pull-request.behaviour.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
/*
|
||||
* Pull Request Behaviour
|
||||
* This behaviour addresses how a PR event is handled decoupled from any integration
|
||||
*/
|
||||
|
||||
import { Client, ExIssue } from "@plane/sdk";
|
||||
import { env } from "@/env";
|
||||
import { CONSTANTS, E_STATE_MAP_KEYS } from "@/helpers/constants";
|
||||
import { getReferredIssues, IssueReference, IssueWithReference } from "@/helpers/parser";
|
||||
import { logger } from "@/logger";
|
||||
import { IPullRequestConfig, IPullRequestService, IPullRequestDetails, TPullRequestError, IPullRequestEventData, IGitComment } from "@/types/behaviours/git";
|
||||
import { Either, left, right } from '@/types/either';
|
||||
|
||||
export class PullRequestBehaviour {
|
||||
constructor(
|
||||
// Identifiers
|
||||
private readonly providerName: string,
|
||||
private readonly workspaceSlug: string,
|
||||
|
||||
// Services
|
||||
private readonly service: IPullRequestService,
|
||||
private readonly planeClient: Client,
|
||||
|
||||
// Configuration
|
||||
private readonly config?: IPullRequestConfig
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Handle a pull request event
|
||||
* @param data - The event data
|
||||
*/
|
||||
public async handleEvent(data: IPullRequestEventData): Promise<void> {
|
||||
try {
|
||||
const pullRequestResult = await this.fetchPullRequest(data);
|
||||
if (!pullRequestResult.success) {
|
||||
logger.error(`Failed to fetch pull request: ${pullRequestResult.error.message}`);
|
||||
return;
|
||||
}
|
||||
const pullRequestDetails = pullRequestResult.data;
|
||||
|
||||
const references = getReferredIssues(pullRequestDetails.title, pullRequestDetails.description);
|
||||
if (references.closingReferences.length === 0 && references.nonClosingReferences.length === 0) {
|
||||
logger.info('No issue references found, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
const event = this.classifyPullRequestEvent(pullRequestDetails);
|
||||
const targetState = this.getTargetStateForEvent(event);
|
||||
|
||||
// Determine which references to process
|
||||
const isClosingEvent = [E_STATE_MAP_KEYS.MR_CLOSED, E_STATE_MAP_KEYS.MR_MERGED].includes(event as E_STATE_MAP_KEYS);
|
||||
const referredIssues = isClosingEvent
|
||||
? references.closingReferences
|
||||
: [...references.closingReferences, ...references.nonClosingReferences];
|
||||
|
||||
const updateResults = await this.updateReferencedIssues(
|
||||
referredIssues,
|
||||
targetState,
|
||||
pullRequestDetails
|
||||
);
|
||||
|
||||
const validIssues = updateResults.filter((result): result is IssueWithReference => result !== null);
|
||||
|
||||
if (validIssues.length > 0) {
|
||||
await this.manageCommentOnPullRequest(
|
||||
pullRequestDetails,
|
||||
validIssues,
|
||||
references.nonClosingReferences
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error handling pull request: ${(error as Error)?.stack}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the pull request details
|
||||
* @param data - The event data
|
||||
* @returns The pull request details
|
||||
*/
|
||||
private async fetchPullRequest(
|
||||
data: IPullRequestEventData
|
||||
): Promise<Either<TPullRequestError, IPullRequestDetails>> {
|
||||
try {
|
||||
const pullRequest = await this.service.getPullRequest(
|
||||
data.owner,
|
||||
data.repositoryName,
|
||||
data.pullRequestIdentifier
|
||||
);
|
||||
return right(pullRequest);
|
||||
} catch (error) {
|
||||
return left({
|
||||
message: `Failed to fetch pull request details for ${data.owner}/${data.repositoryName}#${data.pullRequestIdentifier}`,
|
||||
details: error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the pull request event
|
||||
* @param pullRequestDetails - The pull request details
|
||||
* @returns The event
|
||||
*/
|
||||
protected classifyPullRequestEvent(pullRequestDetails: IPullRequestDetails): string | undefined {
|
||||
if (pullRequestDetails.state === "closed") {
|
||||
return pullRequestDetails.merged ? E_STATE_MAP_KEYS.MR_MERGED : E_STATE_MAP_KEYS.MR_CLOSED;
|
||||
}
|
||||
|
||||
if (pullRequestDetails.draft) {
|
||||
return E_STATE_MAP_KEYS.DRAFT_MR_OPENED;
|
||||
}
|
||||
|
||||
if (!pullRequestDetails.draft && pullRequestDetails.mergeable && pullRequestDetails.mergeable_state === "clean") {
|
||||
return E_STATE_MAP_KEYS.MR_READY_FOR_MERGE;
|
||||
}
|
||||
|
||||
if (pullRequestDetails.state === "open") {
|
||||
return E_STATE_MAP_KEYS.MR_OPENED;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target state for an event
|
||||
* @param event - The event
|
||||
* @returns The target state
|
||||
*/
|
||||
private getTargetStateForEvent(event: string | undefined): { id: string; name: string } | null {
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetState = this.config?.states?.mergeRequestEventMapping?.[event];
|
||||
if (!targetState) {
|
||||
logger.error(`Target state not found for event ${event}`);
|
||||
return null;
|
||||
}
|
||||
return targetState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the referenced issues
|
||||
* @param references - The references
|
||||
* @param targetState - The target state
|
||||
* @param prDetails - The pull request details
|
||||
* @returns The updated issues
|
||||
*/
|
||||
private async updateReferencedIssues(
|
||||
references: IssueReference[],
|
||||
targetState: { id: string; name: string } | null,
|
||||
prDetails: IPullRequestDetails
|
||||
): Promise<(IssueWithReference | null)[]> {
|
||||
return Promise.all(
|
||||
references.map(reference => this.updateSingleIssue(reference, targetState, prDetails))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single issue
|
||||
* @param reference - The reference
|
||||
* @param targetState - The target state
|
||||
* @param prDetails - The pull request details
|
||||
* @returns The updated issue
|
||||
*/
|
||||
private async updateSingleIssue(
|
||||
reference: IssueReference,
|
||||
targetState: { id: string; name: string } | null,
|
||||
prDetails: IPullRequestDetails
|
||||
): Promise<IssueWithReference | null> {
|
||||
let issue: ExIssue | null = null;
|
||||
|
||||
try {
|
||||
issue = await this.planeClient.issue.getIssueByIdentifier(
|
||||
this.workspaceSlug,
|
||||
reference.identifier,
|
||||
reference.sequence
|
||||
);
|
||||
|
||||
if (targetState) {
|
||||
await this.planeClient.issue.update(
|
||||
this.workspaceSlug,
|
||||
issue.project,
|
||||
issue.id,
|
||||
{ state: targetState.id }
|
||||
);
|
||||
logger.info(`[${this.providerName.toUpperCase()}] Issue ${reference.identifier}-${reference.sequence} updated to state ${targetState.name}`);
|
||||
}
|
||||
|
||||
// Create link to pull request
|
||||
const linkTitle = `[${prDetails.number}] ${prDetails.title}`;
|
||||
await this.planeClient.issue.createLink(
|
||||
this.workspaceSlug,
|
||||
issue.project,
|
||||
issue.id,
|
||||
linkTitle,
|
||||
prDetails.url
|
||||
);
|
||||
|
||||
return { reference, issue };
|
||||
} catch (error: any) {
|
||||
// Handle permission errors
|
||||
if (error?.detail && error?.detail.includes(CONSTANTS.NO_PERMISSION_ERROR)) {
|
||||
logger.info(
|
||||
`[${this.providerName.toUpperCase()}] No permission to process event: ${error.detail} ${reference.identifier}-${reference.sequence}`
|
||||
);
|
||||
|
||||
// If we managed to get the issue before the error, still return it
|
||||
if (issue) {
|
||||
return { reference, issue };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle 404 errors (issue not found)
|
||||
if (error?.status === 404 || (error?.detail && error?.detail.includes("not found"))) {
|
||||
logger.info(
|
||||
`[${this.providerName.toUpperCase()}] Issue not found: ${reference.identifier}-${reference.sequence}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Generic error handling
|
||||
logger.error(`[${this.providerName.toUpperCase()}] Error updating issue ${reference.identifier}-${reference.sequence}`, error);
|
||||
|
||||
// If we managed to get the issue before the error, still return it
|
||||
if (issue) {
|
||||
return { reference, issue };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manage a comment on the pull request
|
||||
* @param prDetails - The pull request details
|
||||
* @param validIssues - The valid issues
|
||||
* @param nonClosingReferences - The non-closing references
|
||||
*/
|
||||
private async manageCommentOnPullRequest(
|
||||
prDetails: IPullRequestDetails,
|
||||
validIssues: IssueWithReference[],
|
||||
nonClosingReferences: IssueReference[]
|
||||
): Promise<void> {
|
||||
const body = this.generateCommentBody(validIssues, nonClosingReferences);
|
||||
const comments = await this.fetchExistingComments(prDetails);
|
||||
const existingComment = this.findExistingPlaneComment(comments);
|
||||
|
||||
if (existingComment) {
|
||||
await this.updateExistingComment(prDetails, existingComment.id, body);
|
||||
} else {
|
||||
await this.createNewComment(prDetails, body);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch existing comments on the pull request
|
||||
* @param prDetails - The pull request details
|
||||
* @returns The existing comments
|
||||
*/
|
||||
private async fetchExistingComments(prDetails: IPullRequestDetails): Promise<IGitComment[]> {
|
||||
return this.service.getPullRequestComments(
|
||||
prDetails.repository.owner,
|
||||
prDetails.repository.name,
|
||||
prDetails.number.toString()
|
||||
) as Promise<IGitComment[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing comment on the pull request
|
||||
* @param comments - The comments
|
||||
* @param prefix - The prefix
|
||||
* @returns The existing comment
|
||||
*/
|
||||
protected findExistingComment(comments: IGitComment[], prefix: string): IGitComment | undefined {
|
||||
return comments.find((comment) => comment.body.startsWith(prefix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an existing Plane comment on the pull request
|
||||
* @param comments - The comments
|
||||
* @returns The existing comment
|
||||
*/
|
||||
private findExistingPlaneComment(comments: IGitComment[]): IGitComment | undefined {
|
||||
const commentPrefix = `Pull Request Linked with Plane`;
|
||||
return this.findExistingComment(comments, commentPrefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing comment on the pull request
|
||||
* @param prDetails - The pull request details
|
||||
* @param commentId - The comment ID
|
||||
* @param body - The body
|
||||
*/
|
||||
private async updateExistingComment(
|
||||
prDetails: IPullRequestDetails,
|
||||
commentId: string | number,
|
||||
body: string
|
||||
): Promise<void> {
|
||||
await this.service.updatePullRequestComment(
|
||||
prDetails.repository.owner,
|
||||
prDetails.repository.name,
|
||||
commentId.toString(),
|
||||
body
|
||||
);
|
||||
logger.info(`Updated comment for pull request ${prDetails.number} in repo ${prDetails.repository.owner}/${prDetails.repository.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new comment on the pull request
|
||||
* @param prDetails - The pull request details
|
||||
* @param body - The body
|
||||
*/
|
||||
private async createNewComment(prDetails: IPullRequestDetails, body: string): Promise<void> {
|
||||
await this.service.createPullRequestComment(
|
||||
prDetails.repository.owner,
|
||||
prDetails.repository.name,
|
||||
prDetails.number.toString(),
|
||||
body
|
||||
);
|
||||
logger.info(`Created new comment for pull request ${prDetails.number} in repo ${prDetails.repository.owner}/${prDetails.repository.name}`);
|
||||
}
|
||||
|
||||
private generateCommentBody(
|
||||
issues: IssueWithReference[],
|
||||
nonClosingReferences: IssueReference[]
|
||||
): string {
|
||||
const commentPrefix = `Pull Request Linked with Plane Work Items`;
|
||||
let body = `${commentPrefix}\n\n`;
|
||||
|
||||
const { closingIssues, nonClosingIssues } = this.categorizeIssues(issues, nonClosingReferences);
|
||||
|
||||
body += this.formatIssueSection(closingIssues);
|
||||
|
||||
if (nonClosingIssues.length > 0) {
|
||||
body += `\n\nReferences\n\n${this.formatIssueSection(nonClosingIssues)}`;
|
||||
}
|
||||
|
||||
body += `\n\nComment Automatically Generated by [Plane](https://plane.so)\n`;
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorize issues
|
||||
* @param issues - The issues
|
||||
* @param nonClosingReferences - The non-closing references
|
||||
* @returns The categorized issues
|
||||
*/
|
||||
private categorizeIssues(
|
||||
issues: IssueWithReference[],
|
||||
nonClosingReferences: IssueReference[]
|
||||
): { closingIssues: IssueWithReference[], nonClosingIssues: IssueWithReference[] } {
|
||||
const closingIssues = issues.filter(
|
||||
({ reference }) => !this.isNonClosingReference(reference, nonClosingReferences)
|
||||
);
|
||||
|
||||
const nonClosingIssues = issues.filter(
|
||||
({ reference }) => this.isNonClosingReference(reference, nonClosingReferences)
|
||||
);
|
||||
|
||||
return { closingIssues, nonClosingIssues };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an issue is a non-closing reference
|
||||
* @param reference - The reference
|
||||
* @param nonClosingReferences - The non-closing references
|
||||
* @returns Whether the issue is a non-closing reference
|
||||
*/
|
||||
private isNonClosingReference(
|
||||
reference: IssueReference,
|
||||
nonClosingReferences: IssueReference[]
|
||||
): boolean {
|
||||
return nonClosingReferences.some(
|
||||
(ref) => ref.identifier === reference.identifier && ref.sequence === reference.sequence
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an issue section
|
||||
* @param issues - The issues
|
||||
* @returns The formatted issue section
|
||||
*/
|
||||
private formatIssueSection(issues: IssueWithReference[]): string {
|
||||
return issues.map(({ reference, issue }) =>
|
||||
`- [${reference.identifier}-${reference.sequence}] [${issue.name}](${env.APP_BASE_URL}/${this.workspaceSlug}/projects/${issue.project}/issues/${issue.id})\n`
|
||||
).join('');
|
||||
}
|
||||
}
|
||||
1
silo/src/lib/behaviours/index.ts
Normal file
1
silo/src/lib/behaviours/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./git";
|
||||
40
silo/src/types/behaviours/git/base.ts
Normal file
40
silo/src/types/behaviours/git/base.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// ----------------------------------------
|
||||
// Core Entities
|
||||
// ----------------------------------------
|
||||
|
||||
/**
|
||||
* Represents a comment on a Git platform
|
||||
*/
|
||||
export interface IGitComment {
|
||||
id: string | number;
|
||||
body: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
user: {
|
||||
id: string | number;
|
||||
login?: string;
|
||||
username?: string;
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Common properties of a pull request
|
||||
*/
|
||||
export interface IPullRequestDetails {
|
||||
title: string;
|
||||
description: string;
|
||||
number: number;
|
||||
url: string;
|
||||
repository: {
|
||||
owner: string;
|
||||
name: string;
|
||||
id: string | number
|
||||
};
|
||||
state: "open" | "closed";
|
||||
merged: boolean;
|
||||
draft: boolean;
|
||||
mergeable: boolean | null;
|
||||
mergeable_state: string | null;
|
||||
}
|
||||
2
silo/src/types/behaviours/git/index.ts
Normal file
2
silo/src/types/behaviours/git/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./base";
|
||||
export * from "./pull-request";
|
||||
78
silo/src/types/behaviours/git/pull-request.ts
Normal file
78
silo/src/types/behaviours/git/pull-request.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// ----------------------------------------
|
||||
// Configuration Types
|
||||
// ----------------------------------------
|
||||
|
||||
import { IGitComment, IPullRequestDetails } from "./base";
|
||||
|
||||
|
||||
// ----------------------------------------
|
||||
// Event Data Types
|
||||
// ----------------------------------------
|
||||
|
||||
/**
|
||||
* Standardized event data structure
|
||||
*/
|
||||
export interface IPullRequestEventData {
|
||||
repositoryName: string;
|
||||
pullRequestIdentifier: string;
|
||||
owner: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for PR state mapping
|
||||
*/
|
||||
export interface IPullRequestConfig {
|
||||
states?: {
|
||||
mergeRequestEventMapping?: Record<string, { id: string; name: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ----------------------------------------
|
||||
// Error Types
|
||||
// ----------------------------------------
|
||||
|
||||
/**
|
||||
* Error type for pull request operations
|
||||
*/
|
||||
export type TPullRequestError = {
|
||||
message: string;
|
||||
details?: any;
|
||||
};
|
||||
|
||||
// ----------------------------------------
|
||||
// Service Interfaces
|
||||
// ----------------------------------------
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Service interface for pull request operations
|
||||
*/
|
||||
export interface IPullRequestService {
|
||||
getPullRequest(
|
||||
owner: string,
|
||||
repositoryName: string,
|
||||
pullRequestIdentifier: string
|
||||
): Promise<IPullRequestDetails>;
|
||||
|
||||
getPullRequestComments(
|
||||
owner: string,
|
||||
repo: string,
|
||||
pullRequestIdentifier: string
|
||||
): Promise<IGitComment[]>;
|
||||
|
||||
createPullRequestComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
pullRequestIdentifier: string,
|
||||
body: string
|
||||
): Promise<IGitComment>;
|
||||
|
||||
updatePullRequestComment(
|
||||
owner: string,
|
||||
repo: string,
|
||||
commentId: string,
|
||||
body: string
|
||||
): Promise<IGitComment>;
|
||||
}
|
||||
20
silo/src/types/either.ts
Normal file
20
silo/src/types/either.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* A type representing either a success (right) or failure (left) value
|
||||
*/
|
||||
export type Either<L, R> =
|
||||
| { success: true, data: R }
|
||||
| { success: false, error: L };
|
||||
|
||||
/**
|
||||
* Creates a Right (success) Either value
|
||||
*/
|
||||
export function right<L, R>(value: R): Either<L, R> {
|
||||
return { success: true, data: value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Left (error) Either value
|
||||
*/
|
||||
export function left<L, R>(error: L): Either<L, R> {
|
||||
return { success: false, error };
|
||||
}
|
||||
Reference in New Issue
Block a user