[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:
Henit Chobisa
2025-04-11 18:58:52 +05:30
committed by GitHub
parent cfec029177
commit 0c6d4610bf
20 changed files with 1190 additions and 392 deletions

View File

@@ -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,

View File

@@ -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"];

View File

@@ -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 {

View File

@@ -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'
}
};

View 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 || "",
},
};
}
}

View File

@@ -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(),
});
};

View File

@@ -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}`);
}
}

View File

@@ -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 {

View 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
}
};
}
}

View File

@@ -6,6 +6,6 @@ export type GitlabEntityConnection = TWorkspaceEntityConnection<typeof gitlabEnt
export type GitlabConnectionDetails = {
workspaceConnection: GitlabWorkspaceConnection;
entityConnection: GitlabEntityConnection;
projectConnections: GitlabEntityConnection[];
entityConnection?: GitlabEntityConnection;
projectConnections?: GitlabEntityConnection[];
};

View File

@@ -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;

View File

@@ -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",
}

View File

@@ -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();
});
});
});

View File

@@ -0,0 +1 @@
export * from "./pull-request.behaviour";

View 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('');
}
}

View File

@@ -0,0 +1 @@
export * from "./git";

View 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;
}

View File

@@ -0,0 +1,2 @@
export * from "./base";
export * from "./pull-request";

View 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
View 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 };
}