diff --git a/apiserver/plane/api/urls/issue.py b/apiserver/plane/api/urls/issue.py index 85bd6eaed9..197f555c76 100644 --- a/apiserver/plane/api/urls/issue.py +++ b/apiserver/plane/api/urls/issue.py @@ -9,9 +9,15 @@ from plane.api.views import ( WorkspaceIssueAPIEndpoint, IssueAttachmentEndpoint, IssueAttachmentServerEndpoint, + IssueSearchEndpoint, ) urlpatterns = [ + path( + "workspaces//issues/search/", + IssueSearchEndpoint.as_view(), + name="issue-search", + ), path( "workspaces//issues/-/", WorkspaceIssueAPIEndpoint.as_view(), diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 96518deb78..93d016c1f5 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -11,6 +11,7 @@ from .issue import ( IssueActivityAPIEndpoint, IssueAttachmentEndpoint, IssueAttachmentServerEndpoint, + IssueSearchEndpoint, ) from .cycle import ( diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 5bb10e6749..df81922858 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -1,5 +1,6 @@ # Python imports import json +import re import uuid # Django imports @@ -65,7 +66,6 @@ from plane.payment.flags.flag import FeatureFlag from plane.ee.utils.workflow import WorkflowStateManager - class WorkspaceIssueAPIEndpoint(BaseAPIView): """ This viewset provides `retrieveByIssueId` on workspace level @@ -289,8 +289,7 @@ class IssueAPIEndpoint(BaseAPIView): project_id=project_id, slug=slug ) if workflow_state_manager.validate_issue_creation( - state_id=request.data.get("state_id"), - user_id=request.user.id, + state_id=request.data.get("state_id"), user_id=request.user.id ): return Response( {"error": "You cannot create a work item in this state"}, @@ -1410,3 +1409,54 @@ class IssueAttachmentServerEndpoint(BaseAPIView): get_asset_object_metadata.delay(str(issue_attachment.id)) issue_attachment.save() return Response(status=status.HTTP_204_NO_CONTENT) + + +class IssueSearchEndpoint(BaseAPIView): + """Endpoint to search across multiple fields in the issues""" + + def get(self, request, slug): + query = request.query_params.get("search", False) + limit = request.query_params.get("limit", 10) + workspace_search = request.query_params.get("workspace_search", "false") + project_id = request.query_params.get("project_id", False) + + if not query: + return Response({"issues": []}, status=status.HTTP_200_OK) + + # Build search query + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + for field in fields: + if field == "sequence_id": + # Match whole integers only (exclude decimal numbers) + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) + + # Filter issues + issues = Issue.issue_objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + project__archived_at__isnull=True, + workspace__slug=slug, + ) + + # Apply project filter if not searching across workspace + if workspace_search == "false" and project_id: + issues = issues.filter(project_id=project_id) + + # Get results + issue_results = issues.distinct().values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "workspace__slug", + "type_id", + )[: int(limit)] + + return Response({"issues": issue_results}, status=status.HTTP_200_OK) diff --git a/packages/etl/src/slack/types/base.ts b/packages/etl/src/slack/types/base.ts index e1e52a9fc6..24c2fef4f8 100644 --- a/packages/etl/src/slack/types/base.ts +++ b/packages/etl/src/slack/types/base.ts @@ -156,6 +156,7 @@ export type TViewSubmissionPayload = { team: ISlackTeam; user: ISlackUser; api_app_id: string; + callback_id: string; token: string; trigger_id: string; view: ISlackView; diff --git a/packages/sdk/src/services/issue.service.ts b/packages/sdk/src/services/issue.service.ts index 5fbfe08a93..c884bccaf7 100644 --- a/packages/sdk/src/services/issue.service.ts +++ b/packages/sdk/src/services/issue.service.ts @@ -6,6 +6,9 @@ import { ExcludedProps, ExIssue, ExIssueAttachment, + IssueSearchResponse, + ExpandableFields, + IssueWithExpanded, Optional, Paginated, } from "@/types/types"; @@ -27,6 +30,37 @@ export class IssueService extends APIService { }); } + async getIssueByIdentifierWithFields( + workspaceSlug: string, + projectIdentifier: string, + issueSequence: number, + expand: (keyof ExpandableFields)[] + ): Promise> { + return this.get(`/api/v1/workspaces/${workspaceSlug}/issues/${projectIdentifier}-${issueSequence.toString()}/?expand=${expand.join(",")}`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async searchIssues( + workspaceSlug: string, + query: string, + projectId?: string + ): Promise<{ issues: IssueSearchResponse[] }> { + return this.get(`/api/v1/workspaces/${workspaceSlug}/issues/search/`, { + params: { + search: query, + project_id: projectId, + workspace_search: !projectId, + }, + }) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async list(slug: string, projectId: string): Promise> { return this.get(`/api/v1/workspaces/${slug}/projects/${projectId}/issues/`) .then((response) => response.data) @@ -202,6 +236,14 @@ export class IssueService extends APIService { }); } + async getIssueWithFields(workspaceSlug: string, projectId: string, issueId: string, expand: (keyof ExpandableFields)[]): Promise> { + return this.get(`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/?expand=${expand.join(",")}`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async getIssueAttachments(workspaceSlug: string, projectId: string, issueId: string): Promise { return this.get( `/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-attachments/server/` diff --git a/packages/sdk/src/types/types.ts b/packages/sdk/src/types/types.ts index 587c4d465f..74434a882d 100644 --- a/packages/sdk/src/types/types.ts +++ b/packages/sdk/src/types/types.ts @@ -1,10 +1,3 @@ -import { - // issue types - ExIssueType, - ExIssueProperty, - ExIssuePropertyOption, - ExIssuePropertyValue, -} from "./issue-types"; // service types export type ClientOptions = { @@ -91,6 +84,17 @@ type IIsssue = { type_id: string | undefined; }; +export type ExpandableFields = { + state: ExState; + project: ExProject; + assignees: ExUser[]; + labels: ExIssueLabel[]; +} +// Create a type that can handle both expanded and unexpanded fields +export type IssueWithExpanded> = Omit & + Pick; + + export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled"; export interface IState { @@ -341,6 +345,16 @@ export interface AttachmentResponse { asset_url: string; } +export interface IssueSearchResponse { + name: string; + id: string; + sequence_id: number; + project__identifier: string; + project_id: string; + workspace__slug: string; + type_id: string | null; +} + export interface ExAsset { id: string; attributes: { diff --git a/packages/sdk/src/types/webhook-types.ts b/packages/sdk/src/types/webhook-types.ts index e69f85dd53..66a631bfe6 100644 --- a/packages/sdk/src/types/webhook-types.ts +++ b/packages/sdk/src/types/webhook-types.ts @@ -3,7 +3,8 @@ import { ExIssue, ExIssueComment, PlaneUser } from "./types"; // Common types type UUID = string; -interface Activity { +export interface PlaneActivity { + timestamp: string; field: string; new_value: string | null; old_value: string | null; @@ -18,7 +19,7 @@ export interface WebhookPayloadBase { action: string; webhook_id: UUID; workspace_id: UUID; - activity: Activity; + activity: PlaneActivity; } export interface WebhookIssuePayload extends WebhookPayloadBase { @@ -71,7 +72,7 @@ export interface PlaneWebhookPayloadBase { action: string; webhook_id: UUID; workspace_id: UUID; - activity: Activity; + activity: PlaneActivity; } // Union type for all possible webhook payloads diff --git a/silo/src/apps/slack/controllers/index.ts b/silo/src/apps/slack/controllers/index.ts index d708de2614..7c3e068b35 100644 --- a/silo/src/apps/slack/controllers/index.ts +++ b/silo/src/apps/slack/controllers/index.ts @@ -7,7 +7,7 @@ import { TSlackCommandPayload, TSlackPayload } from "@plane/etl/slack"; -import { PlaneWebhookData } from "@plane/sdk"; +import { ExIssue, PlaneWebhookData, PlaneWebhookPayloadBase } from "@plane/sdk"; import { env } from "@/env"; import { responseHandler } from "@/helpers/response-handler"; import { Controller, EnsureEnabled, Get, Post, useValidateUserAuthentication } from "@/lib"; @@ -19,6 +19,7 @@ import { getConnectionDetails } from "../helpers/connection-details"; import { ACTIONS } from "../helpers/constants"; import { parseIssueFormData } from "../helpers/parse-issue-form"; import { convertToSlackOptions } from "../helpers/slack-options"; +import { Store } from "@/worker/base"; const apiClient = getAPIClient(); @EnsureEnabled(E_INTEGRATION_KEYS.SLACK) @@ -380,6 +381,31 @@ export default class SlackController { }); } + if (payload.type === "block_suggestion" && payload.action_id && payload.action_id === ACTIONS.LINK_WORK_ITEM) { + const text = payload.value; + // If the action is link_work_item, parse the view to be of type + // LinkIssueModalView and pass it to the slack worker + const details = await getConnectionDetails(payload.team.id); + if (!details) { logger.info(`[SLACK] No connection details found for team ${payload.team.id}`); return } + + const { workspaceConnection, planeClient } = details; + const workItems = await planeClient.issue.searchIssues(workspaceConnection.workspace_slug, text); + + const filteredWorkItems = workItems.issues + .map((workItem) => { + return { + id: `${workItem.workspace__slug}:${workItem.project_id}:${workItem.id}`, + name: `[${workItem.project__identifier}-${workItem.sequence_id}] ${workItem.name}`, + } + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + const workItemOptions = convertToSlackOptions(filteredWorkItems); + return res.status(200).json({ + options: workItemOptions, + }); + } + return res.status(200).json({}); } catch (error) { return responseHandler(res, 500, error); @@ -402,9 +428,70 @@ export default class SlackController { ); } + if (payload.event === "issue" && !payload.activity.field.includes("_id")) { + const payload = req.body as PlaneWebhookPayloadBase; + + const id = payload.data.id; + const workspace = payload.data.workspace; + const project = payload.data.project; + const issue = payload.data.issue; + + const [entityConnection] = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({ + workspace_id: workspace, + project_id: project, + issue_id: id, + entity_type: E_INTEGRATION_KEYS.SLACK, + }); + + if (!entityConnection) { + return res.sendStatus(200); + } + + logger.info(`[SLACK] Entity connection found for issue ${id} in workspace ${workspace} and project ${project}`); + // Register activity key for the particular issue + await this.collectActivityForStacking(payload); + + // Register store task for stacking the issue + await integrationTaskManager.registerStoreTask( + { + route: "plane-slack-webhook", + jobId: payload.event, + type: "issue", + }, + { + id, + event: payload.event, + workspace, + project, + issue, + }, + Number(env.DEDUP_INTERVAL) + ); + } return res.sendStatus(200); } catch (error) { return responseHandler(res, 500, error); } } + + /** + * Collects activity for stacking the issue + * @param payload - The payload of the issue + */ + async collectActivityForStacking(payload: PlaneWebhookPayloadBase) { + const store = Store.getInstance() + + // Create a key for the activity field + const key = `slack:issue:${payload.data.id}`; + const ttl = 60 // 1 minute + + const activity = { + ...payload.activity, + timestamp: payload.data.updated_at ?? payload.data.created_at ?? new Date().toISOString(), + } + + // Set the activity field, as we gave false, it's gonna update the field + await store.setList(key, JSON.stringify(activity), ttl, false); + } + } diff --git a/silo/src/apps/slack/helpers/connection-details.ts b/silo/src/apps/slack/helpers/connection-details.ts index 11cfd2029d..00040f62d1 100644 --- a/silo/src/apps/slack/helpers/connection-details.ts +++ b/silo/src/apps/slack/helpers/connection-details.ts @@ -1,15 +1,23 @@ import { E_INTEGRATION_KEYS } from "@plane/etl/core"; -import { createSlackService } from "@plane/etl/slack"; -import { Client as PlaneClient } from "@plane/sdk"; +import { createSlackService, SlackService } from "@plane/etl/slack"; +import { Client as PlaneClient, PlaneWebhookPayload } from "@plane/sdk"; import { env } from "@/env"; import { logger } from "@/logger"; import { getAPIClient } from "@/services/client"; import { slackAuth } from "../auth/auth"; import { getRefreshCredentialHandler } from "./update-credentials"; +import { TWorkspaceConnection, TWorkspaceCredential } from "@plane/types"; +export type SlackConnectionDetails = { + workspaceConnection: TWorkspaceConnection; + credentials: TWorkspaceCredential; + slackService: SlackService; + planeClient: PlaneClient; +} -export const getConnectionDetails = async (teamId: string) => { - const apiClient = getAPIClient(); +const apiClient = getAPIClient(); + +export const getConnectionDetails = async (teamId: string): Promise => { const [workspaceConnection] = await apiClient.workspaceConnection.listWorkspaceConnections({ connection_id: teamId, connection_type: E_INTEGRATION_KEYS.SLACK, @@ -64,3 +72,51 @@ export const getConnectionDetails = async (teamId: string) => { planeClient, }; }; + + +export const getConnectionDetailsForIssue = async (payload: PlaneWebhookPayload) => { + const [entityConnection] = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({ + workspace_id: payload.workspace, + project_id: payload.project, + issue_id: payload.id, + }); + + if (!entityConnection) { + return; + } + + const workspaceConnection = await apiClient.workspaceConnection.getWorkspaceConnection( + entityConnection.workspace_connection_id + ); + if (!workspaceConnection) { + return; + } + + const credentials = await apiClient.workspaceCredential.getWorkspaceCredential(workspaceConnection.credential_id); + + if (!credentials) { + return; + } + + if (!credentials.source_access_token || !credentials.source_refresh_token || !credentials.target_access_token) { + return; + } + + const refreshHandler = getRefreshCredentialHandler( + workspaceConnection.workspace_id, + credentials.user_id, + credentials.target_access_token + ); + + const slackService = createSlackService( + credentials.source_access_token, + credentials.source_refresh_token, + slackAuth, + refreshHandler + ); + + return { + entityConnection, + slackService, + }; +}; diff --git a/silo/src/apps/slack/helpers/constants.ts b/silo/src/apps/slack/helpers/constants.ts index a61c970d94..521429da78 100644 --- a/silo/src/apps/slack/helpers/constants.ts +++ b/silo/src/apps/slack/helpers/constants.ts @@ -13,8 +13,11 @@ export const ACTIONS = { LINKBACK_COMMENT_SUBMIT: "comment_submit", LINKBACK_ADD_WEB_LINK: "add_web_link", LINKBACK_OVERFLOW_ACTIONS: "linkback_overflow_actions", + ENABLE_THREAD_SYNC: "enable_thread_sync", ASSIGN_TO_ME: "assign_to_me", + + LINK_WORK_ITEM: "link_work_item", }; export type EntityType = keyof typeof ENTITIES; @@ -26,6 +29,8 @@ export const ENTITIES = { ISSUE_SUBMISSION: "issue_submission", ISSUE_COMMENT_SUBMISSION: "issue_comment_submission", ISSUE_WEBLINK_SUBMISSION: "issue_weblink_submission", + LINK_WORK_ITEM: "link_work_item", + DISCONNECT_WORK_ITEM: "disconnect_work_item", } as const; export const PLANE_PRIORITIES = [ diff --git a/silo/src/apps/slack/helpers/parse-issue-form.ts b/silo/src/apps/slack/helpers/parse-issue-form.ts index 10a6c26813..e88669101c 100644 --- a/silo/src/apps/slack/helpers/parse-issue-form.ts +++ b/silo/src/apps/slack/helpers/parse-issue-form.ts @@ -1,4 +1,4 @@ -import { ParsedIssueData } from "../types/types"; +import { ParsedIssueData, ParsedLinkWorkItemData } from "../types/types"; export const parseIssueFormData = (values: any): ParsedIssueData => { const parsed: ParsedIssueData = { @@ -48,3 +48,28 @@ export const parseIssueFormData = (values: any): ParsedIssueData => { return parsed; }; + +export const parseLinkWorkItemFormData = (values: any): ParsedLinkWorkItemData | undefined => { + let parsed: ParsedLinkWorkItemData | undefined = undefined + + Object.entries(values).forEach(([_, blockData]: [string, any]) => { + if (blockData.link_work_item?.type === "external_select") { + const identifer = blockData?.link_work_item?.selected_option?.value; + + if (!identifer) { + return; + } + + const parts = identifer.split(":"); + if (parts.length === 3) { + parsed = { + workspaceSlug: parts[0], + projectId: parts[1], + issueId: parts[2], + }; + } + } + }); + + return parsed; +}; diff --git a/silo/src/apps/slack/types/types.ts b/silo/src/apps/slack/types/types.ts index ef8fd7e94e..54d74ecb56 100644 --- a/silo/src/apps/slack/types/types.ts +++ b/silo/src/apps/slack/types/types.ts @@ -1,12 +1,8 @@ import { - ISlackChannel, - ISlackMessage, - ISlackUser, - TMessageActionPayload, - TSlackCommandPayload, - TSlackPayload, + ISlackChannel, ISlackUser, TSlackPayload } from "@plane/etl/slack"; import { ENTITIES } from "../helpers/constants"; +import { PlaneActivity } from "@plane/sdk"; export interface ParsedIssueData { project: string; @@ -18,6 +14,12 @@ export interface ParsedIssueData { enableThreadSync?: boolean; } +export interface ParsedLinkWorkItemData { + projectId: string; + issueId: string; + workspaceSlug: string; +} + export interface MetadataPayloadShort { type: string; channel: ISlackChannel; @@ -43,21 +45,59 @@ export type ShortcutActionPayload = { }; response_url?: string; }; +// Define the payload mapping first +type EntityPayloadMapping = { + [ENTITIES.ISSUE_SUBMISSION]: TSlackPayload; + [ENTITIES.ISSUE_WEBLINK_SUBMISSION]: MetadataPayloadShort; + [ENTITIES.ISSUE_COMMENT_SUBMISSION]: MetadataPayloadShort; + [ENTITIES.SHORTCUT_PROJECT_SELECTION]: ShortcutActionPayload; + [ENTITIES.COMMAND_PROJECT_SELECTION]: Omit; + [ENTITIES.LINK_WORK_ITEM]: ShortcutActionPayload; + [ENTITIES.DISCONNECT_WORK_ITEM]: ShortcutActionPayload; + // Add more mappings as needed +}; -export type SlackPrivateMetadata = - | { - entityType: typeof ENTITIES.SHORTCUT_PROJECT_SELECTION; - entityPayload: ShortcutActionPayload; - } - | { - entityType: typeof ENTITIES.COMMAND_PROJECT_SELECTION; - entityPayload: TSlackCommandPayload; - } - | { - entityType: typeof ENTITIES.ISSUE_SUBMISSION; - entityPayload: TSlackPayload; - } - | { - entityType: typeof ENTITIES.ISSUE_WEBLINK_SUBMISSION | typeof ENTITIES.ISSUE_COMMENT_SUBMISSION; - entityPayload: MetadataPayloadShort; - }; +// Create the indexed type +export type SlackPrivateMetadata = { + entityType: T; + entityPayload: EntityPayloadMapping[T]; +}; + +// export type SlackPrivateMetadata = +// | { +// entityType: typeof ENTITIES.ISSUE_SUBMISSION; +// entityPayload: TSlackPayload; +// } +// | { +// entityType: typeof ENTITIES.ISSUE_WEBLINK_SUBMISSION | typeof ENTITIES.ISSUE_COMMENT_SUBMISSION; +// entityPayload: MetadataPayloadShort; +// } +// | { +// entityType: typeof ENTITIES.SHORTCUT_PROJECT_SELECTION | typeof ENTITIES.LINK_WORK_ITEM | typeof ENTITIES.DISCONNECT_WORK_ITEM; +// entityPayload: ShortcutActionPayload; +// } + +export enum E_MESSAGE_ACTION_TYPES { + LINK_WORK_ITEM = "link_work_item", + CREATE_NEW_WORK_ITEM = "create_new_issue", + DISCONNECT_WORK_ITEM = "disconnect_work_item", + ISSUE_WEBLINK_SUBMISSION = "issue_weblink_submission", + ISSUE_COMMENT_SUBMISSION = "issue_comment_submission", +} + + +export type PlaneActivityWithTimestamp = { + timestamp: string; +} & PlaneActivity; + +export type ActivityForSlack = { + field: string; + actor: string; +} & ({ + isArrayField: true; + removed: string[]; + added: string[]; +} | { + isArrayField: false; + newValue: string; +}) diff --git a/silo/src/apps/slack/views/create-comment-modal.ts b/silo/src/apps/slack/views/create-comment-modal.ts index 864a8a49b9..d91b12dfc0 100644 --- a/silo/src/apps/slack/views/create-comment-modal.ts +++ b/silo/src/apps/slack/views/create-comment-modal.ts @@ -1,10 +1,11 @@ import { ACTIONS, ENTITIES } from "../helpers/constants"; -import { MetadataPayloadShort } from "../types/types"; +import { E_MESSAGE_ACTION_TYPES, MetadataPayloadShort } from "../types/types"; export const createCommentModal = (payload: MetadataPayloadShort) => { return { type: "modal", private_metadata: JSON.stringify({ entityType: ENTITIES.ISSUE_COMMENT_SUBMISSION, entityPayload: payload }), + callback_id: E_MESSAGE_ACTION_TYPES.ISSUE_COMMENT_SUBMISSION, title: { type: "plain_text", text: "Create Comment", diff --git a/silo/src/apps/slack/views/create-weblink-modal.ts b/silo/src/apps/slack/views/create-weblink-modal.ts index f294c84f1c..5a259fbd3c 100644 --- a/silo/src/apps/slack/views/create-weblink-modal.ts +++ b/silo/src/apps/slack/views/create-weblink-modal.ts @@ -1,9 +1,10 @@ import { ENTITIES } from "../helpers/constants"; -import { MetadataPayloadShort } from "../types/types"; +import { E_MESSAGE_ACTION_TYPES, MetadataPayloadShort } from "../types/types"; export const createWebLinkModal = (payload: MetadataPayloadShort) => { return { type: "modal", + callback_id: E_MESSAGE_ACTION_TYPES.ISSUE_WEBLINK_SUBMISSION, private_metadata: JSON.stringify({ entityType: ENTITIES.ISSUE_WEBLINK_SUBMISSION, entityPayload: payload }), title: { type: "plain_text", diff --git a/silo/src/apps/slack/views/custom-blocks.ts b/silo/src/apps/slack/views/custom-blocks.ts index 7d233cde25..56257708f9 100644 --- a/silo/src/apps/slack/views/custom-blocks.ts +++ b/silo/src/apps/slack/views/custom-blocks.ts @@ -1,4 +1,4 @@ -import { InputBlock, StaticSelect, PlainTextInput, MultiExternalSelect, Checkboxes } from "@slack/types"; +import { InputBlock, StaticSelect, PlainTextInput, MultiExternalSelect, Checkboxes, ExternalSelect } from "@slack/types"; export interface StaticSelectInputBlock extends InputBlock { element: StaticSelect; @@ -15,3 +15,7 @@ export interface MultiExternalSelectInputBlock extends InputBlock { export interface CheckboxInputBlock extends InputBlock { element: Checkboxes; } + +export interface ExternalSelectInputBlock extends InputBlock { + element: ExternalSelect; +} diff --git a/silo/src/apps/slack/views/issue-linkback.ts b/silo/src/apps/slack/views/issue-linkback.ts index 2163e08f41..4d453035a0 100644 --- a/silo/src/apps/slack/views/issue-linkback.ts +++ b/silo/src/apps/slack/views/issue-linkback.ts @@ -1,27 +1,25 @@ import { env } from "@/env"; -import { ExIssue, ExProject, ExState, PlaneUser } from "@plane/sdk"; +import { ExState, IssueWithExpanded } from "@plane/sdk"; import { formatTimestampToNaturalLanguage } from "../helpers/format-date"; import { ACTIONS, PLANE_PRIORITIES } from "../helpers/constants"; import { convertUnicodeToSlackEmoji } from "../helpers/emoji-converter"; export const createSlackLinkback = ( workspaceSlug: string, - project: ExProject, - members: PlaneUser[], - state: ExState[], - issue: ExIssue, + issue: IssueWithExpanded<["state", "project", "assignees", "labels"]>, + states: ExState[], isSynced: boolean, - showLogo = false + showLogo = false, + hideOverflowMenu = false, + asHeader = false ) => { - const targetState = state.find((s) => s.id === issue.state); - - const stateList = state.map((s) => ({ + const stateList = states.map((s) => ({ text: { type: "plain_text", text: s.name, emoji: true, }, - value: `${issue.project}.${issue.id}.${s.id}`, + value: `${issue.project.id}.${issue.id}.${s.id}`, })); const priorityList = PLANE_PRIORITIES.map((p) => ({ @@ -30,7 +28,7 @@ export const createSlackLinkback = ( text: p.name, emoji: true, }, - value: `${issue.project}.${issue.id}.${p.id}`, + value: `${issue.project.id}.${issue.id}.${p.id}`, })); // Create base blocks array @@ -57,24 +55,24 @@ export const createSlackLinkback = ( type: "section", text: { type: "mrkdwn", - text: `<${env.APP_BASE_URL}/${workspaceSlug}/projects/${issue.project}/issues/${issue.id}| ${project.identifier}-${issue.sequence_id} ${issue.name}>`, + text: asHeader ? `*${issue.project.identifier}-${issue.sequence_id} ${issue.name}*` : `<${env.APP_BASE_URL}/${workspaceSlug}/projects/${issue.project.id}/issues/${issue.id}| ${issue.project.identifier}-${issue.sequence_id} ${issue.name}>`, }, }); // Create context elements array with only non-empty values const contextElements: any[] = []; - if (project.name) { - const emoji = project.logo_props && project.logo_props?.in_use === "emoji" && project.logo_props?.emoji && project.logo_props?.emoji?.value ? convertUnicodeToSlackEmoji(project.logo_props?.emoji?.value) : "šŸ“‹"; + if (issue.project.name) { + const emoji = issue.project.logo_props && issue.project.logo_props?.in_use === "emoji" && issue.project.logo_props?.emoji && issue.project.logo_props?.emoji?.value ? convertUnicodeToSlackEmoji(issue.project.logo_props?.emoji?.value) : "šŸ“‹"; contextElements.push({ type: "mrkdwn", - text: `${emoji} <${env.APP_BASE_URL}/${workspaceSlug}/projects/${issue.project}/issues|${project.name}>`, + text: `${emoji} <${env.APP_BASE_URL}/${workspaceSlug}/projects/${issue.project.id}/issues|${issue.project.name}>`, }); } // Add state and priority labels - const stateLabel = targetState ? `*State:* ${targetState.name}` : "*State:* Not set"; + const stateLabel = issue.state ? `*State:* ${issue.state.name}` : "*State:* Not set"; const priorityLabel = issue.priority ? `*Priority:* ${titleCaseWord(issue.priority)}` : "*Priority:* Not set"; contextElements.push({ @@ -84,12 +82,11 @@ export const createSlackLinkback = ( if (issue.assignees && issue.assignees.length > 0) { const uniqueAssignees = Array.from(new Set(issue.assignees)); - - const firstAssignee = members.find((m) => m.id === uniqueAssignees[0]); + const firstAssignee = issue.assignees.find((m) => m.id === uniqueAssignees[0].id); contextElements.push({ type: "mrkdwn", - text: `*${uniqueAssignees.length > 1 ? "Assignees:" : "Assignee:"}* ${firstAssignee?.display_name} ${uniqueAssignees.length > 1 ? `and ${uniqueAssignees.length - 1} others` : ""}`, + text: `*${uniqueAssignees.length > 1 ? "Assignees:" : "Assignee:"}* ${firstAssignee?.first_name} ${uniqueAssignees.length > 1 ? `and ${uniqueAssignees.length - 1} others` : ""}`, }); } @@ -161,38 +158,63 @@ export const createSlackLinkback = ( } // Add overflow menu for button actions - const overflowMenu = { - type: "overflow", - action_id: ACTIONS.LINKBACK_OVERFLOW_ACTIONS, - options: [ - { - text: { - type: "plain_text", - text: "Add Link", - emoji: true, + if (!hideOverflowMenu) { + const overflowMenu = { + type: "overflow", + action_id: ACTIONS.LINKBACK_OVERFLOW_ACTIONS, + options: [ + { + text: { + type: "plain_text", + text: "Add Link", + emoji: true, + }, + value: `${issue.project.id}.${issue.id}`, }, - value: `${issue.project}.${issue.id}`, - }, - { - text: { - type: "plain_text", - text: "Comment", - emoji: true, + { + text: { + type: "plain_text", + text: "Comment", + emoji: true, + }, + value: `${issue.project.id}.${issue.id}`, }, - value: `${issue.project}.${issue.id}`, - }, - { - text: { - type: "plain_text", - text: "Assign to me", - emoji: true, + { + text: { + type: "plain_text", + text: "Assign to me", + emoji: true, + }, + value: `${issue.project.id}.${issue.id}`, }, - value: `${issue.project}.${issue.id}`, - }, - ], - }; + ], + }; - actionElements.push(overflowMenu); + actionElements.push(overflowMenu); + } + + if (hideOverflowMenu) { + // Give a button for assign to me + actionElements.push({ + type: "button", + text: { + type: "plain_text", + text: "Assign to me", + }, + value: `${issue.project.id}.${issue.id}`, + }); + } + + if (asHeader) { + actionElements.push({ + type: "button", + text: { + type: "plain_text", + text: "Open in Plane", + }, + url: `${env.APP_BASE_URL}/${workspaceSlug}/projects/${issue.project.id}/issues/${issue.id}`, + }); + } // Add actions block if there are any elements if (actionElements.length > 0) { diff --git a/silo/src/apps/slack/views/issue-modal-full.ts b/silo/src/apps/slack/views/issue-modal-full.ts index b8b1e3b8a2..6a9c21ccff 100644 --- a/silo/src/apps/slack/views/issue-modal-full.ts +++ b/silo/src/apps/slack/views/issue-modal-full.ts @@ -7,9 +7,11 @@ import { PlainTextInputBlock, StaticSelectInputBlock, } from "./custom-blocks"; +import { E_MESSAGE_ACTION_TYPES } from "../types/types"; export type IssueModalViewFull = { type: "modal"; + callback_id: string; private_metadata: string; title: PlainTextElement; submit: PlainTextElement; @@ -44,6 +46,7 @@ export const createIssueModalViewFull = ( showThreadSync: boolean = true ): IssueModalViewFull => ({ type: "modal", + callback_id: E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM, private_metadata: privateMetadata, title: { type: "plain_text", @@ -177,30 +180,30 @@ export const createIssueModalViewFull = ( }, ...(showThreadSync ? [ - { - type: "input", - optional: true, - element: { - type: "checkboxes", - options: [ - { - text: { - type: "plain_text", - text: "Sync slack comments with plane comments and vice versa", - emoji: true, - }, - value: "true", + { + type: "input", + optional: true, + element: { + type: "checkboxes", + options: [ + { + text: { + type: "plain_text", + text: "Sync slack comments with plane comments and vice versa", + emoji: true, }, - ], - action_id: "enable_thread_sync", - }, - label: { - type: "plain_text", - text: "Thread Sync", - emoji: true, - }, + value: "true", + }, + ], + action_id: "enable_thread_sync", }, - ] + label: { + type: "plain_text", + text: "Thread Sync", + emoji: true, + }, + }, + ] : []), ], }); diff --git a/silo/src/apps/slack/views/link-issue-modal.ts b/silo/src/apps/slack/views/link-issue-modal.ts new file mode 100644 index 0000000000..a43d9713cb --- /dev/null +++ b/silo/src/apps/slack/views/link-issue-modal.ts @@ -0,0 +1,120 @@ +import { ACTIONS, ENTITIES } from "../helpers/constants"; +import { PlainTextElement } from "@slack/types"; +import { E_MESSAGE_ACTION_TYPES } from "../types/types"; +import { ExState, IssueWithExpanded } from "@plane/sdk"; +import { createSlackLinkback } from "./issue-linkback"; + +export type LinkIssueModalView = { + type: "modal"; + callback_id: string; + private_metadata: string; + title: PlainTextElement; + submit: PlainTextElement; + close: PlainTextElement; + blocks: any; +}; + +/** + * Creates a modal view for linking existing issues + * @param privateMetadata Metadata to pass through the modal (JSON string) + * @returns A configured modal view + */ +export const createLinkIssueModalView = ( + privateMetadata: any = {}, + error: string | null = null +): LinkIssueModalView => ({ + type: "modal", + callback_id: E_MESSAGE_ACTION_TYPES.LINK_WORK_ITEM, + private_metadata: JSON.stringify({ + entityType: ENTITIES.LINK_WORK_ITEM, + entityPayload: privateMetadata, + }), + title: { + type: "plain_text", + text: "Link Work Item", + emoji: true, + }, + submit: { + type: "plain_text", + text: "Link", + emoji: true, + }, + close: { + type: "plain_text", + text: "Cancel", + emoji: true, + }, + blocks: [ + ...(error ? [{ + type: "section", + text: { + type: "mrkdwn", + text: error, + }, + }] : []), + { + type: "input", + block_id: "work_item_select", + element: { + type: "external_select", + placeholder: { + type: "plain_text", + text: "Search for work items by title or ID", + emoji: true, + }, + min_query_length: 2, + action_id: ACTIONS.LINK_WORK_ITEM, + focus_on_load: true, + }, + label: { + type: "plain_text", + text: "Work Items", + emoji: true, + }, + }, + ], +}); + +/** + * Creates a modal view for already linked issues + * @param workspaceSlug The workspace slug + * @param project The project details + * @param state The issue state + * @param issue The issue that is already linked + * @param assignees Optional array of assignee users + * @returns A configured modal view with issue context + */ +export const alreadyLinkedModalView = ( + workspaceSlug: string, + issue: IssueWithExpanded<["state", "project", "assignees", "labels"]>, + states: ExState[], + privateMetadata: any = {} +) => { + + const linkbackBlocks = createSlackLinkback(workspaceSlug, issue, states, false, false, true, true); + + return { + type: "modal", + callback_id: E_MESSAGE_ACTION_TYPES.DISCONNECT_WORK_ITEM, + private_metadata: JSON.stringify({ + entityType: ENTITIES.DISCONNECT_WORK_ITEM, + entityPayload: { ...privateMetadata }, + }), + title: { + type: "plain_text", + text: "Linked Work Item", + emoji: true, + }, + submit: { + type: "plain_text", + text: "Disconnect", + emoji: true, + }, + close: { + type: "plain_text", + text: "Cancel", + emoji: true, + }, + blocks: linkbackBlocks.blocks, + }; +}; diff --git a/silo/src/apps/slack/views/project-select-modal.ts b/silo/src/apps/slack/views/project-select-modal.ts index 12d00e3e7b..56cb19492d 100644 --- a/silo/src/apps/slack/views/project-select-modal.ts +++ b/silo/src/apps/slack/views/project-select-modal.ts @@ -1,6 +1,6 @@ import { ACTIONS, ENTITIES, EntityTypeValue } from "../helpers/constants"; import { PlainTextOption } from "../helpers/slack-options"; -import { ShortcutActionPayload } from "../types/types"; +import { E_MESSAGE_ACTION_TYPES, ShortcutActionPayload } from "../types/types"; export const createProjectSelectionModal = ( projects: Array, @@ -8,6 +8,7 @@ export const createProjectSelectionModal = ( type: EntityTypeValue = ENTITIES.SHORTCUT_PROJECT_SELECTION ) => ({ type: "modal", + callback_id: E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM, private_metadata: JSON.stringify({ entityType: type, entityPayload: privateMetadata, diff --git a/silo/src/apps/slack/worker/handlers/block-actions.ts b/silo/src/apps/slack/worker/handlers/block-actions.ts index 5f9f218dda..72127233b4 100644 --- a/silo/src/apps/slack/worker/handlers/block-actions.ts +++ b/silo/src/apps/slack/worker/handlers/block-actions.ts @@ -189,17 +189,13 @@ async function handleSwitchPriorityAction(data: TBlockActionPayload) { priority: value[2], }); - const issue = await planeClient.issue.getIssue(workspaceConnection.workspace_slug, projectId, issueId); - const project = await planeClient.project.getProject(workspaceConnection.workspace_slug, projectId); + const issue = await planeClient.issue.getIssueWithFields(workspaceConnection.workspace_slug, projectId, issueId, ["state", "project", "assignees", "labels"]); const states = await planeClient.state.list(workspaceConnection.workspace_slug, projectId); - const members = await planeClient.users.list(workspaceConnection.workspace_slug, projectId); const updatedLinkback = createSlackLinkback( workspaceConnection.workspace_slug, - project, - members, - states.results, issue, + states.results, isThreadSync ); @@ -243,12 +239,11 @@ async function handleProjectSelectAction(data: TBlockActionModalPayload) { const projects = await planeClient.project.list(workspaceConnection.workspace_slug); const selectedProject = await planeClient.project.getProject(workspaceConnection.workspace_slug, selection.value); const projectAssets = await fetchPlaneAssets(workspaceConnection.workspace_slug, selection.value, planeClient); - const metadata = JSON.parse(data.view.private_metadata) as SlackPrivateMetadata; + const metadata = JSON.parse(data.view.private_metadata) as SlackPrivateMetadata; if ( - (metadata && metadata.entityPayload.type === "message_action") || - metadata.entityPayload.type === "shortcut" || - metadata.entityPayload.type === "command_project_selection" + metadata.entityType === ENTITIES.SHORTCUT_PROJECT_SELECTION || + metadata.entityType === ENTITIES.COMMAND_PROJECT_SELECTION ) { const modal = createIssueModalViewFull( { @@ -257,11 +252,11 @@ async function handleProjectSelectAction(data: TBlockActionModalPayload) { priorityOptions: convertToSlackOptions(PLANE_PRIORITIES), stateOptions: convertToSlackOptions(projectAssets.states.results), }, - metadata.entityType === ENTITIES.SHORTCUT_PROJECT_SELECTION && metadata.entityPayload.type === "message_action" + metadata.entityType === ENTITIES.SHORTCUT_PROJECT_SELECTION ? metadata.entityPayload.message?.text : "", - JSON.stringify({ entityType: ENTITIES.ISSUE_SUBMISSION, entityPayload: metadata.entityPayload }), - metadata.entityPayload.type === "command_project_selection" ? false : true + JSON.stringify({ entityType: metadata.entityType, entityPayload: metadata.entityPayload }), + metadata.entityPayload.type !== ENTITIES.COMMAND_PROJECT_SELECTION ); await slackService.updateModal(data.view.id, modal); @@ -295,17 +290,13 @@ async function handleLinkbackStateChange(data: TBlockActionPayload) { state: stateId, }); - const issue = await planeClient.issue.getIssue(workspaceConnection.workspace_slug, projectId, issueId); - const project = await planeClient.project.getProject(workspaceConnection.workspace_slug, projectId); + const issue = await planeClient.issue.getIssueWithFields(workspaceConnection.workspace_slug, projectId, issueId, ["state", "project", "assignees", "labels"]); const states = await planeClient.state.list(workspaceConnection.workspace_slug, projectId); - const members = await planeClient.users.list(workspaceConnection.workspace_slug, projectId); const updatedLinkback = createSlackLinkback( workspaceConnection.workspace_slug, - project, - members, - states.results, issue, + states.results, isThreadSync ); diff --git a/silo/src/apps/slack/worker/handlers/handle-message.ts b/silo/src/apps/slack/worker/handlers/handle-message.ts index c73bce430e..01e7dc0e7f 100644 --- a/silo/src/apps/slack/worker/handlers/handle-message.ts +++ b/silo/src/apps/slack/worker/handlers/handle-message.ts @@ -67,7 +67,7 @@ export const handleMessageEvent = async (data: SlackEventPayload) => { const members = await planeClient.users.list(workspaceConnection.workspace_slug, eConnection.project_id ?? ""); const userInfo = await slackService.getUserInfo(data.event.user); - const issueId = eConnection.entity_slug; + const issueId = eConnection.entity_slug ?? eConnection.issue_id; const planeUser = members.find((member) => member.email === userInfo?.user.profile.email); @@ -103,24 +103,21 @@ export const handleLinkSharedEvent = async (data: SlackEventPayload) => { if (resource === null) return; if (resource.type === "issue") { + if (resource.workspaceSlug !== workspaceConnection.workspace_slug) return; - const issue = await planeClient.issue.getIssueByIdentifier( + const issue = await planeClient.issue.getIssueByIdentifierWithFields( workspaceConnection.workspace_slug, resource.projectIdentifier, - Number(resource.issueKey) + Number(resource.issueKey), + ["state", "project", "assignees", "labels"] ); - - const project = await planeClient.project.getProject(workspaceConnection.workspace_slug, issue.project); - const members = await planeClient.users.list(workspaceConnection.workspace_slug, issue.project); - const states = await planeClient.state.list(workspaceConnection.workspace_slug, issue.project); + const states = await planeClient.state.list(workspaceConnection.workspace_slug, issue.project.id ?? ""); const linkBack = createSlackLinkback( workspaceConnection.workspace_slug, - project, - members, - states.results, issue, + states.results, false, true ); diff --git a/silo/src/apps/slack/worker/handlers/message-action.ts b/silo/src/apps/slack/worker/handlers/message-action.ts index c8e80c4fbd..06d6810670 100644 --- a/silo/src/apps/slack/worker/handlers/message-action.ts +++ b/silo/src/apps/slack/worker/handlers/message-action.ts @@ -3,8 +3,91 @@ import { convertToSlackOptions } from "@/apps/slack/helpers/slack-options"; import { createProjectSelectionModal } from "@/apps/slack/views"; import { logger } from "@/logger"; import { getConnectionDetails } from "../../helpers/connection-details"; +import { alreadyLinkedModalView, createLinkIssueModalView } from "../../views/link-issue-modal"; +import { E_MESSAGE_ACTION_TYPES } from "../../types/types"; +import { getAPIClient } from "@/services/client"; +import { E_INTEGRATION_KEYS } from "@plane/etl/core"; +import { ENTITIES } from "../../helpers/constants"; + +const apiClient = getAPIClient() export const handleMessageAction = async (data: TMessageActionPayload) => { + if (!data.callback_id) { + logger.info(`[SLACK] No callback id found for message action ${data.type}`); + return + } + + switch (data.callback_id) { + case E_MESSAGE_ACTION_TYPES.LINK_WORK_ITEM: + await handleLinkWorkItem(data); + break; + case E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM: + await handleCreateNewWorkItem(data); + break; + } +}; + +const handleLinkWorkItem = async (data: TMessageActionPayload) => { + // Get the workspace connection for the associated team + + /* + * A work item can only be linked to one thread at a time + 1. If connected to existing work item, show the work item details in the modal and disconnect button + 2. If no work item is connected, show the modal to link a work item + 3. If selected work item is already linked to another thread, show a message to the user that it is + already linked to another thread and to disconnect it first + */ + + const details = await getConnectionDetails(data.team.id); + if (!details) { + logger.info(`[SLACK] No connection details found for team ${data.team.id}`); + return; + } + + const { slackService, planeClient } = details; + + const metadata = { + type: "message_action", + channel: { + id: data.channel.id, + name: data.channel.name, + }, + message: { + text: data.message.text, + ts: data.message.ts, + }, + } + + const workspaceEntityConnections = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({ + workspace_connection_id: details.workspaceConnection.id, + entity_type: E_INTEGRATION_KEYS.SLACK, + entity_id: data.message.ts, + }); + + let modal: any | undefined; + + if (workspaceEntityConnections.length > 0) { + const issuePromise = planeClient.issue.getIssueWithFields(workspaceEntityConnections[0].workspace_slug, workspaceEntityConnections[0].project_id!, workspaceEntityConnections[0].issue_id!, ["state", "project", "assignees", "labels"]); + const statesPromise = planeClient.state.list(workspaceEntityConnections[0].workspace_slug, workspaceEntityConnections[0].project_id!); + + const [issue, states] = await Promise.all([issuePromise, statesPromise]); + + modal = alreadyLinkedModalView(workspaceEntityConnections[0].workspace_slug, issue, states.results, metadata); + } else { + modal = createLinkIssueModalView(metadata); + } + + try { + const res = await slackService.openModal(data.trigger_id, modal); + if (res && !res.ok) { + logger.error("Something went wrong while opening the modal", res); + } + } catch (error) { + logger.error(error); + } +} + +const handleCreateNewWorkItem = async (data: TMessageActionPayload) => { // Get the workspace connection for the associated team const details = await getConnectionDetails(data.team.id); if (!details) { @@ -18,7 +101,7 @@ export const handleMessageAction = async (data: TMessageActionPayload) => { const filteredProjects = projects.results.filter((project) => project.is_member === true); const plainTextOptions = convertToSlackOptions(filteredProjects); const modal = createProjectSelectionModal(plainTextOptions, { - type: "message_action", + type: ENTITIES.SHORTCUT_PROJECT_SELECTION, message: { text: data.message.text, ts: data.message.ts, @@ -36,4 +119,4 @@ export const handleMessageAction = async (data: TMessageActionPayload) => { } catch (error) { logger.error(error); } -}; +} diff --git a/silo/src/apps/slack/worker/handlers/view-submission.ts b/silo/src/apps/slack/worker/handlers/view-submission.ts index f36afd7b61..e263a8d126 100644 --- a/silo/src/apps/slack/worker/handlers/view-submission.ts +++ b/silo/src/apps/slack/worker/handlers/view-submission.ts @@ -1,243 +1,461 @@ import { E_INTEGRATION_KEYS } from "@plane/etl/core"; import { TViewSubmissionPayload } from "@plane/etl/slack"; import { CONSTANTS } from "@/helpers/constants"; -import { downloadFile } from "@/helpers/utils"; import { logger } from "@/logger"; import { getAPIClient } from "@/services/client"; -import { getConnectionDetails } from "../../helpers/connection-details"; +import { getConnectionDetails, SlackConnectionDetails } from "../../helpers/connection-details"; import { ENTITIES } from "../../helpers/constants"; -import { parseIssueFormData } from "../../helpers/parse-issue-form"; -import { SlackPrivateMetadata } from "../../types/types"; +import { parseIssueFormData, parseLinkWorkItemFormData } from "../../helpers/parse-issue-form"; +import { E_MESSAGE_ACTION_TYPES, SlackPrivateMetadata } from "../../types/types"; import { createSlackLinkback } from "../../views/issue-linkback"; +import { createLinkIssueModalView } from "../../views/link-issue-modal"; +import { env } from "@/env"; +import { IssueWithExpanded } from "@plane/sdk"; +import { credentials } from "amqplib"; const apiClient = getAPIClient(); export const handleViewSubmission = async (data: TViewSubmissionPayload) => { + const details = await getConnectionDetails(data.team.id); if (!details) { logger.info(`[SLACK] No connection details found for team ${data.team.id}`); return; } - const { workspaceConnection, slackService, planeClient, credentials } = details; + switch (data.view.callback_id) { + case E_MESSAGE_ACTION_TYPES.LINK_WORK_ITEM: + return await handleLinkWorkItemViewSubmission(details, data); + case E_MESSAGE_ACTION_TYPES.DISCONNECT_WORK_ITEM: + return await handleDisconnectWorkItemViewSubmission(details, data); + case E_MESSAGE_ACTION_TYPES.ISSUE_COMMENT_SUBMISSION: + return await handleIssueCommentViewSubmission(details, data); + case E_MESSAGE_ACTION_TYPES.ISSUE_WEBLINK_SUBMISSION: + return await handleIssueWeblinkViewSubmission(details, data); + case E_MESSAGE_ACTION_TYPES.CREATE_NEW_WORK_ITEM: + return await handleCreateNewWorkItemViewSubmission(details, data); + default: + logger.error("Unknown view submission callback id:", data.view.callback_id); + return; + } +}; + +export const handleLinkWorkItemViewSubmission = async (details: SlackConnectionDetails, data: TViewSubmissionPayload) => { + const { slackService, planeClient } = details; + + const linkWorkItemValues = parseLinkWorkItemFormData(data.view.state.values); + + const metadata = JSON.parse(data.view.private_metadata) as SlackPrivateMetadata; + + if (linkWorkItemValues) { + const issue = await planeClient.issue.getIssueWithFields(linkWorkItemValues.workspaceSlug, linkWorkItemValues.projectId, linkWorkItemValues.issueId, ["state", "project", "assignees", "labels"]); + + + // Check if the issue is already linked to a thread + const workspaceEntityConnection = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({ + workspace_connection_id: details.workspaceConnection.id, + issue_id: linkWorkItemValues.issueId, + entity_type: E_INTEGRATION_KEYS.SLACK, + }); + + if (workspaceEntityConnection.length > 0) { + // We need to update the view in order to show that the selected issue is already linked to a thread + const existingLink = workspaceEntityConnection[0]; + const entityData = existingLink.entity_data as any; + const channelName = entityData?.channel?.name || 'a channel'; + const threadLink = `https://${data.team.domain}.slack.com/archives/${entityData?.channel?.id}/p${existingLink.entity_id}`; + const issueLink = `${env.APP_BASE_URL}/${linkWorkItemValues.workspaceSlug}/browse/${issue.project.identifier}-${issue.sequence_id}`; + + // Create rich error message with links + const errorMessage = createIssueErrorBlocks(issue, channelName, threadLink, issueLink); + const updatedModal = createLinkIssueModalView(metadata.entityPayload); + updatedModal.blocks = [...errorMessage.blocks, ...updatedModal.blocks]; + const res = await slackService.openModal(data.trigger_id, updatedModal); + } else { + const title = "Connected to Slack thread"; + const link = `https://${data.team.domain}.slack.com/archives/${metadata.entityPayload.channel.id}/p${metadata.entityPayload.message?.ts}`; + + const states = await planeClient.state.list(linkWorkItemValues.workspaceSlug, linkWorkItemValues.projectId); + + const linkBack = createSlackLinkback( + linkWorkItemValues.workspaceSlug, + issue, + states.results, + false + ); + // Send a thread message to the channel + const linkbackPromise = slackService.sendThreadMessage( + metadata.entityPayload.channel.id, + metadata.entityPayload.message?.ts || "", + linkBack, + issue, + false + ); + + const createLinkInPlane = planeClient.issue.createLink(linkWorkItemValues.workspaceSlug, linkWorkItemValues.projectId, issue.id, title, link); + + const createEntityConnection = apiClient.workspaceEntityConnection.createWorkspaceEntityConnection({ + // Main workspace connection + workspace_connection_id: details.workspaceConnection.id, + + // Issue identifiers + workspace_id: details.workspaceConnection.workspace_id, + project_id: linkWorkItemValues.projectId, + issue_id: issue.id, + + // Entity identifiers + entity_type: E_INTEGRATION_KEYS.SLACK, + entity_id: metadata.entityPayload.message?.ts || "", + entity_data: metadata.entityPayload, + entity_slug: issue.id, + }); + + try { + await Promise.all([linkbackPromise, createLinkInPlane, createEntityConnection]); + } catch (error) { + logger.error("Error processing link work item view submission:", error); + } + } + } +} + +export const handleDisconnectWorkItemViewSubmission = async (details: SlackConnectionDetails, data: TViewSubmissionPayload) => { + const { workspaceConnection, slackService, planeClient } = details; + + const metadata = JSON.parse(data.view.private_metadata) as SlackPrivateMetadata; + + const workspaceEntityConnection = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({ + workspace_connection_id: workspaceConnection.id, + entity_id: metadata.entityPayload.message?.ts || "", + }); + + if (workspaceEntityConnection.length > 0) { + // Disconnect the work item + await apiClient.workspaceEntityConnection.deleteWorkspaceEntityConnection(workspaceEntityConnection[0].id); + + // Send a message to the channel + await slackService.sendThreadMessage(metadata.entityPayload.channel.id, metadata.entityPayload.message?.ts ?? "", { + text: "Work item disconnected from Slack thread.", + blocks: [] + }); + } +} + +export const handleIssueCommentViewSubmission = async (details: SlackConnectionDetails, data: TViewSubmissionPayload) => { + const { workspaceConnection, slackService, planeClient } = details; + + const metadata = JSON.parse(data.view.private_metadata) as SlackPrivateMetadata; + + const thread_ts = metadata.entityPayload.message?.thread_ts; + const channel = metadata.entityPayload.channel.id; + const user = metadata.entityPayload.user.id; + const value = metadata.entityPayload.value; + const message_ts = metadata.entityPayload.message_ts; - const metadata = JSON.parse(data.view.private_metadata) as SlackPrivateMetadata; try { - const projects = await planeClient.project.list(workspaceConnection.workspace_slug); - projects.results.filter((project) => project.is_member === true); + const values = value.split("."); - const parsedData = parseIssueFormData(data.view.state.values); - - if (metadata.entityPayload.type === "message_action" || metadata.entityPayload.type === "command_project_selection") { - try { - const slackUser = await slackService.getUserInfo(data.user.id); - const members = await planeClient.users.listAllUsers(workspaceConnection.workspace_slug); - const member = members.find((member) => member.email === slackUser?.user.profile.email); - - /* ==================== Create Issue ==================== */ - const issue = await planeClient.issue.create(workspaceConnection.workspace_slug, parsedData.project, { - name: parsedData.title, - description_html: parsedData.description == null ? "

" : parsedData.description, - created_by: member?.id, - state: parsedData.state, - priority: parsedData.priority, - labels: parsedData.labels, - }); - - const project = await planeClient.project.getProject(workspaceConnection.workspace_slug, parsedData.project); - const states = await planeClient.state.list(workspaceConnection.workspace_slug, issue.project); - - const linkBack = createSlackLinkback( - workspaceConnection.workspace_slug, - project, - members, - states.results, - issue, - parsedData.enableThreadSync || false - ); - - if (metadata.entityType === ENTITIES.ISSUE_SUBMISSION && metadata.entityPayload.type === "message_action") { - /* ==================== Send Linkback ==================== */ - const res = await slackService.sendThreadMessage( - metadata.entityPayload.channel.id, - metadata.entityPayload.message?.ts, - linkBack, - issue, - false - ); - - // If thread sync is enabled and the response is ok for the linkback - if (parsedData.enableThreadSync && res.ok) { - // If thread sync is enabled, create an entity connection for the issue - // check if the connection already exists - const connections = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({ - entity_id: res.message.thread_ts, - }); - if (!connections || connections.length === 0) { - await apiClient.workspaceEntityConnection.createWorkspaceEntityConnection({ - workspace_id: workspaceConnection.workspace_id, - workspace_connection_id: workspaceConnection.id, - project_id: parsedData.project, - entity_type: E_INTEGRATION_KEYS.SLACK, - entity_id: res.message.thread_ts, - entity_data: res, - entity_slug: issue.id, - issue_id: issue.id, - }); - } - } else { - logger.error("Error sending thread message:", res); - } - - /* ==================== File Upload ==================== */ - - if (metadata.entityPayload.message.ts) { - const response = await slackService.getMessage( - metadata.entityPayload.channel.id, - metadata.entityPayload.message?.ts - ); - - if (response && response.ok && response.messages && response.messages.length > 0) { - const message = response.messages[0]; - if (message.files && credentials.source_access_token) { - const fileUploadPromises = message.files.map(async (file) => { - const blob = await downloadFile(file.url_private_download, `Bearer ${credentials.source_access_token}`); - if (blob) { - await planeClient.issue.uploadAttachment( - workspaceConnection.workspace_slug, - parsedData.project, - issue.id, - blob as File, - file.name, - file.size, - { - type: file.mimetype, - } - ); - } - }); - - await Promise.all(fileUploadPromises); - } - } - } - } else if (metadata.entityPayload.type === "command_project_selection") { - if (metadata.entityPayload.response_url) - await slackService.sendMessage(metadata.entityPayload.response_url, { - text: "Issue successfully created. šŸ˜„", - blocks: linkBack.blocks, - }); - } - } catch (error: any) { - const isPermissionError = error?.detail?.includes(CONSTANTS.NO_PERMISSION_ERROR); - if (isPermissionError) { - logger.error("Permission error in handleViewSubmission:", error); - } else { - throw error; - } - } - } else if (metadata.entityType === ENTITIES.ISSUE_COMMENT_SUBMISSION) { - const thread_ts = metadata.entityPayload.message?.thread_ts; - const channel = metadata.entityPayload.channel.id; - const user = metadata.entityPayload.user.id; - const value = metadata.entityPayload.value; - const message_ts = metadata.entityPayload.message_ts; - - - try { - const values = value.split("."); - - if (values.length === 2) { - const projectId = values[0]; - const issueId = values[1]; - - let comment = ""; - - Object.entries(data.view.state.values).forEach(([_, blockData]: [string, any]) => { - if (blockData.comment_submit?.type === "plain_text_input") { - comment = blockData.comment_submit.value; - } - }); - - const slackUser = await slackService.getUserInfo(user); - const projectMembers = await planeClient.users.list(workspaceConnection.workspace_slug, projectId); - - const member = projectMembers.find((member) => member.email === slackUser?.user.profile.email); - - await planeClient.issueComment.create(workspaceConnection.workspace_slug, projectId, issueId, { - comment_html: "

" + comment + "

", - created_by: member?.id, - external_source: "SLACK-PRIVATE_COMMENT", - external_id: data.view.id, - }); - - if (thread_ts) { - await slackService.sendEphemeralMessage(user, `Comment successfully added to issue.`, channel, thread_ts); - } else { - await slackService.sendThreadMessage(channel, message_ts, `Comment successfully added to issue.`); - } - } - } catch (error: any) { - const isPermissionError = error?.detail?.includes(CONSTANTS.NO_PERMISSION_ERROR); - const errorMessage = isPermissionError - ? CONSTANTS.NO_PERMISSION_ERROR_MESSAGE - : CONSTANTS.SOMETHING_WENT_WRONG; - - await slackService.sendEphemeralMessage(data.user.id, errorMessage, channel, thread_ts); - - if (!isPermissionError) { - throw error; - } - } - } else if (metadata.entityType === ENTITIES.ISSUE_WEBLINK_SUBMISSION) { - const thread_ts = metadata.entityPayload.message?.thread_ts; - const user = metadata.entityPayload.user.id; - const channel = metadata.entityPayload.channel.id; - const message_ts = metadata.entityPayload.message_ts; - const value = metadata.entityPayload.value; - const values = value.split("."); + if (values.length === 2) { const projectId = values[0]; const issueId = values[1]; - let label = ""; - let url = ""; - try { + let comment = ""; - Object.entries(data.view.state.values).forEach(([_, blockData]: [string, any]) => { - Object.entries(blockData).forEach(([_, values]: [string, any]) => { - if (values.type === "plain_text_input") { - label = values.value; - } else if (values.type === "url_text_input") { - url = values.value; - } - }); - }); - - const message = `Link <${url}|${label}> successfully added to issue.`; - - await planeClient.issue.createLink(workspaceConnection.workspace_slug, projectId, issueId, label, url); - - if (thread_ts) { - await slackService.sendEphemeralMessage(user, message, channel, thread_ts); - } else { - await slackService.sendThreadMessage(channel, message_ts, message); + Object.entries(data.view.state.values).forEach(([_, blockData]: [string, any]) => { + if (blockData.comment_submit?.type === "plain_text_input") { + comment = blockData.comment_submit.value; } - } catch (error: any) { - const isPermissionError = error?.detail?.includes(CONSTANTS.NO_PERMISSION_ERROR); - const errorMessage = isPermissionError - ? CONSTANTS.NO_PERMISSION_ERROR_MESSAGE - : CONSTANTS.SOMETHING_WENT_WRONG; + }); - await slackService.sendEphemeralMessage(data.user.id, errorMessage, channel, thread_ts); + const slackUser = await slackService.getUserInfo(user); + const projectMembers = await planeClient.users.list(workspaceConnection.workspace_slug, projectId); - if (!isPermissionError) { - throw error; - } + const member = projectMembers.find((member) => member.email === slackUser?.user.profile.email); + + await planeClient.issueComment.create(workspaceConnection.workspace_slug, projectId, issueId, { + comment_html: "

" + comment + "

", + created_by: member?.id, + external_source: "SLACK-PRIVATE_COMMENT", + external_id: data.view.id, + }); + + if (thread_ts) { + await slackService.sendEphemeralMessage(user, `Comment successfully added to issue.`, channel, thread_ts); + } else { + await slackService.sendThreadMessage(channel, message_ts, `Comment successfully added to issue.`); } } } catch (error: any) { - logger.error("Unexpected error in handleViewSubmission:", error); - throw error; + const isPermissionError = error?.detail?.includes(CONSTANTS.NO_PERMISSION_ERROR); + const errorMessage = isPermissionError + ? CONSTANTS.NO_PERMISSION_ERROR_MESSAGE + : CONSTANTS.SOMETHING_WENT_WRONG; + + await slackService.sendEphemeralMessage(data.user.id, errorMessage, channel, thread_ts); + + if (!isPermissionError) { + throw error; + } + } +} + +export const handleIssueWeblinkViewSubmission = async (details: SlackConnectionDetails, data: TViewSubmissionPayload) => { + const { workspaceConnection, slackService, planeClient } = details; + + const metadata = JSON.parse(data.view.private_metadata) as SlackPrivateMetadata; + + const thread_ts = metadata.entityPayload.message?.thread_ts; + const user = metadata.entityPayload.user.id; + const channel = metadata.entityPayload.channel.id; + const message_ts = metadata.entityPayload.message_ts; + const value = metadata.entityPayload.value; + const values = value.split("."); + const projectId = values[0]; + const issueId = values[1]; + let label = ""; + let url = ""; + + try { + + Object.entries(data.view.state.values).forEach(([_, blockData]: [string, any]) => { + Object.entries(blockData).forEach(([_, values]: [string, any]) => { + if (values.type === "plain_text_input") { + label = values.value; + } else if (values.type === "url_text_input") { + url = values.value; + } + }); + }); + + const message = `Link <${url}|${label}> successfully added to issue.`; + + await planeClient.issue.createLink(workspaceConnection.workspace_slug, projectId, issueId, label, url); + + if (thread_ts) { + await slackService.sendEphemeralMessage(user, message, channel, thread_ts); + } else { + await slackService.sendThreadMessage(channel, message_ts, message); + } + } catch (error: any) { + const isPermissionError = error?.detail?.includes(CONSTANTS.NO_PERMISSION_ERROR); + const errorMessage = isPermissionError + ? CONSTANTS.NO_PERMISSION_ERROR_MESSAGE + : CONSTANTS.SOMETHING_WENT_WRONG; + + await slackService.sendEphemeralMessage(data.user.id, errorMessage, channel, thread_ts); + + if (!isPermissionError) { + throw error; + } + } +} + +export const handleCreateNewWorkItemViewSubmission = async (details: SlackConnectionDetails, data: TViewSubmissionPayload) => { + const { workspaceConnection, slackService, planeClient } = details; + + try { + const parsedData = parseIssueFormData(data.view.state.values); + const metadata = JSON.parse(data.view.private_metadata) as SlackPrivateMetadata; + + if (metadata.entityType !== ENTITIES.SHORTCUT_PROJECT_SELECTION && metadata.entityType !== ENTITIES.COMMAND_PROJECT_SELECTION) { + logger.info(`[SLACK] Unsupported payload type: ${metadata.entityType}`); + return; + } + + const slackUser = await slackService.getUserInfo(data.user.id); + const members = await planeClient.users.listAllUsers(workspaceConnection.workspace_slug); + const member = members.find(m => m.email === slackUser?.user.profile.email); + + const issue = await planeClient.issue.create( + workspaceConnection.workspace_slug, + parsedData.project, + { + name: parsedData.title, + description_html: parsedData.description == null ? "

" : parsedData.description, + created_by: member?.id, + state: parsedData.state, + priority: parsedData.priority, + labels: parsedData.labels, + } + ); + + const issueWithFields = await planeClient.issue.getIssueWithFields( + workspaceConnection.workspace_slug, + parsedData.project, + issue.id, + ["state", "project", "assignees", "labels"] + ); + + const states = await planeClient.state.list( + workspaceConnection.workspace_slug, + issue.project + ); + + const linkBack = createSlackLinkback( + workspaceConnection.workspace_slug, + issueWithFields, + states.results, + parsedData.enableThreadSync || false + ); + + // Step 6: Send the appropriate response based on entity type + if (metadata.entityType === ENTITIES.SHORTCUT_PROJECT_SELECTION) { + /* For shortcut project selection, + - Send a thread message to the channel + - Create a thread connection if thread sync is enabled + Hence, we need to handle the shortcut project selection in a separate function + */ + await handleShortcutProjectSelection( + slackService, + apiClient, + workspaceConnection, + parsedData, + metadata as SlackPrivateMetadata, + linkBack, + issue, + credentials + ); + } else if (metadata.entityType === ENTITIES.COMMAND_PROJECT_SELECTION) { + /* For command project selection, + - Send a message to the response_url, directly to the channel + Hence, we need to handle the command project selection in a separate function + */ + await handleCommandProjectSelection( + slackService, + metadata as SlackPrivateMetadata, + linkBack + ); + } + } catch (error: any) { + // Handle any errors + const isPermissionError = error?.detail?.includes(CONSTANTS.NO_PERMISSION_ERROR); + if (isPermissionError) { + logger.error("Permission error in handleCreateNewWorkItemViewSubmission:", error); + } else { + logger.error("Unexpected error in handleCreateNewWorkItemViewSubmission:", error); + throw error; + } } }; + +// Helper function for shortcut project selection handling +async function handleShortcutProjectSelection( + slackService: any, + apiClient: any, + workspaceConnection: any, + parsedData: any, + metadata: SlackPrivateMetadata, + linkBack: any, + issue: any, + credentials: any +) { + // Send thread message + const res = await slackService.sendThreadMessage( + metadata.entityPayload.channel.id, + metadata.entityPayload.message?.ts || "", + linkBack, + issue, + false + ); + + // Handle thread sync if enabled + if (parsedData.enableThreadSync && res.ok) { + await createThreadConnection( + apiClient, + workspaceConnection, + parsedData.project, + issue.id, + res + ); + } +} + +// Helper function for command project selection handling +async function handleCommandProjectSelection( + slackService: any, + metadata: SlackPrivateMetadata, + linkBack: any +) { + if (metadata.entityPayload.response_url) { + await slackService.sendMessage(metadata.entityPayload.response_url, { + text: "Issue successfully created. šŸ˜„", + blocks: linkBack.blocks, + }); + } +} + +// Helper function to create thread connection +async function createThreadConnection( + apiClient: any, + workspaceConnection: any, + projectId: string, + issueId: string, + res: any +) { + const connections = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({ + entity_id: res.message.thread_ts, + }); + + if (!connections || connections.length === 0) { + await apiClient.workspaceEntityConnection.createWorkspaceEntityConnection({ + workspace_id: workspaceConnection.workspace_id, + workspace_connection_id: workspaceConnection.id, + project_id: projectId, + entity_type: E_INTEGRATION_KEYS.SLACK, + entity_id: res.message.thread_ts, + entity_data: res, + entity_slug: issueId, + issue_id: issueId, + }); + } +} + +export const createIssueErrorBlocks = (issue: IssueWithExpanded<["state", "project", "assignees", "labels"]>, channelName: string, threadLink: string, issueLink: string) => { + const errorMessage = { + blocks: [ + { + type: "header", + text: { + type: "plain_text", + text: "Work Item Already Linked", + emoji: true + } + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `:warning: The work item *<${issueLink}|[${issue.project.identifier}-${issue.sequence_id}] ${issue.name}>* is already linked to another conversation in #${channelName}.` + } + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "View Linked Thread", + emoji: true + }, + url: threadLink, + style: "primary" + }, + { + type: "button", + text: { + type: "plain_text", + text: "View Work Item", + emoji: true + }, + url: issueLink + } + ] + } + ] + }; + + return errorMessage; +} + diff --git a/silo/src/apps/slack/worker/plane-webhook-handlers/handle-comment-webhook.ts b/silo/src/apps/slack/worker/plane-webhook-handlers/handle-comment-webhook.ts index 8889844bf7..2023171cce 100644 --- a/silo/src/apps/slack/worker/plane-webhook-handlers/handle-comment-webhook.ts +++ b/silo/src/apps/slack/worker/plane-webhook-handlers/handle-comment-webhook.ts @@ -16,7 +16,7 @@ const handleCommentSync = async (payload: WebhookIssueCommentPayload) => { const [entityConnection] = await apiClient.workspaceEntityConnection.listWorkspaceEntityConnections({ workspace_id: data.data.workspace, project_id: data.data.project, - entity_slug: data.data.issue, + issue_id: data.data.issue, }); /* @@ -37,20 +37,23 @@ const handleCommentSync = async (payload: WebhookIssueCommentPayload) => { const slackData = entityConnection.entity_data as SlackMessageResponse; const details = await getConnectionDetails(slackData.message.team); + if (!details) return logger.info(`[SLACK] No connection details found for team ${slackData.message.team}`); const { slackService } = details; + const channel = typeof slackData.channel === "string" ? slackData.channel : slackData.channel?.["id"]; + if (credentials && credentials.length > 0 && credentials[0].source_access_token) { await slackService.sendMessageAsUser( - slackData.channel, + channel, entityConnection.entity_id ?? "", data.data.comment_stripped, credentials[0].source_access_token ); } else { await slackService.sendThreadMessage( - slackData.channel, + channel, entityConnection.entity_id ?? "", data.data.comment_stripped ); diff --git a/silo/src/apps/slack/worker/plane-webhook-handlers/handle-issue-webhook.ts b/silo/src/apps/slack/worker/plane-webhook-handlers/handle-issue-webhook.ts new file mode 100644 index 0000000000..4b164fa036 --- /dev/null +++ b/silo/src/apps/slack/worker/plane-webhook-handlers/handle-issue-webhook.ts @@ -0,0 +1,181 @@ +import { Store } from "@/worker/base"; +import { PlaneWebhookPayload } from "@plane/sdk"; +import { getConnectionDetailsForIssue } from "../../helpers/connection-details"; +import { ActivityForSlack, PlaneActivityWithTimestamp } from "../../types/types"; + +const ignoredFieldUpdates = ["description", "attachment"]; + +export const handleIssueWebhook = async (payload: PlaneWebhookPayload) => { + const activities = await getActivities(payload); + + // Ideally we won't hit this case, but just in case + if (activities.length === 0) { + return; + } + + const details = await getConnectionDetailsForIssue(payload); + + if (!details) { + return; + } + + const { slackService, entityConnection } = details; + + const message = createSlackBlocksFromActivity(activities); + const entityData = entityConnection.entity_data as any; + + const channel = entityData.channel.id; + const messageTs = entityData.message.ts; + + if (message.length === 0) { + return; + } + + await slackService.sendThreadMessage(channel, messageTs, { + text: "Work Item Updated", + blocks: message, + }); +}; + +export const createSlackBlocksFromActivity = (fields: ActivityForSlack[]) => { + // Sort the fields so that array fields are at the end + fields.sort((a, b) => (a.isArrayField ? 1 : -1)); + + let message = "Work item updated šŸ”„\n"; + + // Filter out fields that should be ignored + fields = fields.filter((field) => !shouldIgnoreField(field.field, ignoredFieldUpdates)); + + if (fields.length === 0) { + return []; + } + + fields.forEach((field) => { + const capitalize = (str: string) => { + return str.charAt(0).toUpperCase() + str.slice(1); + }; + const cleanField = capitalize(field.field.replace("_", " ")); + + if (field.isArrayField) { + if (field.added.length > 0 || field.removed.length > 0) { + message += `\n• *${cleanField} updated:*`; + if (field.added.length > 0) { + message += `\n • Added _${field.added.join(", ")}_`; + } + if (field.removed.length > 0) { + message += `\n • Removed _${field.removed.join(", ")}_`; + } + } + } else { + message += `\n• *${cleanField}* updated to *${field.newValue}*`; + } + }); + + const blocks = [ + { + type: "section", + text: { + type: "mrkdwn", + text: message, + }, + }, + ]; + + return blocks; +}; + +export const getActivities = async (payload: PlaneWebhookPayload): Promise => { + const store = Store.getInstance(); + const key = `slack:issue:${payload.id}`; + const activityList = await store.getList(key); + + if (!activityList) { + return []; + } + + await store.del(key); + const activities = activityList.map((activity) => JSON.parse(activity) as PlaneActivityWithTimestamp); + + // Sort the activities by timestamp + activities.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + + /* + Group all the activities such that we can process each of them in a + single pass whether it's an array field or a singular field + */ + const fieldGroups = new Map(); + activities.forEach((activity) => { + if (!fieldGroups.has(activity.field)) { + fieldGroups.set(activity.field, []); + } + fieldGroups.get(activity.field)!.push(activity); + }); + + const latestActivities = new Map(); + + /* + For each field group, if it's an array field, we need to get the added and removed values + If it's a singular field, we just need to get the latest value + */ + for (const [field, fieldActivities] of fieldGroups.entries()) { + const isArrayField = field.endsWith("s"); + const actor = fieldActivities[0].actor.display_name; + + if (isArrayField) { + const { added, removed } = getArrayActivity(fieldActivities); + latestActivities.set(field, { + isArrayField: true, + field, + actor, + added, + removed, + }); + } else { + // For singular fields, use the last activity's new_value + const latestActivity = fieldActivities[fieldActivities.length - 1]; + latestActivities.set(field, { + isArrayField: false, + field, + actor, + newValue: latestActivity.new_value || "", + }); + } + } + + // Return the activities + return Array.from(latestActivities.values()); +}; + +export const getArrayActivity = (activities: PlaneActivityWithTimestamp[]) => { + /* + According to how plane shares the activity, if the old value is null and new values is something, something is added + If the old value is something and new value is null, something is removed, we just need to collect those values with + two arrays removed and added values + */ + + const added: string[] = []; + const removed: string[] = []; + + for (const activity of activities) { + if (activity.old_value === null && activity.new_value !== null) { + added.push(activity.new_value); + } else if (activity.old_value !== null && activity.new_value === null) { + removed.push(activity.old_value); + } + } + + return { added, removed }; +}; + +// Helper function to check if a field should be ignored +const shouldIgnoreField = (fieldName: string, ignoredFieldUpdates: string[]): boolean => { + // Check if any ignored field is a prefix of this field + // This handles cases like "description" also ignoring "description_html" + for (const ignoredField of ignoredFieldUpdates) { + if (fieldName === ignoredField || fieldName.startsWith(ignoredField + "_")) { + return true; + } + } + + return false; +}; diff --git a/silo/src/apps/slack/worker/plane-worker.ts b/silo/src/apps/slack/worker/plane-worker.ts index b4f1245f92..e88214af7d 100644 --- a/silo/src/apps/slack/worker/plane-worker.ts +++ b/silo/src/apps/slack/worker/plane-worker.ts @@ -1,8 +1,9 @@ import { MQ, Store } from "@/worker/base"; import { TaskHandler, TaskHeaders } from "@/types"; -import { PlaneWebhookData, WebhookIssueCommentPayload } from "@plane/sdk"; +import { PlaneWebhookPayload, WebhookIssueCommentPayload } from "@plane/sdk"; import { handleIssueCommentWebhook } from "./plane-webhook-handlers/handle-comment-webhook"; import { logger } from "@/logger"; +import { handleIssueWebhook } from "./plane-webhook-handlers/handle-issue-webhook"; export class PlaneSlackWebhookWorker extends TaskHandler { mq: MQ; @@ -13,9 +14,12 @@ export class PlaneSlackWebhookWorker extends TaskHandler { this.mq = mq; this.store = store; } - async handleTask(headers: TaskHeaders, data: PlaneWebhookData): Promise { + async handleTask(headers: TaskHeaders, data: any): Promise { try { switch (data.event) { + case "issue": + await handleIssueWebhook(data as PlaneWebhookPayload); + break case "issue_comment": await handleIssueCommentWebhook(data as WebhookIssueCommentPayload); break; diff --git a/silo/src/worker/base/store.ts b/silo/src/worker/base/store.ts index a348a6d3e9..90e6f6d1f2 100644 --- a/silo/src/worker/base/store.ts +++ b/silo/src/worker/base/store.ts @@ -115,6 +115,10 @@ export class Store extends EventEmitter { return await this.client.get(key); } + public async getList(key: string): Promise { + return await this.client.lRange(key, 0, -1); + } + public async set(key: string, value: string, ttl?: number, NX = true): Promise { this.jobs.push(key); @@ -127,6 +131,29 @@ export class Store extends EventEmitter { } } + public async setList(key: string, value: string, ttl?: number, NX = true): Promise { + try { + const exists = await this.client.exists(key) > 0; + if (NX && exists) return false; + + // Atomic operation: delete if exists, then push new items + const multi = this.client.multi(); + + // Push the value to the list + multi.rPush(key, value); + + // Set the expiration time + if (ttl) multi.expire(key, ttl); + + // Execute the transaction + await multi.exec(); + return true; + } catch (error) { + logger.error(`Error in setList for key ${key}:`, error); + return false; + } + } + public async del(key: string): Promise { const index = this.jobs.indexOf(key); if (index > -1) {