feat: silo importers and integrations (#1811)

* SIlo's GitHub and Gitlab Integrations, with Sync and Magic Words  (#1585)

* fix: silo service initial setup

* feat: moved controllers inside runner

* feat: created core definations for apps and context

* feat: added interfaces required for apps

* feat: moved shared definations

* feat: added logger as an injection for apps

* feat: changes to controllers

* feat: added vm based app execution inside manager

* feat: added dummy implementations for core constructs

* feat: moved controllers to routes

* feat: created worker moduler for processing message queue requests

* feat: added task manager defination

* feat: added context and manager separations

* feat: created defaults for the defination implementation

* feat: added async task management inside apps

* feat: removed unnecessary arguments in function calls

* feat: added context builder for app instances

* feat: added runtime type safety with zod

* feat: added packaged definition structure for silo apps

* feat: added package based configuration for engine and definitions

* feat: added custom eslint rule for throws decorator

* feat: added custom eslint rule for try catch

* feat: removed custom eslint config

* feat: added authentication controller inside engine

* feat: added auth in silo engine

* feat: migrated jira ui module to plane

* chore: removed jira app

* chore: moved engine components into src_engine directory

* feat: added base template inside new src directory

* feat: migrated worker interface with mq and redis store inside new src

* feat: created jira migrator and jira importer types

* chore: migrated worker's helper inside the base directory

* feat: added logic for booting up root worker

* feat: added jira and jira auth service inside jira app

* feat: added all transformers inside jira package

* feat: added authorization types for jira

* feat: added jira authentication oauth class

* feat: added jira transformer and pull mechanisms inside jira app

* feat: addded batching logic inside jira migrator

* feat: embedded silo sdk inside jira migrator

* feat: added plane migrator inside engine

* feat: added plane migrator for push inside migration controller

* feat: added controller methods for migration

* feat: added credentials and job routes in controller

* feat: added linear importer

* feat: added linear pull function as importer

* feat: added transformation for linear data

* feat: added pull mechanism for linear

* feat: attached linear data importer with migration controller

* fix: removed hardcoded jira from cycle and module migrator

* feat: fixed build errors

* chore: addeed example env

* feat: added authentication routes for jira

* feat: added linear route controller

* fix: restructuring

* fix: sdk configs setup

* fix: merge conflicts

* fix: sdk setup

* chore: added jira and linear importers and separate packages

* feat: moved transformation parts to linear package

* feat: decoupled jira logical parts with worker

* fix: linear silo app to use linear package

* fix: build errors and dependency resolution with packages in silo

* fix: module build errors in silo

* fix: linear authorization flow

* feat: added logic for segregated workers

* feat: attached task manager with the base starter

* feat: added migrations, query and schema into db directory

* feat: added linear importer and jira importer app structure

* feat: added silo core package

* chore: migrated worker and main engine controller inside apps

* fix: made linear integration working

* silo: added cors

* feat: added base64 state changes with jira

* chore: updated silo env

* fix: jira token cookie

* feat: added issue attachments in linear job

* feat: added credentials controller

* feat: added github package inside silo packages

* feat: added resource fetching in jira api service

* feat: added credentials locking

* feat: created resources endpoint for jira

* feat: added endpoints for getting jira data

* fix: credentials not working

* chore: exported jira types

* chore: added jira states

* fix: jira project pagination issue

* chore: initiated silo folder in web

* feat: added github routes and services in silo app

* feat: improvided auth service and added background worker for github

* feat: added task manager for github

* fix: build updates

* fix: updated plane sdk and updated jira importer

* chore: updated the importer layout

* chore: removed as any from table component

* chore: integrated importer dashboard

* fix: tsup fixes

* fix: removed unnecessary files

* fix: removing tsup for building silo packages

* fix: build related issues

* feat: modified project and issue sdk service to add more methods

* feat: added installation webhook hook handler

* feat: added issue comment handler

* feat: added issue events handler interface

* feat: added pull request interface handler

* feat: added github worker for handling bg tasks

* feat: attached manager into main controller

* fix: build issues

* fix: eslint fixes

* fix: silo build errors

* fix: silo app build errors types

* fix: reverting the cloud branch

* fix: updated package json in silo service

* fix: branch build cloud updated

* fix: build errors in apps while using sdk due to ts-alias paths

* fix: branch build cloud workflow fixes

* feat: added user mentions with github sync

* fix: docker compose setup updates

* fix: bot user comments with github

* feat: initialized github plane worker

* fix: docker compose build fixes

* fix: docker build fixes envs updated in example file

* feat: added plane github webhook worker

* chore: updates folder structure and handled job services

* chore: resolved build errors in silo chore

* fix: docker compose cloud added

* feat: added issue handler with labels

* fix: build process docker compose

* feat: added issue edit and comment edits in github sync

* chore: updated jira workflow

* feat: added plane webhook types

* feat: added plane webhook event handlers

* feat: added workspace connection schema

* chore: handled the job start and jira dashboard

* feat: added query for getting entity connection

* chore: updated constants, file naming convention

* chore: resolved merge conflicts

* feat: added worspace and entity connection tables

* feat: added workspace and entity connections for pr, comment as well

* fix: github import with usermapping

* feat: added plane issue synce to github

* chore: integrated linear and updated jobs query

* feat: added hostname changes

* conflict: updated jira config and added issue transformation count in dashboard

* conflict: updated job

* chore: updated Jira status

* chore: updated Jira status

* fix: batch processing

* fix: batch key release

* chore: updated workflow for building images

* fix: detached silo build

* chore: updates linear and resolved build errors

* fix: batch key missing

* fix: merge issues with Credential types

* fix: issues with github mereg

* fix: linear workflow

* chore: updated linear queries

* feat: updated plane bg tasks to send webhook on desc change

* feat: added plane transformations for github

* feat: added connection schema and comment handler inside github worker

* chore: modified dashboard labels

* fix: docker compose fixed for running silo services

* feat: added key based deduping for webhook events

* feat: added schema based tables

* feat: added redis based payload dispatch and dedup

* feat: added migrations and changed connection table schema

* feat: modified github etl for changed webhook process

* feat: implemented redis ttl based arch with github

* feat: implemented gitlab silo package

* feat: added github user authentication

* feat: modified linear dashboard and services

* feat: changed yarn lock

* feat: added user auth into comments as well

* feat: addded gitlab authentication service

* fix: state activity not being reconized by external apis

* feat: added gitlab package for apis and sdk

* feat: added gitlab merge request integration

* fix: code cleanup for gitlab

* feat: added title references to gitlab

* feat: added magic words in github integration

* chore: removed logging token response

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>

* fix: env variables

* fix: silo schema migrations

* fix: drizzle migrations

* fix: adding migrator script

* fix: migrator command update in migrator

* fix: migrator changes

* fix: wait db updated

* chore: silo imports integrations (#1610)

* fix: silo service initial setup

* feat: moved controllers inside runner

* feat: created core definations for apps and context

* feat: added interfaces required for apps

* feat: moved shared definations

* feat: added logger as an injection for apps

* feat: changes to controllers

* feat: added vm based app execution inside manager

* feat: added dummy implementations for core constructs

* feat: moved controllers to routes

* feat: created worker moduler for processing message queue requests

* feat: added task manager defination

* feat: added context and manager separations

* feat: created defaults for the defination implementation

* feat: added async task management inside apps

* feat: removed unnecessary arguments in function calls

* feat: added context builder for app instances

* feat: added runtime type safety with zod

* feat: added packaged definition structure for silo apps

* feat: added package based configuration for engine and definitions

* feat: added custom eslint rule for throws decorator

* feat: added custom eslint rule for try catch

* feat: removed custom eslint config

* feat: added authentication controller inside engine

* feat: added auth in silo engine

* feat: migrated jira ui module to plane

* chore: removed jira app

* chore: moved engine components into src_engine directory

* feat: added base template inside new src directory

* feat: migrated worker interface with mq and redis store inside new src

* feat: created jira migrator and jira importer types

* chore: migrated worker's helper inside the base directory

* feat: added logic for booting up root worker

* feat: added jira and jira auth service inside jira app

* feat: added all transformers inside jira package

* feat: added authorization types for jira

* feat: added jira authentication oauth class

* feat: added jira transformer and pull mechanisms inside jira app

* feat: addded batching logic inside jira migrator

* feat: embedded silo sdk inside jira migrator

* feat: added plane migrator inside engine

* feat: added plane migrator for push inside migration controller

* feat: added controller methods for migration

* feat: added credentials and job routes in controller

* feat: added linear importer

* feat: added linear pull function as importer

* feat: added transformation for linear data

* feat: added pull mechanism for linear

* feat: attached linear data importer with migration controller

* fix: removed hardcoded jira from cycle and module migrator

* feat: fixed build errors

* chore: addeed example env

* feat: added authentication routes for jira

* feat: added linear route controller

* fix: restructuring

* fix: sdk configs setup

* fix: merge conflicts

* fix: sdk setup

* chore: added jira and linear importers and separate packages

* feat: moved transformation parts to linear package

* feat: decoupled jira logical parts with worker

* fix: linear silo app to use linear package

* fix: build errors and dependency resolution with packages in silo

* fix: module build errors in silo

* fix: linear authorization flow

* feat: added logic for segregated workers

* feat: attached task manager with the base starter

* feat: added migrations, query and schema into db directory

* feat: added linear importer and jira importer app structure

* feat: added silo core package

* chore: migrated worker and main engine controller inside apps

* fix: made linear integration working

* silo: added cors

* feat: added base64 state changes with jira

* chore: updated silo env

* fix: jira token cookie

* feat: added issue attachments in linear job

* feat: added credentials controller

* feat: added github package inside silo packages

* feat: added resource fetching in jira api service

* feat: added credentials locking

* feat: created resources endpoint for jira

* feat: added endpoints for getting jira data

* fix: credentials not working

* chore: exported jira types

* chore: added jira states

* fix: jira project pagination issue

* chore: initiated silo folder in web

* feat: added github routes and services in silo app

* feat: improvided auth service and added background worker for github

* feat: added task manager for github

* fix: build updates

* fix: updated plane sdk and updated jira importer

* chore: updated the importer layout

* chore: removed as any from table component

* chore: integrated importer dashboard

* fix: tsup fixes

* fix: removed unnecessary files

* fix: removing tsup for building silo packages

* fix: build related issues

* feat: modified project and issue sdk service to add more methods

* feat: added installation webhook hook handler

* feat: added issue comment handler

* feat: added issue events handler interface

* feat: added pull request interface handler

* feat: added github worker for handling bg tasks

* feat: attached manager into main controller

* fix: build issues

* fix: eslint fixes

* fix: silo build errors

* fix: silo app build errors types

* fix: reverting the cloud branch

* fix: updated package json in silo service

* fix: branch build cloud updated

* fix: build errors in apps while using sdk due to ts-alias paths

* fix: branch build cloud workflow fixes

* feat: added user mentions with github sync

* fix: docker compose setup updates

* fix: bot user comments with github

* feat: initialized github plane worker

* fix: docker compose build fixes

* fix: docker build fixes envs updated in example file

* feat: added plane github webhook worker

* chore: updates folder structure and handled job services

* chore: resolved build errors in silo chore

* fix: docker compose cloud added

* feat: added issue handler with labels

* fix: build process docker compose

* feat: added issue edit and comment edits in github sync

* chore: updated jira workflow

* feat: added plane webhook types

* feat: added plane webhook event handlers

* feat: added workspace connection schema

* chore: handled the job start and jira dashboard

* feat: added query for getting entity connection

* chore: updated constants, file naming convention

* chore: resolved merge conflicts

* feat: added worspace and entity connection tables

* feat: added workspace and entity connections for pr, comment as well

* fix: github import with usermapping

* feat: added plane issue synce to github

* chore: integrated linear and updated jobs query

* feat: added hostname changes

* conflict: updated jira config and added issue transformation count in dashboard

* conflict: updated job

* chore: updated Jira status

* chore: updated Jira status

* fix: batch processing

* fix: batch key release

* chore: updated workflow for building images

* fix: detached silo build

* chore: updates linear and resolved build errors

* fix: batch key missing

* fix: merge issues with Credential types

* fix: issues with github mereg

* fix: linear workflow

* chore: updated linear queries

* feat: updated plane bg tasks to send webhook on desc change

* feat: added plane transformations for github

* feat: added connection schema and comment handler inside github worker

* chore: modified dashboard labels

* fix: docker compose fixed for running silo services

* feat: added key based deduping for webhook events

* feat: added schema based tables

* feat: added redis based payload dispatch and dedup

* feat: added migrations and changed connection table schema

* feat: modified github etl for changed webhook process

* feat: implemented redis ttl based arch with github

* feat: implemented gitlab silo package

* feat: added github user authentication

* feat: modified linear dashboard and services

* feat: changed yarn lock

* feat: added user auth into comments as well

* feat: addded gitlab authentication service

* fix: state activity not being reconized by external apis

* feat: added gitlab package for apis and sdk

* feat: added gitlab merge request integration

* fix: code cleanup for gitlab

* feat: added title references to gitlab

* feat: added magic words in github integration

* chore: removed logging token response

* feat: added slack authentication flow

* feat: added user and team level authentication for slack

* feat: added services for slack integration

* feat: added blockkit components inside slack interaction

* feat: modified slack service for addded thread ephemeral messages

* feat: added handlers and views

* feat: asana importer.
* fix: build errors.

* feat: added thread sync to slack

* feat: added changes with merge

* chore: handle asana token refresh.

* fix: builder for giving warning

* chore: remove console logs.

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: Henit Chobisa <chobisa.henit@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>

* fix: feature flags implementation

* fix: build errorsg

* fix: build errors

* chore: add missing silo packages.

* fix: updating package version for build issues in docker

* Added PAT based authentication for Jira Integration and Private bucket attachment uploads (#1642)

* feat: added pat authentication for jira service

* feat: added jira oauth and pat authentication strategy

* feat: added PAT auth for linear sdk

* feat: blocked routes for jira oauth

* fix: added OAuth Enabled key for authentication route

* feat: added PAT routes for linear and jira

* feat: added default value to job status

* feat: added credentials PAT filter

* feat: added credential fetch condition for PAT token

* chore: basic PAT token UI.

* fix: pat token implementation for jira

* chore: Jira personal access token form.

* fix: controller jira projects

* chore: handle migration steps without resourceId

* fix: jira project logo

* fix: migrator and attachment

* chore: update issue attachment external apis

* fix: attachments private bucket changes for silo

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>

* chore: Asana importer UI. (#1630)

* chore: UI improvements for Jira importer personal access token authentication (#1648)

* feat: added pat authentication for jira service

* feat: added jira oauth and pat authentication strategy

* feat: added PAT auth for linear sdk

* feat: blocked routes for jira oauth

* fix: added OAuth Enabled key for authentication route

* feat: added PAT routes for linear and jira

* feat: added default value to job status

* feat: added credentials PAT filter

* feat: added credential fetch condition for PAT token

* chore: basic PAT token UI.

* fix: pat token implementation for jira

* chore: Jira personal access token form.

* fix: controller jira projects

* chore: handle migration steps without resourceId

* fix: jira project logo

* fix: migrator and attachment

* chore: update issue attachment external apis

* fix: attachments private bucket changes for silo

* chore: minor UI improvements

* chore: ui improvement

* chore: fix importer list

---------

Co-authored-by: Henit Chobisa <chobisa.henit@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>

* fix: github package dependency with silo (#1622)

* fix/deps

* fix: axios

* chore: fix formatting.

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>

* Runway silo store (#1672)

* chore: silo store init

* chore: infra update for maintenance mode (#5963)

* [WEB-2728] improvement: add `true-transparent` variant for textarea. (#5960)

* fix: renaming and formatting

* chore: app sidebar behaviour (#5964)

* chore: infra update for maintenance mode (#1667)

* [WEB-2728] improvement: new UI for custom property settings. (#1663)

* chore: importer store integration for jira and resolved build error

---------

Co-authored-by: pushya22 <130810100+pushya22@users.noreply.github.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>

* Basic Jira issue type and custom properties migration (#1676)

* chore: issue type migration.

* chore: update issue types api for pagination

* chore: update jira migrator for issue types

* chore: issue type sdk types and service updates

* typo: issue service files name update

* typo: issue service files name update

* fix: issue type fetch and create request.

* fix: build

* fix: issue type migration

* chore: minor improvements

* chore: issue property fetch for jira

* chore: issue property fetch

* fix: type errors

* chore: temp fix for batches completion logic.

* chore: add issue type check

* chore: issue property basic migration.

* fix: updated base silo base url

* remove console log.

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>

* chore: add silo base url and silo base path in dockerfile

* chore: update in runway silo

* chore: custom field attributes structure.

* chore: helper for test settings config.

* chore: add logo props

* chore: custom field migrations

* chore: all simple properties migrations.

* chore: issue property options

* chore: update could not fetch feature

* chore: feature flag service in packages silo core

* chore: feature flagging

* chore: issue property values.

* [WEB-2742] chore: issue link ui revamp (#5971)

* chore-issue-link-ui

* chore: issue link ui revamp

* chore: upload image in the intake form (#1677)

* fix: editor user config (#1679)

* fix: editor user config (#5974)

* fix: pi chat bug fixes (#1670)

* chore: pi empty state

* fix: response change handled

* fix: handled api failure

* fix: formatted ai messages

* fix: reverted the pi pallete

* fix: integrated  models api

* fix: disabled regenerating new chat

* fix: page loading fixed

* fix: dynamically imported markdown

* fix: removed extras

* fix: minor css

* fix: handled pi chat in bulk ops

* [WEB-2706] fix: Add fallback when db initialisation fails (#5973)

* Add fallback when db initialization fails

* add checks for instance.exec

* chore: convert issue boolean fields to actual boolean value.

* change instance exec code

* sync issue to local db when inbox issue is accepted and draft issue is moved to project

* chore: added project and workspace keys

---------

Co-authored-by: rahulramesha <rahulramesham@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>

* fix related to activity (#5972)

* fix: renamed inbox to intake (#5967)

* feat: intake

* chore: intake model migration changes

* dev: update dummy data

* dev: add duplicate apis for inbox

* dev: fix external apis

* fix: external apis

* chore: migration file changes

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>

* chore: removed the validation in patch request (#1680)

* chore: changed inbox to intake (#1682)

* chore: fix custom fields option fetch logic.

* fix: renamed inbox to intake (#5967)

* feat: intake

* chore: intake model migration changes

* dev: update dummy data

* dev: add duplicate apis for inbox

* dev: fix external apis

* fix: external apis

* chore: migration file changes

---------

Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>

* fix: intake loading (#5966)

* fix: intake loading

* fix: image upload in space

* chore: silo feature flag service

* fix: jira issue pull logic

* chore: map user picker custom property using display names.

* fix: silo github package dependencies.

* fix: silo github package

* chore: add lucide icons mappings

* fix: removed constants

* fix: package.json dependency fix for silo

* chore: update batching logic to handle trees

* chore: take job hostname from .env

* fix: build issues

* chore: fix migrations

* chore: asana custom field import

* chore: remove unused values

* chore: update oauth enabled envs

* chore: fix builds

* chore: asana batching logic.

* chore: asana PAT auth endpoint

* chore: delete credential endpoint

* fix: silo jira importer

* chore: fix silo refresh token callback

* chore: update linear batching logic

* dev: jira store migration

* chore: fetching jobs

* chore: updating job store

* chore: build error in store

* chore: job store updates

* chore: returning the jira resource details

* chore: add pat token validation

* chore: updated URL in the silo

* chore: issues count

* chore: fix merge conflicts

* dev: resolved merge conflict

* chore: removed logs

* feat: added comment import with linear

* chore: build fixes

* chore: add missing status class

* chore: updated Jira

* chore: loader in summary

* chore: Asana importer improved UI with mobx store

* fix: jobs api

* chore: make dashboard view true

* chore: updating the jira dashboard toggler

* chore: minor ui improvements.

* chore: fix job order logic.

* chore: Linear integration

* fix: slack linkback workflow

* chore: update service token logic

* chore: deactivate credentials

* dev: updated linear states mapping

* chore: asana priority mapping.

* chore: updated linear config

* fix:images not loading when issue is imported

* chore: asana comments

* chore: asana attachements

* chore: linear attachements pull

* chore: deactivating the importers

* chore: minor bug fix in priority mapping

* chore: handled feature flag, reload jobs and dashboard load

* chore: handled verfication check in start and re-run job

* chore: code cleanup

* feat: added controller routes for disconnecting and getting current status

* chore: update silo to be a single service

* fix: credentials

* chore: handled map states and map priority on worksapce and project change of importers

* chore: handled loaders in importers

* chore: updating linear team details

* chore: update assets

* chore: replaced migration

* fix: condition for credential availability

* chore: slack integration

* chore: handled modal confirmation and linear issue count in summary

* chore: github UI (#1721)

* chore: typos integrations and importers

* chore: github integration base

* fix: github integration context updates

* chore: resolved build errors

* fix: issue migrator to handle linear image component

* fix: issue serializer fixes

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
Co-authored-by: pablohashescobar <nikhilschacko@gmail.com>
Co-authored-by: pushya22 <130810100+pushya22@users.noreply.github.com>
Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com>
Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com>
Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Co-authored-by: Akshita Goyal <36129505+gakshita@users.noreply.github.com>
Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
Co-authored-by: rahulramesha <rahulramesham@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: rahulramesha <71900764+rahulramesha@users.noreply.github.com>
This commit is contained in:
Henit Chobisa
2024-11-23 23:38:31 +05:30
committed by GitHub
co-authored by sriram veeraghanta gurusainath Prateek Shourya pablohashescobar pushya22 Anmol Singh Bhatia Bavisetti Narayan Aaryan Khandelwal Akshita Goyal Satish Gandham rahulramesha NarayanBavisetti rahulramesha
parent a8eb472a53
commit dbe330e304
452 changed files with 24670 additions and 1786 deletions
+1 -1
View File
@@ -422,7 +422,7 @@ jobs:
uses: actions/checkout@v4
- name: Generate Keypair
run: |
if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ env.BUILD_TYPE }}" == "Release" ]; then
if [ "${{ env.TARGET_BRANCH }}" == "master" ] || [ "${{ env.BUILD_TYPE }}" == "Release" ]; then
openssl genrsa -out private_key.pem 2048
else
echo "${{ secrets.DEFAULT_PRIME_PRIVATE_KEY }}" > private_key.pem
+2 -2
View File
@@ -55,7 +55,7 @@ class IssueSerializer(BaseSerializer):
class Meta:
model = Issue
read_only_fields = ["id", "workspace", "project", "updated_by", "updated_at"]
exclude = ["description", "description_stripped"]
exclude = ["description"]
def validate(self, data):
if (
@@ -358,7 +358,7 @@ class IssueCommentSerializer(BaseSerializer):
"created_at",
"updated_at",
]
exclude = ["comment_stripped", "comment_json"]
exclude = ["comment_json"]
def validate(self, data):
try:
+5
View File
@@ -71,4 +71,9 @@ urlpatterns = [
IssueAttachmentEndpoint.as_view(),
name="attachment",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/issue-attachments/<uuid:pk>/",
IssueAttachmentEndpoint.as_view(),
name="attachment",
),
]
+189 -44
View File
@@ -1,9 +1,10 @@
# Python imports
import json
from django.core.serializers.json import DjangoJSONEncoder
import uuid
# Django imports
from django.http import HttpResponseRedirect
from django.core.serializers.json import DjangoJSONEncoder
from django.db import IntegrityError
from django.db.models import (
Case,
@@ -19,11 +20,11 @@ from django.db.models import (
Subquery,
)
from django.utils import timezone
from django.conf import settings
# Third party imports
from rest_framework import status
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser
# Module imports
from plane.api.serializers import (
@@ -50,9 +51,13 @@ from plane.db.models import (
Project,
ProjectMember,
CycleIssue,
Workspace,
)
from plane.settings.storage import S3Storage
from .base import BaseAPIView
from plane.payment.flags.flag_decorator import check_workspace_feature_flag
from plane.payment.flags.flag import FeatureFlag
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
class WorkspaceIssueAPIEndpoint(BaseAPIView):
@@ -773,12 +778,39 @@ class IssueCommentAPIEndpoint(BaseAPIView):
)
def get(self, request, slug, project_id, issue_id, pk=None):
external_id = request.GET.get("external_id")
external_source = request.GET.get("external_source")
if external_id and external_source:
try:
issue_comment = IssueComment.objects.get(
external_id=external_id,
external_source=external_source,
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
)
serializer = IssueCommentSerializer(
issue_comment, fields=self.fields, expand=self.expand
)
return Response(serializer.data, status=status.HTTP_200_OK)
except IssueComment.DoesNotExist:
return Response(
{"error": "Comment not found"}, status=status.HTTP_404_NOT_FOUND
)
if pk:
issue_comment = self.get_queryset().get(pk=pk)
serializer = IssueCommentSerializer(
issue_comment, fields=self.fields, expand=self.expand
)
return Response(serializer.data, status=status.HTTP_200_OK)
try:
issue_comment = self.get_queryset().get(pk=pk)
serializer = IssueCommentSerializer(
issue_comment, fields=self.fields, expand=self.expand
)
return Response(serializer.data, status=status.HTTP_200_OK)
except IssueComment.DoesNotExist:
return Response(
{"error": "Comment not found"}, status=status.HTTP_404_NOT_FOUND
)
return self.paginate(
request=request,
queryset=(self.get_queryset()),
@@ -824,7 +856,8 @@ class IssueCommentAPIEndpoint(BaseAPIView):
issue_comment.created_by_id = request.data.get(
"created_by", request.user.id
)
issue_comment.save(update_fields=["created_at", "created_by"])
issue_comment.actor_id = request.data.get("created_by", request.user.id)
issue_comment.save(update_fields=["created_at", "created_by", "actor"])
issue_activity.delay(
type="comment.activity.created",
@@ -940,37 +973,166 @@ class IssueAttachmentEndpoint(BaseAPIView):
serializer_class = IssueAttachmentSerializer
permission_classes = [ProjectEntityPermission]
model = FileAsset
parser_classes = (MultiPartParser, FormParser)
def post(self, request, slug, project_id, issue_id):
serializer = IssueAttachmentSerializer(data=request.data)
name = request.data.get("name")
type = request.data.get("type", False)
size = request.data.get("size")
external_id = request.data.get("external_id")
external_source = request.data.get("external_source")
# Check if the request is valid
if not name or not size:
return Response(
{"error": "Invalid request.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if the file size is greater than the limit
if check_workspace_feature_flag(
feature_key=FeatureFlag.FILE_SIZE_LIMIT_PRO,
slug=slug,
user_id=str(request.user.id),
):
size_limit = min(size, settings.PRO_FILE_SIZE_LIMIT)
else:
size_limit = min(size, settings.FILE_SIZE_LIMIT)
if not type or type not in settings.ATTACHMENT_MIME_TYPES:
return Response(
{"error": "Invalid file type.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
# Get the workspace
workspace = Workspace.objects.get(slug=slug)
# asset key
asset_key = f"{workspace.id}/{uuid.uuid4().hex}-{name}"
if (
request.data.get("external_id")
and request.data.get("external_source")
and FileAsset.objects.filter(
project_id=project_id,
workspace__slug=slug,
issue_id=issue_id,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
issue_id=issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
).exists()
):
issue_attachment = FileAsset.objects.filter(
workspace__slug=slug,
asset = FileAsset.objects.filter(
project_id=project_id,
external_id=request.data.get("external_id"),
workspace__slug=slug,
external_source=request.data.get("external_source"),
external_id=request.data.get("external_id"),
issue_id=issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
).first()
return Response(
{
"error": "Issue attachment with the same external id and external source already exists",
"id": str(issue_attachment.id),
"error": "Issue with the same external id and external source already exists",
"id": str(asset.id),
},
status=status.HTTP_409_CONFLICT,
)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
# Create a File Asset
asset = FileAsset.objects.create(
attributes={"name": name, "type": type, "size": size_limit},
asset=asset_key,
size=size_limit,
workspace_id=workspace.id,
created_by=request.user,
issue_id=issue_id,
project_id=project_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
external_id=external_id,
external_source=external_source,
)
# Get the presigned URL
storage = S3Storage(request=request)
# Generate a presigned URL to share an S3 object
presigned_url = storage.generate_presigned_post(
object_name=asset_key, file_type=type, file_size=size_limit
)
# Return the presigned URL
return Response(
{
"upload_data": presigned_url,
"asset_id": str(asset.id),
"attachment": IssueAttachmentSerializer(asset).data,
"asset_url": asset.asset_url,
},
status=status.HTTP_200_OK,
)
def delete(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
)
issue_attachment.is_deleted = True
issue_attachment.deleted_at = timezone.now()
issue_attachment.save()
issue_activity.delay(
type="attachment.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(issue_id),
project_id=str(project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(status=status.HTTP_204_NO_CONTENT)
def get(self, request, slug, project_id, issue_id, pk=None):
if pk:
# Get the asset
asset = FileAsset.objects.get(
id=pk, workspace__slug=slug, project_id=project_id
)
# Check if the asset is uploaded
if not asset.is_uploaded:
return Response(
{"error": "The asset is not uploaded.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
storage = S3Storage(request=request)
presigned_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition="attachment",
filename=asset.attributes.get("name"),
)
return HttpResponseRedirect(presigned_url)
# Get all the attachments
issue_attachments = FileAsset.objects.filter(
issue_id=issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
workspace__slug=slug,
project_id=project_id,
is_uploaded=True,
)
# Serialize the attachments
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
def patch(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
)
serializer = IssueAttachmentSerializer(issue_attachment)
# Send this activity only if the attachment is not uploaded before
if not issue_attachment.is_uploaded:
issue_activity.delay(
type="attachment.activity.created",
requested_data=None,
@@ -982,30 +1144,13 @@ class IssueAttachmentEndpoint(BaseAPIView):
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(pk=pk)
issue_attachment.asset.delete(save=False)
issue_attachment.delete()
issue_activity.delay(
type="attachment.activity.deleted",
requested_data=None,
actor_id=str(self.request.user.id),
issue_id=str(self.kwargs.get("issue_id", None)),
project_id=str(self.kwargs.get("project_id", None)),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
# Update the attachment
issue_attachment.is_uploaded = True
issue_attachment.created_by = request.user
# Get the storage metadata
if not issue_attachment.storage_metadata:
get_asset_object_metadata.delay(str(issue_attachment.id))
issue_attachment.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def get(self, request, slug, project_id, issue_id):
issue_attachments = FileAsset.objects.filter(
issue_id=issue_id, workspace__slug=slug, project_id=project_id
)
serializer = IssueAttachmentSerializer(issue_attachments, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
+89 -34
View File
@@ -1,3 +1,6 @@
# Python imports
import random
# Django imports
from django.db.models import OuterRef, Subquery
from django.db.models.functions import Coalesce
@@ -26,6 +29,41 @@ class IssueTypeAPIEndpoint(BaseAPIView):
"""
logo_icons = [
"Activity",
"AlertCircle",
"Archive",
"Bell",
"Calendar",
"Camera",
"Check",
"Clock",
"Code",
"Database",
"Download",
"Edit",
"File",
"Folder",
"Globe",
"Heart",
"Home",
"Mail",
"Search",
"User",
]
logo_backgrounds = [
"#EF5974",
"#FF7474",
"#FC964D",
"#1FA191",
"#6DBCF5",
"#748AFF",
"#4C49F8",
"#5D407A",
"#999AA0",
]
model = IssueType
serializer_class = IssueTypeAPISerializer
permission_classes = [ProjectEntityPermission]
@@ -43,43 +81,59 @@ class IssueTypeAPIEndpoint(BaseAPIView):
def type_id(self):
return self.kwargs.get("type_id", None)
def generate_logo_prop(self):
return {
"in_use": "icon",
"icon": {
"name": self.logo_icons[
random.randint(0, len(self.logo_icons) - 1)
],
"background_color": self.logo_backgrounds[
random.randint(0, len(self.logo_backgrounds) - 1)
],
},
}
# list issue types and get issue type by id
@check_feature_flag(FeatureFlag.ISSUE_TYPE_DISPLAY)
def get(self, request, slug, project_id, type_id=None):
if self.workspace_slug and self.project_id:
# list of issue types
if self.type_id is None:
issue_types = self.model.objects.filter(
workspace__slug=self.workspace_slug,
project_issue_types__project_id=self.project_id,
).annotate(
project_ids=Coalesce(
Subquery(
ProjectIssueType.objects.filter(
issue_type=OuterRef("pk"), workspace__slug=slug
)
.values("issue_type")
.annotate(
project_ids=ArrayAgg(
"project_id", distinct=True
)
)
.values("project_ids")
),
[],
)
)
serializer = self.serializer_class(issue_types, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)
# getting issue type by id
issue_type = self.model.objects.get(
# list of issue types
if self.type_id is None:
issue_types = self.model.objects.filter(
workspace__slug=self.workspace_slug,
project_issue_types__project_id=self.project_id,
pk=self.type_id,
).annotate(
project_ids=Coalesce(
Subquery(
ProjectIssueType.objects.filter(
issue_type=OuterRef("pk"), workspace__slug=slug
)
.values("issue_type")
.annotate(
project_ids=ArrayAgg("project_id", distinct=True)
)
.values("project_ids")
),
[],
)
)
serializer = self.serializer_class(issue_type)
return Response(serializer.data, status=status.HTTP_200_OK)
return self.paginate(
request=request,
queryset=(issue_types),
on_results=lambda issues: IssueTypeAPISerializer(
issues,
many=True,
).data,
)
# getting issue type by id
issue_type = self.model.objects.get(
workspace__slug=self.workspace_slug,
project_issue_types__project_id=self.project_id,
pk=self.type_id,
)
serializer = self.serializer_class(issue_type)
return Response(serializer.data, status=status.HTTP_200_OK)
# create issue type
@check_feature_flag(FeatureFlag.ISSUE_TYPE_SETTINGS)
@@ -116,10 +170,11 @@ class IssueTypeAPIEndpoint(BaseAPIView):
)
# creating issue type
data = request.data
issue_type_serializer = self.serializer_class(data=data)
issue_type_serializer = self.serializer_class(data=request.data)
issue_type_serializer.is_valid(raise_exception=True)
issue_type_serializer.save(workspace=workspace)
issue_type_serializer.save(
workspace=workspace, logo_props=self.generate_logo_prop()
)
# adding the issue type to the project
project_issue_type_serializer = ProjectIssueTypeAPISerializer(
+10 -1
View File
@@ -1,5 +1,9 @@
# Python import
from uuid import uuid4
from datetime import timedelta
# Django imports
from django.utils import timezone
# Third party
from rest_framework.response import Response
@@ -74,7 +78,11 @@ class ServiceApiTokenEndpoint(BaseAPIView):
workspace = Workspace.objects.get(slug=slug)
api_token = APIToken.objects.filter(
workspace=workspace, is_service=True, user=request.user
workspace=workspace,
is_service=True,
user=request.user,
expired_at__isnull=False,
expired_at__gt=timezone.now(),
).first()
if api_token:
@@ -90,6 +98,7 @@ class ServiceApiTokenEndpoint(BaseAPIView):
workspace=workspace,
user_type=user_type,
is_service=True,
expired_at=(timezone.now() + timedelta(days=7)),
)
return Response(
{"token": str(api_token.token)}, status=status.HTTP_201_CREATED
@@ -29,6 +29,7 @@ from plane.db.models import (
EstimatePoint,
IssueType,
)
from plane.db.models.workspace import Workspace
from plane.settings.redis import redis_instance
from plane.utils.exception_logger import log_exception
from plane.bgtasks.webhook_task import webhook_activity
@@ -89,6 +90,27 @@ def track_description(
):
last_activity.created_at = timezone.now()
last_activity.save(update_fields=["created_at"])
# Get the current origin from redis
ri = redis_instance()
origin = ri.get(str(issue_id))
workspace = Workspace.objects.get(pk=workspace_id)
if workspace:
webhook_activity.delay(
event="issue",
event_id=issue_id,
verb="updated",
field="description",
old_value=current_instance.get("description_html"),
new_value=requested_data.get("description_html"),
current_site=origin,
actor_id=actor_id,
slug=workspace.slug,
old_identifier=last_activity.old_identifier,
new_identifier=last_activity.new_identifier,
)
else:
issue_activities.append(
IssueActivity(
@@ -194,9 +216,12 @@ def track_state(
issue_activities,
epoch,
):
if current_instance.get("state_id") != requested_data.get("state_id"):
new_state = State.objects.get(pk=requested_data.get("state_id", None))
old_state = State.objects.get(pk=current_instance.get("state_id", None))
current_state_id = current_instance.get("state_id") or current_instance.get("state")
requested_state_id = requested_data.get("state_id") or requested_data.get("state")
if current_state_id != requested_state_id:
new_state = State.objects.get(pk=requested_state_id)
old_state = State.objects.get(pk=current_state_id)
issue_activities.append(
IssueActivity(
@@ -566,21 +591,16 @@ def track_type(
issue_activities,
epoch,
):
new_type_id = requested_data.get("type_id")
old_type_id = current_instance.get("type_id")
if new_type_id != old_type_id:
verb = "updated" if new_type_id else "deleted"
old_type_id = (
IssueType.objects.filter(pk=old_type_id).first()
if old_type_id
else None
IssueType.objects.filter(pk=old_type_id).first() if old_type_id else None
)
new_type_id = (
IssueType.objects.filter(pk=new_type_id).first()
if new_type_id
else None
IssueType.objects.filter(pk=new_type_id).first() if new_type_id else None
)
issue_activities.append(
@@ -653,6 +673,7 @@ def update_issue_activity(
"parent_id": track_parent,
"priority": track_priority,
"state_id": track_state,
"state": track_state,
"description_html": track_description,
"target_date": track_target_date,
"start_date": track_start_date,
@@ -18,7 +18,6 @@ class IssuePropertyAPISerializer(BaseSerializer):
"name",
"logo_props",
"sort_order",
"settings",
"issue_type",
"deleted_at",
"created_at",
@@ -5,7 +5,7 @@ from rest_framework.response import Response
# Module imports
from plane.app.permissions import ProjectEntityPermission
from plane.db.models import Workspace, Project, IssueType
from plane.ee.models import IssueProperty
from plane.ee.models import IssueProperty, PropertyTypeEnum, RelationTypeEnum
from plane.ee.serializers.api import IssuePropertyAPISerializer
from plane.api.views.base import BaseAPIView
from plane.payment.flags.flag_decorator import check_feature_flag
@@ -40,6 +40,59 @@ class IssuePropertyAPIEndpoint(BaseAPIView):
def property_id(self):
return self.kwargs.get("property_id", None)
type_logo_props = {
PropertyTypeEnum.TEXT: {
"in_use": "icon",
"icon": {
"name": "AlignLeft",
"color": "#6d7b8a",
},
},
PropertyTypeEnum.DECIMAL: {
"in_use": "icon",
"icon": {
"name": "Hash",
"color": "#6d7b8a",
},
},
PropertyTypeEnum.OPTION: {
"in_use": "icon",
"icon": {
"name": "CircleChevronDown",
"color": "#6d7b8a",
},
},
PropertyTypeEnum.BOOLEAN: {
"in_use": "icon",
"icon": {
"name": "ToggleLeft",
"color": "#6d7b8a",
},
},
PropertyTypeEnum.DATETIME: {
"in_use": "icon",
"icon": {
"name": "Calendar",
"color": "#6d7b8a",
},
},
f"{PropertyTypeEnum.RELATION}_{RelationTypeEnum.USER}": {
"in_use": "icon",
"icon": {
"name": "UsersRound",
"color": "#6d7b8a",
},
},
}
def get_logo_props(self, property_type, relation_type=None):
"""Get logo properties for issue property"""
if property_type == PropertyTypeEnum.RELATION:
return self.type_logo_props.get(
f"{PropertyTypeEnum.RELATION}_{relation_type}"
)
return self.type_logo_props.get(property_type)
# list issue properties and get issue property by id
@check_feature_flag(FeatureFlag.ISSUE_TYPE_SETTINGS)
def get(self, request, slug, project_id, type_id, property_id=None):
@@ -105,7 +158,12 @@ class IssuePropertyAPIEndpoint(BaseAPIView):
issue_property_serializer = self.serializer_class(data=data)
issue_property_serializer.is_valid(raise_exception=True)
issue_property_serializer.save(
workspace=workspace, project=project, issue_type=issue_type
workspace=workspace,
project=project,
issue_type=issue_type,
logo_props=self.get_logo_props(
data.get("property_type"), data.get("relation_type")
),
)
# getting the issue property
@@ -154,6 +154,10 @@ class IssuePropertyOptionAPIEndpoint(BaseAPIView):
return Response(
serializer.data, status=status.HTTP_201_CREATED
)
return Response(
{"error": "Issue Property type is not OPTION"},
status=status.HTTP_400_BAD_REQUEST,
)
# update issue property option by id
@check_feature_flag(FeatureFlag.ISSUE_TYPE_SETTINGS)
+1
View File
@@ -442,6 +442,7 @@ ATTACHMENT_MIME_TYPES = [
"text/xml",
"text/csv",
"application/xml",
"application/x-gzip",
]
# Prime Server Base url
PRIME_SERVER_BASE_URL = os.environ.get("PRIME_SERVER_BASE_URL", False)
+2
View File
@@ -12,6 +12,8 @@ from storages.backends.s3boto3 import S3Boto3Storage
class S3Storage(S3Boto3Storage):
file_overwrite = True
def url(self, name, parameters=None, expire=None, http_method=None):
return name
+17
View File
@@ -0,0 +1,17 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
// PI Base Url
export const PI_BASE_URL = process.env.NEXT_PUBLIC_PI_BASE_URL || "";
// God Mode Admin App Base Url
export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}/`);
// Publish App Base Url
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
// Live App Base Url
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
export const LIVE_URL = `${LIVE_BASE_URL}${LIVE_BASE_PATH}`;
// Silo Base Url
export const SILO_BASE_URL = process.env.NEXT_PUBLIC_SILO_BASE_URL || "";
export const SILO_BASE_PATH = process.env.NEXT_PUBLIC_SILO_BASE_PATH || "";
+1 -1
View File
@@ -1,4 +1,4 @@
export * from "./auth";
export * from "./endpoints";
export * from "./issue";
export * from "./payment";
export * from "./silo"
+16
View File
@@ -6,6 +6,12 @@ import { ModuleService } from "@/services/module.service";
import { ProjectService } from "@/services/project.service";
import { StateService } from "@/services/state.service";
import { UserService } from "@/services/user.service";
import {
IssueTypeService,
IssuePropertyService,
IssuePropertyOptionService,
IssuePropertyValueService,
} from "@/services/issue-types";
// types
import { ClientOptions } from "@/types/types";
@@ -19,6 +25,11 @@ export class Client {
modules: ModuleService;
project: ProjectService;
issueComment: IssueCommentService;
// issue types
issueType: IssueTypeService;
issueProperty: IssuePropertyService;
issuePropertyOption: IssuePropertyOptionService;
issuePropertyValue: IssuePropertyValueService;
constructor(options: ClientOptions) {
this.options = options;
@@ -30,5 +41,10 @@ export class Client {
this.issueComment = new IssueCommentService(options);
this.cycles = new CycleService(options);
this.modules = new ModuleService(options);
// issue types
this.issueType = new IssueTypeService(options);
this.issueProperty = new IssuePropertyService(options);
this.issuePropertyOption = new IssuePropertyOptionService(options);
this.issuePropertyValue = new IssuePropertyValueService(options);
}
}
@@ -1,4 +1,4 @@
import { APIService } from "@/services/api.service";
import { APIService } from '@/services/api.service';
// types
import {
ClientOptions,
@@ -7,7 +7,7 @@ import {
ExIssueLabel,
Optional,
Paginated,
} from "@/types/types";
} from '@/types/types';
export class IssueCommentService extends APIService {
constructor(options: ClientOptions) {
@@ -17,10 +17,10 @@ export class IssueCommentService extends APIService {
async list(
slug: string,
projectId: string,
issueId: string
issueId: string,
): Promise<Paginated<ExIssueLabel>> {
return this.get(
`/api/v1/workspaces/${slug}/projects/${projectId}/issues/${issueId}/comments/`
`/api/v1/workspaces/${slug}/projects/${projectId}/issues/${issueId}/comments/`,
)
.then((response) => response.data)
.catch((error) => {
@@ -32,11 +32,11 @@ export class IssueCommentService extends APIService {
slug: string,
projectId: string,
issueId: string,
payload: Omit<Optional<ExIssueComment>, ExcludedProps>
payload: Omit<Optional<ExIssueComment>, ExcludedProps>,
) {
return this.post(
`/api/v1/workspaces/${slug}/projects/${projectId}/issues/${issueId}/comments/`,
payload
payload,
)
.then((response) => response.data)
.catch((error) => {
@@ -44,16 +44,47 @@ export class IssueCommentService extends APIService {
});
}
async getComment(
workspaceSlug: string,
projectId: string,
issueId: string,
commentId: string
): Promise<ExIssueComment> {
return this.get(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/comments/${commentId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getIssueCommentWithExternalId(
workspaceSlug: string,
projectId: string,
issueId: string,
externalId: string,
externalSource: string
): Promise<ExIssueComment> {
return this.get(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/comments/?external_id=${externalId}&external_source=${externalSource}`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async update(
slug: string,
projectId: string,
issueId: string,
commentId: string,
payload: Omit<Optional<ExIssueComment>, ExcludedProps>
payload: Omit<Optional<ExIssueComment>, ExcludedProps>,
) {
return this.patch(
`/api/v1/workspaces/${slug}/projects/${projectId}/issues/${issueId}/comments/${commentId}/`,
payload
payload,
)
.then((response) => response.data)
.catch((error) => {
@@ -65,10 +96,10 @@ export class IssueCommentService extends APIService {
slug: string,
projectId: string,
issueId: string,
commentId: string
commentId: string,
) {
return this.delete(
`/api/v1/workspaces/${slug}/projects/${projectId}/issues/${issueId}/comments/${commentId}/`
`/api/v1/workspaces/${slug}/projects/${projectId}/issues/${issueId}/comments/${commentId}/`,
)
.then((response) => response.data)
.catch((error) => {
@@ -0,0 +1,4 @@
export * from "./issue-property-options.service";
export * from "./issue-property-values.service";
export * from "./issue-properties.service";
export * from "./issue-types.service";
@@ -0,0 +1,86 @@
import { APIService } from "@/services/api.service";
// types
import { ClientOptions, ExIssueProperty } from "@/types";
export class IssuePropertyService extends APIService {
constructor(options: ClientOptions) {
super(options);
}
async fetch(
workspaceSlug: string,
projectId: string,
typeId: string
): Promise<ExIssueProperty[]> {
return this.get(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issue-types/${typeId}/issue-properties/`
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
async fetchById(
workspaceSlug: string,
projectId: string,
typeId: string,
propertyId: string
): Promise<ExIssueProperty> {
return this.get(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issue-types/${typeId}/issue-properties/${propertyId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
async create(
workspaceSlug: string,
projectId: string,
typeId: string,
data: Partial<ExIssueProperty>
): Promise<ExIssueProperty> {
return this.post(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issue-types/${typeId}/issue-properties/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
async update(
workspaceSlug: string,
projectId: string,
typeId: string,
propertyId: string,
data: Partial<ExIssueProperty>
): Promise<ExIssueProperty> {
return this.patch(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issue-types/${typeId}/issue-properties/${propertyId}/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
async remove(
workspaceSlug: string,
projectId: string,
typeId: string,
propertyId: string
): Promise<void> {
return this.delete(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issue-types/${typeId}/issue-properties/${propertyId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
}
@@ -0,0 +1,86 @@
import { APIService } from "@/services/api.service";
// types
import { ClientOptions, ExIssuePropertyOption } from "@/types";
export class IssuePropertyOptionService extends APIService {
constructor(options: ClientOptions) {
super(options);
}
async fetch(
workspaceSlug: string,
projectId: string,
propertyId: string
): Promise<ExIssuePropertyOption[]> {
return this.get(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issue-properties/${propertyId}/options/`
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
async fetchById(
workspaceSlug: string,
projectId: string,
propertyId: string,
optionId: string
): Promise<ExIssuePropertyOption> {
return this.get(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issue-properties/${propertyId}/options/${optionId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
async create(
workspaceSlug: string,
projectId: string,
propertyId: string,
data: Partial<ExIssuePropertyOption>
): Promise<ExIssuePropertyOption> {
return this.post(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issue-properties/${propertyId}/options/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
async update(
workspaceSlug: string,
projectId: string,
propertyId: string,
optionId: string,
data: Partial<ExIssuePropertyOption>
): Promise<ExIssuePropertyOption> {
return this.patch(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issue-properties/${propertyId}/options/${optionId}/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
async remove(
workspaceSlug: string,
projectId: string,
propertyId: string,
optionId: string
): Promise<void> {
return this.delete(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issue-properties/${propertyId}/options/${optionId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
}
@@ -0,0 +1,41 @@
import { APIService } from "@/services/api.service";
// types
import { ClientOptions, ExIssuePropertyValue } from "@/types";
export class IssuePropertyValueService extends APIService {
constructor(options: ClientOptions) {
super(options);
}
async fetch(
workspaceSlug: string,
projectId: string,
issueId: string,
propertyId: string
): Promise<ExIssuePropertyValue> {
return this.get(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-properties/${propertyId}/values/`
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
async create(
workspaceSlug: string,
projectId: string,
issueId: string,
propertyId: string,
data: { values: ExIssuePropertyValue }
): Promise<ExIssuePropertyValue> {
return this.post(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-properties/${propertyId}/values/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
}
@@ -0,0 +1,81 @@
import { APIService } from "@/services/api.service";
// types
import { ClientOptions, ExIssueType } from "@/types";
export class IssueTypeService extends APIService {
constructor(options: ClientOptions) {
super(options);
}
async fetch(
workspaceSlug: string,
projectId: string
): Promise<ExIssueType[]> {
return this.get(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issue-types/`
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
async fetchById(
workspaceSlug: string,
projectId: string,
typeId: string
): Promise<ExIssueType> {
return this.get(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issue-types/`
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
async create(
workspaceSlug: string,
projectId: string,
data: Partial<ExIssueType>
): Promise<ExIssueType> {
return this.post(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issue-types/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
async update(
workspaceSlug: string,
projectId: string,
typeId: string,
data: Partial<ExIssueType>
): Promise<ExIssueType> {
return this.patch(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issue-types/${typeId}/`,
data
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
async remove(
workspaceSlug: string,
projectId: string,
typeId: string
): Promise<void> {
return this.delete(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issue-types/${typeId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error;
});
}
}
+60 -4
View File
@@ -1,6 +1,7 @@
import { APIService } from "@/services/api.service";
// types
import {
AttachmentResponse,
ClientOptions,
ExcludedProps,
ExIssue,
@@ -14,6 +15,20 @@ export class IssueService extends APIService {
super(options);
}
async getIssueByIdentifier(
workspaceSlug: string,
projectIdentifier: string,
issueSequence: number
): Promise<ExIssue> {
return this.get(
`/api/v1/workspaces/${workspaceSlug}/issues/${projectIdentifier}-${issueSequence.toString()}/`
)
.then((response) => response.data)
.catch((error) => {
throw error?.response?.data;
});
}
async list(slug: string, projectId: string): Promise<Paginated<ExIssue>> {
return this.get(`/api/v1/workspaces/${slug}/projects/${projectId}/issues/`)
.then((response) => response.data)
@@ -83,15 +98,25 @@ export class IssueService extends APIService {
});
}
async uploadIssueAttachment(
async getIssueAttachmentUrl(
workspaceSlug: string,
projectId: string,
issueId: string,
file: FormData
): Promise<ExIssueAttachment> {
name: string,
size: number,
type: string,
external_source: string,
external_id: string
): Promise<AttachmentResponse> {
return this.post(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-attachments/`,
file
{
name,
size,
type,
external_source,
external_id,
}
)
.then((response) => response?.data)
.catch((error) => {
@@ -99,6 +124,23 @@ export class IssueService extends APIService {
});
}
async updateIssueAttachment(
workspaceSlug: string,
projectId: string,
issueId: string,
attachmentId: string,
payload: Omit<Optional<ExIssueAttachment>, ExcludedProps>
) {
return this.patch(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-attachments/${attachmentId}/`,
payload
)
.then((response) => response.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getIssueWithExternalId(
workspaceSlug: string,
projectId: string,
@@ -114,6 +156,20 @@ export class IssueService extends APIService {
});
}
async getIssue(
workspaceSlug: string,
projectId: string,
issueId: string
): Promise<ExIssue> {
return this.get(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/`
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getIssueAttachments(
workspaceSlug: string,
projectId: string,
+6 -6
View File
@@ -15,7 +15,7 @@ export class LabelService extends APIService {
async list(
slug: string,
projectId: string
projectId: string,
): Promise<Paginated<ExIssueLabel>> {
return this.get(`/api/v1/workspaces/${slug}/projects/${projectId}/labels/`)
.then((response) => response.data)
@@ -27,11 +27,11 @@ export class LabelService extends APIService {
async create(
slug: string,
projectId: string,
payload: Omit<Optional<ExIssueLabel>, ExcludedProps>
payload: Omit<Optional<ExIssueLabel>, ExcludedProps>,
): Promise<ExIssueLabel> {
return this.post(
`/api/v1/workspaces/${slug}/projects/${projectId}/labels/`,
payload
payload,
)
.then((response) => response.data)
.catch((error) => {
@@ -43,11 +43,11 @@ export class LabelService extends APIService {
slug: string,
projectId: string,
labelId: string,
payload: Omit<Optional<ExIssueLabel>, ExcludedProps>
payload: Omit<Optional<ExIssueLabel>, ExcludedProps>,
) {
return this.patch(
`/api/v1/workspaces/${slug}/projects/${projectId}/labels/${labelId}/`,
payload
payload,
)
.then((response) => response.data)
.catch((error) => {
@@ -57,7 +57,7 @@ export class LabelService extends APIService {
async destroy(slug: string, projectId: string, labelId: string) {
return this.delete(
`/api/v1/workspaces/${slug}/projects/${projectId}/labels/${labelId}/`
`/api/v1/workspaces/${slug}/projects/${projectId}/labels/${labelId}/`,
)
.then((response) => response.data)
.catch((error) => {
+10 -1
View File
@@ -15,6 +15,15 @@ export class ProjectService extends APIService {
async list(slug: string): Promise<Paginated<ExProject>> {
return this.get(`/api/v1/workspaces/${slug}/projects/`)
.then((response) => response.data)
.catch((error) => {
console.log(error);
throw error?.response?.data;
});
}
async getProject(slug: string, id: string): Promise<ExProject> {
return this.get(`/api/v1/workspaces/${slug}/projects/${id}`)
.then((response) => response.data)
.catch((error) => {
throw error?.response?.data;
@@ -23,7 +32,7 @@ export class ProjectService extends APIService {
async create(
slug: string,
payload: Omit<Optional<ExProject>, ExcludedProps>
payload: Omit<Optional<ExProject>, ExcludedProps>,
) {
return this.post(`/api/v1/workspaces/${slug}/projects/`, payload)
.then((response) => response.data)
+21 -5
View File
@@ -17,6 +17,22 @@ export class StateService extends APIService {
return this.get(`/api/v1/workspaces/${slug}/projects/${projectId}/states/`)
.then((response) => response.data)
.catch((error) => {
console.log(error);
throw error?.response?.data;
});
}
async getState(
slug: string,
projectId: string,
stateId: string,
): Promise<ExState> {
return this.get(
`/api/v1/workspaces/${slug}/projects/${projectId}/states/${stateId}/`,
)
.then((response) => response.data)
.catch((error) => {
console.log(error);
throw error?.response?.data;
});
}
@@ -24,11 +40,11 @@ export class StateService extends APIService {
async create(
slug: string,
projectId: string,
payload: Omit<Optional<ExState>, ExcludedProps>
payload: Omit<Optional<ExState>, ExcludedProps>,
): Promise<ExState> {
return this.post(
`/api/v1/workspaces/${slug}/projects/${projectId}/states/`,
payload
payload,
)
.then((response) => response.data)
.catch((error) => {
@@ -40,11 +56,11 @@ export class StateService extends APIService {
slug: string,
projectId: string,
stateId: string,
payload: Omit<Optional<ExState>, ExcludedProps>
payload: Omit<Optional<ExState>, ExcludedProps>,
) {
return this.patch(
`/api/v1/workspaces/${slug}/projects/${projectId}/states/${stateId}/`,
payload
payload,
)
.then((response) => response.data)
.catch((error) => {
@@ -54,7 +70,7 @@ export class StateService extends APIService {
async destroy(slug: string, projectId: string, stateId: string) {
return this.delete(
`/api/v1/workspaces/${slug}/projects/${projectId}/states/${stateId}/`
`/api/v1/workspaces/${slug}/projects/${projectId}/states/${stateId}/`,
)
.then((response) => response.data)
.catch((error) => {
+4 -3
View File
@@ -15,11 +15,11 @@ export class UserService extends APIService {
async create(
workspaceSlug: string,
projectId: string,
payload: UserCreatePayload
payload: UserCreatePayload,
): Promise<UserResponsePayload> {
return this.post(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/members/`,
payload
payload,
)
.then((response) => response.data)
.catch((error) => {
@@ -29,10 +29,11 @@ export class UserService extends APIService {
async list(workspaceSlug: string, projectId: string): Promise<PlaneUser[]> {
return this.get(
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/members/`
`/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/members/`,
)
.then((response) => response.data)
.catch((error) => {
console.log(error);
throw error?.response?.data;
});
}
+3 -1
View File
@@ -1 +1,3 @@
export * from "./types"
export * from "./types";
export * from "./webhook-types";
export * from "./issue-types";
+90
View File
@@ -0,0 +1,90 @@
type ExIssueTypeBase = {
id?: string | undefined;
workspace: string;
project: string;
parent?: string | undefined;
external_id: string;
external_source: string;
updated_by: string;
created_by: string;
created_at: string;
update_at: string;
};
// ====== issue types ======
export type ExIssueType = ExIssueTypeBase & {
name: string;
description: string;
is_active: boolean;
is_default: boolean;
external_source: string;
external_id: string;
};
// ====== issue type properties ======
export enum EIssuePropertyType {
TEXT = "TEXT",
DECIMAL = "DECIMAL",
OPTION = "OPTION",
BOOLEAN = "BOOLEAN",
DATETIME = "DATETIME",
RELATION = "RELATION",
}
export type TIssuePropertyType = keyof typeof EIssuePropertyType;
export enum EIssuePropertyRelationType {
ISSUE = "ISSUE",
USER = "USER",
}
export type TIssuePropertyRelationType =
keyof typeof EIssuePropertyRelationType;
// Unique keys for issue property types
export type TIssuePropertyTypeKeys =
| `${Exclude<EIssuePropertyType, EIssuePropertyType.RELATION>}`
| `${EIssuePropertyType.RELATION}_${EIssuePropertyRelationType}`;
// settings
export type TTextSettingsDisplayOptions =
| "single-line"
| "multi-line"
| "readonly";
export type TTextSettings = {
display_format: TTextSettingsDisplayOptions;
};
export type ExIssueProperty = ExIssueTypeBase & {
display_name: string;
description?: string | undefined;
property_type: TIssuePropertyType;
relation_type?: TIssuePropertyRelationType | undefined;
default_value?: string[];
settings?: TTextSettings | undefined;
is_required?: boolean;
is_active?: boolean;
is_multi?: boolean;
type_id?: string;
};
// ====== issue type options ======
export type ExIssuePropertyOption = ExIssueTypeBase & {
name: string;
description?: string | undefined;
is_active?: boolean;
is_default?: boolean;
parent?: string | undefined;
property_id?: string;
};
// ====== issue type values ======
export type TPropertyValue = {
value: string | number;
external_id?: string | undefined;
external_source?: string | undefined;
};
export type ExIssuePropertyValue = TPropertyValue[];
+69 -15
View File
@@ -1,3 +1,11 @@
import {
// issue types
ExIssueType,
ExIssueProperty,
ExIssuePropertyOption,
ExIssuePropertyValue,
} from "./issue-types";
// service types
export type ClientOptions = {
baseURL: string;
@@ -38,12 +46,7 @@ export type Paginated<T> = {
results: T[];
};
export type ExcludedProps =
| "id"
| "created_at"
| "created_by"
| "updated_at"
| "updated_by";
export type ExcludedProps = "id" | "created_at" | "updated_at" | "updated_by";
/* ----------------- Base Types --------------------- */
type IIssueLabel = {
@@ -59,6 +62,7 @@ export type IIssueComment = {
issue: string;
actor: string;
comment_html: string;
comment_stripped: string;
access: string;
is_member: boolean;
};
@@ -69,6 +73,7 @@ type IIsssue = {
point: any;
name: string;
description_html: string;
description_stripped: string;
description_binary: any;
priority: string;
start_date: string;
@@ -82,6 +87,7 @@ type IIsssue = {
estimate_point: any;
assignees: string[];
labels: string[];
type_id: string | undefined;
};
export type TStateGroups =
@@ -165,15 +171,6 @@ export interface ICycle {
owned_by: string;
}
export type PlaneEntities = {
labels: Optional<ExIssueLabel>[];
issues: Optional<ExIssue>[];
users: Optional<PlaneUser>[];
issue_comments: Optional<ExIssueComment>[];
cycles: Optional<ExCycle>[];
modules: Optional<ExModule>[];
};
export type ExIssueAttachment = {
id: string;
attributes: {
@@ -215,6 +212,7 @@ type IProject = {
page_view: boolean;
inbox_view: boolean;
is_time_tracking_enabled: boolean;
is_issue_type_enabled: boolean;
cover_image: string;
archive_in: number;
close_in: number;
@@ -254,6 +252,7 @@ export type ExCycle = ICycle &
ExBase & {
issues: string[];
};
type ExUser = {
id: string;
first_name: string;
@@ -275,3 +274,58 @@ export type UserCreatePayload = Omit<Optional<ExUser>, "id"> &
};
export type UserResponsePayload = ExUser & UserMandatePayload;
export interface Attributes {
name: string;
type: string;
size: number;
}
export interface StorageMetadata {
// Empty object in this case, but we'll define it for completeness
[key: string]: any;
}
export interface Attachment {
id: string;
created_at: string;
updated_at: string;
deleted_at: null | string;
attributes: Attributes;
asset: string;
entity_type: string;
is_deleted: boolean;
is_archived: boolean;
external_id: null | string;
external_source: null | string;
size: number;
is_uploaded: boolean;
storage_metadata: StorageMetadata;
created_by: string;
updated_by: string;
user: null | string;
workspace: string;
draft_issue: null | string;
project: string;
issue: string;
comment: null | string;
page: null | string;
}
export interface AttachmentResponse {
asset_id: string;
upload_data: {
url: string;
fields: {
"Content-Type": string;
key: string;
"x-amz-algorithm": string;
"x-amz-credential": string;
"x-amz-date": string;
policy: string;
"x-amz-signature": string;
};
};
attachment: Attachment;
asset_url: string;
}
+89
View File
@@ -0,0 +1,89 @@
import { ExIssue, ExIssueComment, PlaneUser } from "./types";
// Common types
type UUID = string;
interface Activity {
field: string;
new_value: string | null;
old_value: string | null;
actor: PlaneUser;
old_identifier: string | null;
new_identifier: string | null;
}
// Main webhook payload interfaces
export interface WebhookPayloadBase {
event: string;
action: string;
webhook_id: UUID;
workspace_id: UUID;
activity: Activity;
}
export interface WebhookIssuePayload extends WebhookPayloadBase {
created: {
event: "issue";
action: "created";
data: ExIssue;
};
updated: {
event: "issue";
action: "updated";
data: ExIssue;
};
deleted: {
event: "issue";
action: "deleted";
data: {
id: string;
};
};
}
export interface WebhookIssueCommentPayload extends WebhookPayloadBase {
created: {
event: "issue_comment";
action: "created";
data: ExIssueComment;
};
updated: {
event: "issue_comment";
action: "updated";
data: ExIssueComment;
};
deleted: {
event: "issue_comment";
action: "deleted";
data: {
id: string;
};
};
}
// Union type for all possible webhook payloads
export type PlaneWebhookData = WebhookIssuePayload | WebhookIssueCommentPayload;
// Main webhook payload interfaces
export interface PlaneWebhookPayloadBase<data = any> {
event: string;
data: any;
action: string;
webhook_id: UUID;
workspace_id: UUID;
activity: Activity;
}
// Union type for all possible webhook payloads
export type PlaneWebhookPayload = {
id: string;
event: string;
workspace: string;
project: string;
issue: string;
};
export type PlaneIssueWebhookPayload = PlaneWebhookPayload;
export type PlaneIssueCommentWebhookPayload = PlaneWebhookPayload & {
issue: string;
};
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@silo/asana",
"version": "1.0.0",
"repository": "https://github.com/makeplane/plane-ee",
"author": "Plane Engineering",
"license": "AGPL",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"dev": "tsc --watch",
"build": "tsc && tsc-alias",
"lint": "eslint --ext .ts src"
},
"dependencies": {
"@plane/sdk": "*",
"@silo/core": "*",
"asana": "^3.0.11"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@plane/typescript-config": "*",
"tsc-alias": "^1.8.10"
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./pull";
export * from "./transform";
+240
View File
@@ -0,0 +1,240 @@
// services
import { AsanaService } from "@/services/api.service";
// types
import {
AsanaAttachment,
AsanaCustomFieldSettings,
AsanaTag,
AsanaTask,
AsanaTaskComment,
AsanaUser,
PaginationPayload,
} from "@/types";
const DEFAULT_PAGINATION_LIMIT = 100;
interface TaskPullOptions {
batchSize?: number;
maxConcurrent?: number;
}
const DEFAULT_OPTIONS: TaskPullOptions = {
batchSize: 15,
maxConcurrent: 8,
};
export async function pullTasks(
client: AsanaService,
projectGid: string,
options: TaskPullOptions = DEFAULT_OPTIONS
): Promise<AsanaTask[]> {
return await getAllProjectTasks(client, projectGid, options);
}
async function getAllProjectTasks(
client: AsanaService,
projectGid: string,
options: TaskPullOptions
): Promise<AsanaTask[]> {
const allTasks: AsanaTask[] = [];
let offset = "";
do {
const response = await client.getProjectTasks(projectGid, {
limit: DEFAULT_PAGINATION_LIMIT,
offset,
});
// Process tasks in batches
for (let i = 0; i < response.data.length; i += options.batchSize!) {
const batch = response.data.slice(i, i + options.batchSize!);
const batchResults = await processBatch(batch, client, options);
allTasks.push(...batchResults);
}
offset = response._response.next_page?.offset || "";
} while (offset);
return allTasks;
}
async function processBatch(
tasks: AsanaTask[],
client: AsanaService,
options: TaskPullOptions
): Promise<AsanaTask[]> {
const results: AsanaTask[] = [];
// Process tasks in smaller concurrent groups
for (let i = 0; i < tasks.length; i += options.maxConcurrent!) {
const concurrent = tasks.slice(i, i + options.maxConcurrent!);
const concurrentResults = await Promise.all(
concurrent.map((task) => processTask(task, client, options))
);
results.push(...concurrentResults.flat());
}
return results;
}
async function processTask(
task: AsanaTask,
client: AsanaService,
options: TaskPullOptions
): Promise<AsanaTask[]> {
const results = [task];
if (task.num_subtasks > 0) {
const subtasks = await getAllSubtasks(client, task.gid, options);
results.push(...subtasks);
}
return results;
}
async function getAllSubtasks(
client: AsanaService,
taskGid: string,
options: TaskPullOptions
): Promise<AsanaTask[]> {
const allSubtasks: AsanaTask[] = [];
let offset = "";
do {
const response = await client.getTaskSubtasks(taskGid, {
limit: DEFAULT_PAGINATION_LIMIT,
offset,
});
// Process subtasks in batches
for (let i = 0; i < response.data.length; i += options.batchSize!) {
const batch = response.data.slice(i, i + options.batchSize!);
const batchResults = await processBatch(batch, client, options);
allSubtasks.push(...batchResults);
}
offset = response._response.next_page?.offset || "";
} while (offset);
return allSubtasks;
}
export async function pullUsers(
client: AsanaService,
workspaceGId: string
): Promise<AsanaUser[]> {
const users: AsanaUser[] = [];
let pagination: PaginationPayload = {
limit: DEFAULT_PAGINATION_LIMIT,
offset: "",
};
do {
const response = await client.getWorkspaceUsers(workspaceGId, pagination);
users.push(...response.data);
pagination.offset = response._response.next_page?.offset || "";
} while (pagination.offset);
return users;
}
export async function pullTags(
client: AsanaService,
workspaceGid: string
): Promise<AsanaTag[]> {
const tags: AsanaTag[] = [];
let pagination: PaginationPayload = {
limit: DEFAULT_PAGINATION_LIMIT,
offset: "",
};
do {
const response = await client.getWorkspaceTags(workspaceGid, pagination);
tags.push(...response.data);
pagination.offset = response._response.next_page?.offset || "";
} while (pagination.offset);
return tags;
}
export async function pullCustomFields(
client: AsanaService,
projectGid: string
): Promise<AsanaCustomFieldSettings[]> {
const customFields: AsanaCustomFieldSettings[] = [];
let pagination: PaginationPayload = {
limit: DEFAULT_PAGINATION_LIMIT,
offset: "",
};
do {
const response = await client.getProjectCustomFieldSettings(
projectGid,
pagination
);
customFields.push(...response.data);
pagination.offset = response._response.next_page?.offset || "";
} while (pagination.offset);
return customFields;
}
export async function pullAttachments(
client: AsanaService,
tasks: AsanaTask[]
): Promise<Record<string, AsanaAttachment[]>> {
const attachments: Record<string, AsanaAttachment[]> = {};
for (const task of tasks) {
if (!task.gid) continue;
let pagination: PaginationPayload = {
limit: DEFAULT_PAGINATION_LIMIT,
offset: "",
};
do {
const response = await client.getResourceAttachments(
task.gid,
pagination
);
if (!attachments[task.gid]) {
attachments[task.gid] = [];
}
attachments[task.gid].push(...response.data);
} while (pagination.offset);
}
return attachments;
}
export async function pullComments(
client: AsanaService,
tasks: AsanaTask[]
): Promise<AsanaTaskComment[]> {
// Fetch comments for each task
const comments: AsanaTaskComment[] = [];
// Pagination for comments
let pagination: PaginationPayload = {
limit: DEFAULT_PAGINATION_LIMIT,
offset: "",
};
// Fetch comments for each task
for (const task of tasks) {
do {
// Fetch comments for the task
const response = await client.getTaskComments(task.gid, pagination);
comments.push(
...response.data
.filter((story) => story.type && story.type === "comment")
.map(
(story) => ({ ...story, task_gid: task.gid }) as AsanaTaskComment
)
);
// Update pagination
pagination.offset = response._response.next_page?.offset || "";
} while (pagination.offset);
}
return comments;
}
+196
View File
@@ -0,0 +1,196 @@
// plane sdk
import {
ExIssueAttachment,
ExIssueComment,
ExIssueProperty,
ExIssuePropertyOption,
ExIssue as PlaneIssue,
PlaneUser,
} from "@plane/sdk";
// helpers
import {
getFormattedDate,
getPropertyValues,
getTargetAttachments,
getTargetPriority,
getTargetState,
mapAsanaAssignee,
mapAsanaCommentCreator,
mapAsanaCreator,
mapAsanaState,
} from "@/helpers";
// types
import {
AsanaCustomFieldSettings,
AsanaEnumOption,
AsanaTag,
AsanaTask,
AsanaTaskComment,
AsanaUser,
AsanaAttachment,
PriorityConfigSettings,
StateConfig,
} from "@/types";
import { CUSTOM_FIELD_ATTRIBUTES } from "@/helpers/custom-field-etl";
import { TPropertyValuesPayload } from "@silo/core";
export const transformTask = (
task: AsanaTask,
projectGid: string,
users: AsanaUser[],
tags: AsanaTag[],
attachments: AsanaAttachment[],
stateMap: StateConfig[],
prioritySettings: PriorityConfigSettings
): Partial<PlaneIssue> => {
const labels: string[] = ["Asana Imported"];
const assignee = mapAsanaAssignee(task, users);
const creator = mapAsanaCreator(task, users);
const state = mapAsanaState(task, projectGid);
const targetAttachments = getTargetAttachments(attachments);
const targetState = state && getTargetState(stateMap, state);
const {
custom_field_id: priorityCustomFieldId,
priority_config: priorityMap,
} = prioritySettings;
let targetPriority = undefined;
if (priorityCustomFieldId && priorityMap && priorityMap.length) {
const priorityFieldValue = task.custom_fields.find(
(field) => field.gid === priorityCustomFieldId
)?.enum_value;
targetPriority = getTargetPriority(priorityMap, priorityFieldValue);
}
const parent = task.parent ? task.parent.gid : undefined;
// Add tags to the labels
if (task.tags) {
const tagNames = task.tags
.map((tag) => tags.find((t) => t.gid === tag.gid)?.name)
.filter((name): name is string => !!name);
labels.push(...tagNames);
}
// Add link to the asana task
const links = [
{
name: "Linked Asana Task",
url: task.permalink_url,
},
];
const issue: Partial<PlaneIssue> = {
name: task.name || "Untitled Task",
external_id: task.gid,
external_source: "ASANA",
assignees: assignee ? [assignee] : [],
parent,
labels,
links,
attachments: targetAttachments as ExIssueAttachment[], // TODO: Fix this type
state: targetState ? targetState.id : "",
priority: targetPriority,
description_html:
!task.html_notes || task.html_notes == "" ? "<p></p>" : task.html_notes,
start_date: getFormattedDate(task.start_on),
target_date: getFormattedDate(task.due_on),
created_at: task.created_at,
created_by: creator,
};
return issue;
};
export const transformUser = (user: AsanaUser): Partial<PlaneUser> => {
const [first_name, ...lastNameParts] = user.name.split(" ");
const last_name = lastNameParts.join(" ");
const displayName = user.email.split("@")[0];
return {
first_name,
last_name,
email: user.email,
display_name: displayName,
role: 15,
};
};
export const transformCustomFields = (
fieldSettings: AsanaCustomFieldSettings,
priorityFieldGid?: string
): Partial<ExIssueProperty> | undefined => {
if (
!fieldSettings.custom_field?.gid ||
!fieldSettings.custom_field.type ||
fieldSettings.custom_field.gid === priorityFieldGid ||
fieldSettings.custom_field.is_formula_field === true ||
fieldSettings.custom_field.is_value_read_only === true
) {
return undefined;
}
// Get the property attributes
const propertyAttributes =
CUSTOM_FIELD_ATTRIBUTES[fieldSettings.custom_field.type];
if (!propertyAttributes) {
return undefined;
}
// Return the transformed property
return {
external_id: fieldSettings.custom_field.gid,
external_source: "ASANA",
display_name: fieldSettings.custom_field.name,
description: fieldSettings.custom_field.description ?? undefined,
type_id: undefined, // Issue types not supported in Asana
is_active: true,
...propertyAttributes,
};
};
export const transformCustomFieldOptions = (
customFieldGid: string,
customFieldOption: AsanaEnumOption
): Partial<ExIssuePropertyOption> => {
return {
external_id: customFieldOption.gid,
external_source: "ASANA",
name: customFieldOption.name,
is_active: customFieldOption.enabled,
property_id: customFieldGid,
};
};
export const transformCustomFieldValues = (
task: AsanaTask,
planeIssueProperties: Map<string, Partial<ExIssueProperty>>,
asanaUsersMap: Map<string, AsanaUser>
): TPropertyValuesPayload => {
// Get transformed values for property_id -> property_values
const propertyValuesPayload: TPropertyValuesPayload = {};
task.custom_fields.forEach((customField) => {
const property = planeIssueProperties.get(customField.gid);
if (
property &&
property.external_id &&
customField.gid &&
customField.type
) {
propertyValuesPayload[property.external_id] = getPropertyValues(
customField,
asanaUsersMap
);
}
});
return propertyValuesPayload;
};
export const transformComments = (
comment: AsanaTaskComment,
users: AsanaUser[]
): Partial<ExIssueComment> => {
const creator = mapAsanaCommentCreator(comment, users);
return {
external_id: comment.gid,
external_source: "ASANA",
created_at: getFormattedDate(comment.created_at),
comment_html: comment.html_text ?? "<p></p>",
actor: creator,
issue: comment.task_gid,
};
};
+1
View File
@@ -0,0 +1 @@
declare module "asana";
@@ -0,0 +1,47 @@
// plane sdk
import {
EIssuePropertyRelationType,
EIssuePropertyType,
ExIssueProperty,
} from "@plane/sdk";
// silo core
import { getTextPropertySettings } from "@silo/core";
// types
import { AsanaCustomFieldType } from "@/types";
export const CUSTOM_FIELD_ATTRIBUTES: Record<
AsanaCustomFieldType,
Partial<ExIssueProperty>
> = {
text: {
property_type: EIssuePropertyType.TEXT,
relation_type: undefined,
is_multi: false,
settings: getTextPropertySettings("multi-line"),
},
number: {
property_type: EIssuePropertyType.DECIMAL,
relation_type: undefined,
is_multi: false,
},
enum: {
property_type: EIssuePropertyType.OPTION,
relation_type: undefined,
is_multi: false,
},
multi_enum: {
property_type: EIssuePropertyType.OPTION,
relation_type: undefined,
is_multi: true,
},
date: {
property_type: EIssuePropertyType.DATETIME,
relation_type: undefined,
is_multi: false,
},
people: {
property_type: EIssuePropertyType.RELATION,
relation_type: EIssuePropertyRelationType.USER,
is_multi: true,
},
};
+14
View File
@@ -0,0 +1,14 @@
export const getFormattedDate = (
date: string | null | undefined
): string | undefined => {
if (date) {
const dateObj = new Date(date);
const year = dateObj.getUTCFullYear();
const month = String(dateObj.getUTCMonth() + 1).padStart(2, "0"); // Months are zero-based
const day = String(dateObj.getUTCDate()).padStart(2, "0");
const formattedDate = `${year}-${month}-${day}`;
return formattedDate;
}
};
+199
View File
@@ -0,0 +1,199 @@
import {
ExIssueAttachment,
ExIssuePropertyValue,
ExState,
TPropertyValue,
} from "@plane/sdk";
import {
AsanaAttachment,
AsanaCustomField,
AsanaTaskComment,
AsanaEnumOption,
AsanaTask,
AsanaUser,
PriorityConfig,
StateConfig,
} from "@/types";
import { getFormattedDate } from "./date";
export const getTargetAttachments = (
attachments: AsanaAttachment[]
): Partial<ExIssueAttachment>[] => {
if (!attachments) return [];
return attachments.map((attachment) => ({
external_id: attachment.gid ?? "",
external_source: "ASANA",
attributes: {
name: attachment.name ?? "Untitled",
size: attachment.size ?? 0,
},
asset: attachment.download_url ?? "",
}));
};
export const getTargetState = (
stateMap: StateConfig[],
sourceState: string
): ExState | undefined => {
const targetState = stateMap.find((s) => s.source_state.id === sourceState);
if (targetState) {
targetState.target_state.external_source = "ASANA";
targetState.target_state.external_id = sourceState;
return targetState.target_state;
}
};
export const getTargetPriority = (
priorityMap: PriorityConfig[],
priorityFieldValue: AsanaEnumOption | null | undefined
): string | undefined => {
const targetPriority = priorityMap.find(
(p) => p.source_priority.id === priorityFieldValue?.gid
);
return targetPriority?.target_priority;
};
export const mapAsanaAssignee = (
task: AsanaTask,
users: AsanaUser[]
): string | undefined => {
if (task.assignee) {
const assignee = users.find((u) => u.gid === task.assignee?.gid);
if (assignee) {
const displayName = assignee.email.split("@")[0];
return displayName;
}
}
};
export const mapAsanaCreator = (
task: AsanaTask,
users: AsanaUser[]
): string | undefined => {
if (task.created_by) {
const creator = users.find((u) => u.gid === task.created_by?.gid);
if (creator) {
const displayName = creator.email.split("@")[0];
return displayName;
}
}
};
export const mapAsanaState = (
task: AsanaTask,
projectGid: string
): string | undefined => {
if (task.memberships) {
const state = task.memberships.find((m) => m.project.gid === projectGid);
return state?.section.gid;
}
};
export const getPropertyValues = (
customField: AsanaCustomField,
asanaUsersMap: Map<string, AsanaUser>
): ExIssuePropertyValue => {
const propertyValues: ExIssuePropertyValue = [];
const commonPropertyProp: Partial<TPropertyValue> = {
external_source: "ASANA",
external_id: undefined,
};
if (!customField || !customField.type) return [];
switch (customField.type) {
case "text":
// Handle text field
if (customField.text_value) {
propertyValues.push({
...commonPropertyProp,
value: customField.text_value,
});
}
break;
case "number":
// Handle number field
if (customField.number_value) {
propertyValues.push({
...commonPropertyProp,
value: customField.number_value,
});
}
break;
case "date":
// Handle date field
const dateTimeValue = getFormattedDate(customField.date_value?.date_time);
const dateValue = getFormattedDate(customField.date_value?.date);
if (dateTimeValue) {
propertyValues.push({
...commonPropertyProp,
value: dateTimeValue,
});
} else if (dateValue) {
propertyValues.push({
...commonPropertyProp,
value: dateValue,
});
}
break;
case "enum":
// Handle enum field
if (customField.enum_value?.gid) {
propertyValues.push({
...commonPropertyProp,
external_id: customField.enum_value.gid,
value: customField.enum_value.gid,
});
}
break;
case "multi_enum":
// Handle multi enum field
if (customField.multi_enum_values?.length) {
customField.multi_enum_values.forEach((val) => {
if (val.gid) {
propertyValues.push({
...commonPropertyProp,
external_id: val.gid,
value: val.gid,
});
}
});
}
break;
case "people":
// Handle people field
if (customField.people_value) {
customField.people_value.forEach((val) => {
if (val.gid) {
const user = asanaUsersMap.get(val.gid);
const displayName = user?.email.split("@")[0];
if (displayName) {
propertyValues.push({
...commonPropertyProp,
external_id: val.gid,
value: displayName,
});
}
}
});
}
break;
default:
console.warn(`Unhandled custom field type: ${customField.type}`);
}
return propertyValues;
};
export const mapAsanaCommentCreator = (
comment: AsanaTaskComment,
users: AsanaUser[]
): string | undefined => {
if (comment.created_by) {
const creator = users.find((u) => u.gid === comment.created_by?.gid);
if (creator) {
const displayName = creator.email.split("@")[0];
return displayName;
}
}
};
+3
View File
@@ -0,0 +1,3 @@
export * from "./date";
export * from "./string";
export * from "./etl";
+32
View File
@@ -0,0 +1,32 @@
export const removeArrayObjSpaces = (arr: any[]) => {
return arr.map((obj) => {
return removeSpacesFromKeys(obj);
});
};
export const removeSpacesFromKeys = (obj: any) => {
const newObj = {};
for (const [key, value] of Object.entries(obj)) {
const newKey = key.replace(/\s+/g, "_").toLowerCase();
// @ts-ignore
newObj[newKey] = value;
}
return newObj;
};
export const formatDateStringForHHMM = (inputDate: Date): string => {
const date = new Date(inputDate);
// Extract date components
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0"); // Months are zero-based
const day = date.getDate().toString().padStart(2, "0");
// Construct the formatted date string
const formattedDate = `${year}/${month}/${day}`;
return formattedDate;
};
export const getRandomColor = () => {
return "#" + Math.floor(Math.random() * 16777215).toString(16);
};
+4
View File
@@ -0,0 +1,4 @@
export * from "./services";
export * from "./types";
export * from "./helpers";
export * from "./etl";
@@ -0,0 +1,292 @@
const Asana = require("asana");
// types
import {
AsanaAttachment,
AsanaCustomFieldSettings,
AsanaProject,
AsanaProjectTaskCount,
AsanaSection,
AsanaServiceProps,
AsanaTag,
AsanaTask,
AsanaTaskComment,
AsanaUser,
AsanaWorkspace,
PaginatedResponse,
PaginationPayload,
TokenRefreshResponse,
} from "@/types";
// rate limiter
import RateLimiter from "./rate-limiter";
type FetchRefreshTokenProps = {
isRefreshed: boolean;
isRejected: boolean;
};
const TASK_OPT_FIELDS =
"name,html_notes,start_on,due_on,permalink_url,created_at,created_by,assignee,tags,memberships,memberships.project,memberships.section,parent,num_subtasks,custom_fields,offset";
export class AsanaService {
private accessToken: string;
private refreshToken: string | null;
private asanaClient: any;
private usersApiInstance: any;
private workspacesApiInstance: any;
private projectsApiInstance: any;
private sectionsApiInstance: any;
private tasksApiInstance: any;
private tagsApiInstance: any;
private storiesApiInstance: any;
private customFieldSettingsApiInstance: any;
private attachmentsApiInstance: any;
private rateLimiter: RateLimiter;
private refreshTokenFunc?: (
refreshToken: string
) => Promise<TokenRefreshResponse>;
private refreshTokenCallback?: (
response: TokenRefreshResponse
) => Promise<void>;
private refreshTokenRejectCallback?: () => Promise<void>;
constructor(props: AsanaServiceProps) {
this.accessToken = props.accessToken;
this.refreshToken = props.refreshToken;
this.refreshTokenFunc = props.refreshTokenFunc;
this.refreshTokenCallback = props.refreshTokenCallback;
this.refreshTokenRejectCallback = props.refreshTokenRejectCallback;
// Initialize client
this.initializeClient();
// Initialize rate limiter: 150 requests per minute
this.rateLimiter = new RateLimiter(150, 60 * 1000);
}
private initializeClient() {
this.asanaClient = Asana.ApiClient.instance;
this.asanaClient.authentications["token"].accessToken = this.accessToken;
this.usersApiInstance = new Asana.UsersApi();
this.workspacesApiInstance = new Asana.WorkspacesApi();
this.projectsApiInstance = new Asana.ProjectsApi();
this.sectionsApiInstance = new Asana.SectionsApi();
this.tasksApiInstance = new Asana.TasksApi();
this.tagsApiInstance = new Asana.TagsApi();
this.storiesApiInstance = new Asana.StoriesApi();
this.customFieldSettingsApiInstance = new Asana.CustomFieldSettingsApi();
this.attachmentsApiInstance = new Asana.AttachmentsApi();
}
private async refreshAccessToken(): Promise<FetchRefreshTokenProps> {
if (
!this.refreshToken ||
!this.refreshTokenFunc ||
!this.refreshTokenCallback
) {
return {
isRefreshed: false,
isRejected: true,
};
}
try {
const response = await this.refreshTokenFunc(this.refreshToken);
this.accessToken = response.access_token;
this.refreshToken = response.refresh_token;
// Reinitialize the client with the new access token
this.initializeClient();
// Notify callback about the token refresh
await this.refreshTokenCallback(response);
return {
isRefreshed: true,
isRejected: false,
};
} catch (error) {
// if it fails, reset the credentials from db.
if (this.refreshTokenRejectCallback) {
await this.refreshTokenRejectCallback();
}
console.error("Failed to refresh access token:", error);
return {
isRefreshed: false,
isRejected: true,
};
}
}
private async makeRequest<T>(
requestFn: () => Promise<T>,
retryCount = 3
): Promise<T> {
let lastError: any;
for (let attempt = 0; attempt < retryCount; attempt++) {
try {
await this.rateLimiter.acquireToken();
return await requestFn();
} catch (error: any) {
lastError = error;
// Handle rate limiting (429)
if (error.response?.status === 429) {
const retryAfter = parseInt(
error.response.headers["retry-after"] || "60",
10
);
await new Promise((resolve) =>
setTimeout(resolve, retryAfter * 1000)
);
continue;
}
// Handle unauthorized (401) - attempt token refresh
if (error.response?.status === 401) {
const { isRefreshed, isRejected } = await this.refreshAccessToken();
if (isRejected) {
// If token refresh fails, don't retry
break;
}
if (isRefreshed) {
// Retry immediately after successful token refresh
continue;
}
}
// For other errors or if token refresh failed, use exponential backoff
if (attempt === retryCount - 1) throw error;
await new Promise((resolve) =>
setTimeout(resolve, 1000 * Math.pow(2, attempt))
);
}
}
throw lastError || new Error("All retry attempts failed");
}
async getWorkspaceUsers(
workspaceGId: string,
pagination: PaginationPayload
): Promise<PaginatedResponse<AsanaUser>> {
return await this.makeRequest(() =>
this.usersApiInstance.getUsersForWorkspace(workspaceGId, {
limit: pagination.limit,
offset: pagination.offset || undefined,
opt_fields: "name,email,offset",
})
);
}
async getProjectTasks(
projectGid: string,
pagination: PaginationPayload
): Promise<PaginatedResponse<AsanaTask>> {
return await this.makeRequest(() =>
this.tasksApiInstance.getTasks({
project: projectGid,
limit: pagination.limit,
offset: pagination.offset || undefined,
opt_fields: TASK_OPT_FIELDS,
})
);
}
async getTaskSubtasks(
taskGid: string,
pagination: PaginationPayload
): Promise<PaginatedResponse<AsanaTask>> {
return await this.makeRequest(() =>
this.tasksApiInstance.getSubtasksForTask(taskGid, {
limit: pagination.limit,
offset: pagination.offset || undefined,
opt_fields: TASK_OPT_FIELDS,
})
);
}
async getWorkspaceTags(
workspaceGid: string,
pagination: PaginationPayload
): Promise<PaginatedResponse<AsanaTag>> {
return await this.makeRequest(() =>
this.tagsApiInstance.getTagsForWorkspace(workspaceGid, {
limit: pagination.limit,
offset: pagination.offset || undefined,
opt_fields: "name,color,offset",
})
);
}
async getWorkspaces(): Promise<PaginatedResponse<AsanaWorkspace>> {
return this.makeRequest(() => this.workspacesApiInstance.getWorkspaces());
}
async getWorkspaceProjects(
workspaceGid: string
): Promise<PaginatedResponse<AsanaProject>> {
return this.makeRequest(() =>
this.projectsApiInstance.getProjectsForWorkspace(workspaceGid)
);
}
async getProjectSections(
projectGid: string
): Promise<PaginatedResponse<AsanaSection>> {
return this.makeRequest(() =>
this.sectionsApiInstance.getSectionsForProject(projectGid)
);
}
async getProjectTaskCount(
projectGid: string
): Promise<PaginatedResponse<AsanaProjectTaskCount>> {
return this.makeRequest(() =>
this.projectsApiInstance.getTaskCountsForProject(projectGid, {
opt_fields: "num_tasks",
})
);
}
async getProjectCustomFieldSettings(
projectGid: string,
pagination: PaginationPayload
): Promise<PaginatedResponse<AsanaCustomFieldSettings>> {
return this.makeRequest<PaginatedResponse<AsanaCustomFieldSettings>>(() =>
this.customFieldSettingsApiInstance.getCustomFieldSettingsForProject(
projectGid,
{
opt_fields:
"project,parent,custom_field,custom_field.name,custom_field.description,custom_field.type,custom_field.is_formula_field,custom_field.is_value_read_only,custom_field.enum_options",
limit: pagination.limit,
offset: pagination.offset || undefined,
}
)
);
}
async getResourceAttachments(
resourceGid: string,
pagination: PaginationPayload
): Promise<PaginatedResponse<AsanaAttachment>> {
return this.makeRequest(() =>
this.attachmentsApiInstance.getAttachmentsForObject(resourceGid, {
opt_fields: "name,size,download_url",
limit: pagination.limit,
offset: pagination.offset || undefined,
})
);
}
async getTaskComments(
taskGid: string,
pagination: PaginationPayload
): Promise<PaginatedResponse<AsanaTaskComment>> {
return this.makeRequest(() =>
this.storiesApiInstance.getStoriesForTask(taskGid, {
opt_fields: "text,created_at,created_by,type,html_text,task",
limit: pagination.limit,
offset: pagination.offset || undefined,
})
);
}
}
export default AsanaService;
@@ -0,0 +1,65 @@
import axios from "axios";
// types
import { AsanaAuthProps, AsanaAuthState } from "@/types";
export type AsanaTokenResponse = {
access_token: string;
refresh_token: string;
expires_in: number;
};
export class AsanaAuth {
props: AsanaAuthProps;
constructor(props: AsanaAuthProps) {
this.props = props;
}
getCallbackUrl(_state: AsanaAuthState, hostname: string): string {
const host = hostname.endsWith("/") ? hostname.slice(0, -1) : hostname;
return host + this.props.callbackURL;
}
getAuthorizationURL(state: AsanaAuthState, hostname: string): string {
const scope = "default"; // Asana's scope
const callbackURL = this.getCallbackUrl(state, hostname);
const stateString = JSON.stringify(state);
// encode state string to base64
const encodedState = Buffer.from(stateString).toString("base64");
const consentURL = `https://app.asana.com/-/oauth_authorize?scope=${scope}&response_type=code&client_id=${this.props.clientId}&redirect_uri=${callbackURL}&state=${encodedState}`;
return consentURL;
}
async getAccessToken(
code: string,
state: AsanaAuthState,
hostname: string
): Promise<{ tokenResponse: AsanaTokenResponse; state: AsanaAuthState }> {
const params = new URLSearchParams();
params.append("grant_type", "authorization_code");
params.append("client_id", this.props.clientId);
params.append("client_secret", this.props.clientSecret);
params.append("redirect_uri", this.getCallbackUrl(state, hostname));
params.append("code", code);
const { data: tokenResponse } = await axios.post<AsanaTokenResponse>(
"https://app.asana.com/-/oauth_token",
params.toString()
);
return { tokenResponse, state };
}
async getRefreshToken(refreshToken: string): Promise<AsanaTokenResponse> {
const params = new URLSearchParams();
params.append("grant_type", "refresh_token");
params.append("client_id", this.props.clientId);
params.append("client_secret", this.props.clientSecret);
params.append("refresh_token", refreshToken);
const { data: tokenResponse } = await axios.post<AsanaTokenResponse>(
"https://app.asana.com/-/oauth_token",
params.toString()
);
return tokenResponse;
}
}
@@ -0,0 +1,17 @@
import { AsanaAuth } from "./auth.service";
import AsanaService from "./api.service";
import { AsanaServiceProps } from "@/types";
export const createAsanaAuthService = (
clientId: string = "",
clientSecret: string = "",
callbackURL: string
) => {
if (!clientId || !clientSecret) {
console.error("[ASANA] Client ID and client secret are required");
}
return new AsanaAuth({ clientId, clientSecret, callbackURL });
};
export const createAsanaService = (props: AsanaServiceProps) =>
new AsanaService(props);
@@ -0,0 +1,4 @@
export * from "./builder";
export * from "./api.service";
export * from "./auth.service";
export * from "./rate-limiter";
@@ -0,0 +1,36 @@
// RateLimiter class to manage API request timing
class RateLimiter {
private requests: number[];
private readonly limit: number;
private readonly interval: number;
constructor(limit: number, intervalMs: number) {
this.requests = [];
this.limit = limit;
this.interval = intervalMs;
}
async acquireToken(): Promise<void> {
const now = Date.now();
// Remove expired timestamps
this.requests = this.requests.filter(
(timestamp) => now - timestamp < this.interval
);
if (this.requests.length >= this.limit) {
const oldestRequest = this.requests[0];
const waitTime = this.interval - (now - oldestRequest);
if (waitTime > 0) {
await new Promise((resolve) => setTimeout(resolve, waitTime));
}
this.requests = this.requests.filter(
(timestamp) => now - timestamp < this.interval
);
}
this.requests.push(Date.now());
}
}
export default RateLimiter;
+230
View File
@@ -0,0 +1,230 @@
// plane sdk
import { ExProject, ExState } from "@plane/sdk";
// Auth States for Asana
export type AsanaAuthState = {
workspaceId: string;
workspaceSlug: string;
apiToken: string;
userId: string;
};
export type AsanaPATAuthState = {
workspaceId: string;
userId: string;
apiToken: string;
personalAccessToken: string;
};
// Payload for Asana OAuth
export type AsanaAuthPayload = {
state: string;
code: string;
};
// Props required for Asana OAuth
export type AsanaAuthProps = {
clientId: string;
clientSecret: string;
callbackURL: string;
};
export type StateConfig = {
source_state: {
id: string;
name: string;
};
target_state: ExState;
};
export type PriorityConfig = {
source_priority: {
id: string;
name: string;
};
target_priority: string;
};
export type PriorityConfigSettings = {
custom_field_id?: string | undefined;
priority_config?: PriorityConfig[] | undefined;
};
export type AsanaConfig = {
planeProject: ExProject;
workspace: AsanaWorkspace;
project: AsanaProject;
state: StateConfig[];
priority?: PriorityConfigSettings | undefined;
};
export type TokenRefreshResponse = {
access_token: string;
refresh_token: string;
expires_in: number;
};
export type AsanaServiceProps = {
accessToken: string;
refreshToken: string | null;
refreshTokenFunc?: (refreshToken: string) => Promise<TokenRefreshResponse>;
refreshTokenCallback?: (response: TokenRefreshResponse) => Promise<void>;
refreshTokenRejectCallback?: () => Promise<void>;
};
export type PaginationPayload = {
limit: number;
offset: string;
};
export type PaginatedResponse<T> = {
data: T[];
_response: {
next_page?: {
offset: string;
path: string;
uri: string;
};
};
};
// ASANA TYPES
type AsanaResourceType =
| "user"
| "workspace"
| "portfolio"
| "project"
| "section"
| "tag"
| "task"
| "custom_field_setting"
| "custom_field"
| "enum_option"
| "attachment";
// Every object in Asana is a resource with a gid and a resource type
interface AsanaResource<T extends AsanaResourceType> {
gid: string;
resource_type: T;
}
export type AsanaWorkspace = AsanaResource<"workspace"> & {
name: string;
};
export type AsanaProject = AsanaResource<"project"> & {
name: string;
};
export type AsanaSection = AsanaResource<"section"> & {
name: string;
};
export type AsanaUser = AsanaResource<"user"> & {
name: string;
email: string;
};
export type AsanaTaskMembership = {
project: AsanaProject;
section: AsanaSection;
};
export type AsanaTag = AsanaResource<"tag"> & {
name: string;
color: string;
};
export type AsanaCustomFieldType =
| "text"
| "number"
| "enum"
| "multi_enum"
| "date"
| "people";
export type AsanaEnumOption = AsanaResource<"enum_option"> & {
name: string;
color: string;
enabled: boolean;
};
export type AsanaDateFieldValue = {
date: string | null;
date_time: string | null;
};
export type AsanaCustomField = AsanaResource<"custom_field"> & {
enabled?: boolean;
name: string;
display_value?: string; // This is different from our custom property display value, this is used to display the VALUE in Asana. Always a string.
description?: string | null;
created_by: AsanaResource<"user">;
is_formula_field?: boolean;
is_value_read_only?: boolean;
type: AsanaCustomFieldType;
text_value?: string | null;
number_value?: number | null;
precision?: number;
enum_options?: AsanaEnumOption[];
enum_value?: AsanaEnumOption | null;
multi_enum_values?: AsanaEnumOption[];
date_value?: AsanaDateFieldValue | null;
people_value?: AsanaResource<"user">[];
};
export type AsanaCustomFieldSettings = AsanaResource<"custom_field_setting"> & {
custom_field?: AsanaCustomField;
project: AsanaProject;
parent: AsanaResource<"portfolio" | "project"> | null;
};
export type AsanaAttachment = AsanaResource<"attachment"> & {
name: string;
size: number;
download_url: string;
};
export type AsanaTask = AsanaResource<"task"> & {
name: string;
html_notes: string;
assignee: AsanaResource<"user"> | null;
start_on: string | null;
due_on: string | null;
permalink_url: string;
tags: AsanaResource<"tag">[];
memberships: AsanaTaskMembership[];
created_at: string;
created_by: AsanaResource<"user">;
parent: AsanaResource<"task"> | null;
num_subtasks: number;
custom_fields: AsanaCustomField[];
};
export type AsanaEntity = {
users: AsanaUser[];
tasks: AsanaTask[];
tags: AsanaTag[];
fields: AsanaCustomFieldSettings[];
attachments: Record<string, AsanaAttachment[]>; // task gid to attachments
comments: AsanaTaskComment[];
};
export type AsanaProjectTaskCount = {
num_tasks: number;
};
export type AsanaTaskWithChildren = AsanaTask & {
children?: AsanaTaskWithChildren[];
};
export type AsanaTaskComment = {
gid: string;
text: string;
created_at: string;
created_by: AsanaResource<"user">;
html_text: string;
type: string;
task_gid: string;
};
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "@plane/typescript-config/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["dist", "build", "node_modules"]
}
+1
View File
@@ -0,0 +1 @@
export * from "./issue-property.helper";
@@ -0,0 +1,12 @@
import { TTextSettings, TTextSettingsDisplayOptions } from "@plane/sdk";
export const getTextPropertySettings = (display_format: TTextSettingsDisplayOptions): TTextSettings => {
switch (display_format) {
case "single-line":
return { display_format: "single-line" };
case "multi-line":
return { display_format: "multi-line" };
case "readonly":
return { display_format: "readonly" };
}
};
+3
View File
@@ -3,3 +3,6 @@ export * from "./services";
// types
export * from "./types";
// helpers
export * from "./helpers";
@@ -1,8 +1,8 @@
import axios, { AxiosInstance } from "axios";
// types
import { TSyncServiceConfigured, TSyncServices } from "@/types";
import { TServiceAuthConfiguration, TImporterKeys } from "@/types";
export class SyncCredService {
export class CredentialService {
public axiosInstance: AxiosInstance;
constructor(baseURL: string) {
@@ -13,17 +13,19 @@ export class SyncCredService {
* @description check if the service is configured
* @param workspaceId: string
* @param userId: string
* @param source: TSyncServices
* @returns TSyncServiceConfigured
* @param source: TImporterKeys
* @returns TServiceAuthConfiguration
*/
async isServiceConfigured(
workspaceId: string,
userId: string,
source: TSyncServices
): Promise<TSyncServiceConfigured> {
source: TImporterKeys
): Promise<TServiceAuthConfiguration> {
return this.axiosInstance
.get(`/silo/api/credentials/${workspaceId}/${userId}/?source=${source}`)
.get(`/api/credentials/${workspaceId}/${userId}/?source=${source}`)
.then((response) => response?.data)
.catch((error) => error?.response?.data);
}
// TODO: Personal Access Token Service methods
}
@@ -0,0 +1,19 @@
import axios, { AxiosInstance } from "axios";
// types
import { TFeatureFlags } from "@/types";
export class FeatureFlagService {
public axiosInstance: AxiosInstance;
constructor(baseURL: string, config?: { x_api_key: string }) {
this.axiosInstance = axios.create({ baseURL });
this.axiosInstance.defaults.headers.common["x-api-key"] = config?.x_api_key;
}
async featureFlags(payload: { workspace_slug: string; user_id: string; flag_key: TFeatureFlags }): Promise<boolean> {
return this.axiosInstance
.post(`/api/feature-flags/`, payload)
.then((response) => response?.data?.value)
.catch(() => false);
}
}
+3 -2
View File
@@ -1,2 +1,3 @@
export * from "./sync-cred.service";
export * from "./sync-job.service";
export * from "./feature-flag.service";
export * from "./credential.service";
export * from "./job.service";
@@ -1,7 +1,7 @@
import axios, { AxiosInstance } from "axios";
import { TSyncJobWithConfig, TSyncServices, propertiesToOmit } from "@/types";
import { TJobWithConfig, TImporterKeys, propertiesToOmit } from "@/types";
export class SyncJobService<TSyncJobConfig extends object> {
export class JobService<TSyncJobConfig extends object> {
public axiosInstance: AxiosInstance;
constructor(baseUrl: string, xApiKey: string) {
@@ -12,9 +12,9 @@ export class SyncJobService<TSyncJobConfig extends object> {
* @description Retrieves all jobs
* @returns Promise resolving to an array of Job objects
*/
async getSyncJobs(source: TSyncServices): Promise<TSyncJobWithConfig<TSyncJobConfig>[]> {
async list(source: TImporterKeys): Promise<TJobWithConfig<TSyncJobConfig>[]> {
return this.axiosInstance
.get(`/silo/api/jobs?source=${source}`)
.get(`/api/jobs?source=${source}`)
.then((res) => res.data)
.catch((error) => {
throw error?.response?.data;
@@ -26,9 +26,9 @@ export class SyncJobService<TSyncJobConfig extends object> {
* @param jobId - Unique identifier of the job to fetch
* @returns Promise resolving to an array of Job objects
*/
async getSyncJobById(jobId: string): Promise<TSyncJobWithConfig<TSyncJobConfig>> {
async retrieve(jobId: string): Promise<TJobWithConfig<TSyncJobConfig>> {
return this.axiosInstance
.get(`/silo/api/jobs/?id=${jobId}`)
.get(`/api/jobs/?id=${jobId}`)
.then((res) => res.data)
.catch((error) => {
console.log(error);
@@ -43,14 +43,14 @@ export class SyncJobService<TSyncJobConfig extends object> {
* @param payload - Job data, excluding certain properties
* @returns Promise resolving to the created Job object
*/
async createSyncJob(
async create(
workspaceId: string,
projectId: string,
payload: Omit<Partial<TSyncJobWithConfig<TSyncJobConfig>>, (typeof propertiesToOmit)[number]>
payload: Omit<Partial<TJobWithConfig<TSyncJobConfig>>, (typeof propertiesToOmit)[number]>
) {
// Make workspaceId and projectId required
return this.axiosInstance
.post(`/silo/api/jobs/`, {
.post(`/api/jobs/`, {
...payload,
workspace_id: workspaceId,
project_id: projectId,
@@ -67,9 +67,9 @@ export class SyncJobService<TSyncJobConfig extends object> {
* @param payload - Partial job data to update
* @returns Promise resolving to the updated Job object
*/
async updateSyncJob(jobId: string, payload: Partial<TSyncJobWithConfig<TSyncJobConfig>>) {
async update(jobId: string, payload: Partial<TJobWithConfig<TSyncJobConfig>>) {
return this.axiosInstance
.put(`/silo/api/jobs/${jobId}`, payload)
.put(`/api/jobs/${jobId}`, payload)
.then((res) => res.data)
.catch((error) => {
throw error?.response?.data;
@@ -81,9 +81,9 @@ export class SyncJobService<TSyncJobConfig extends object> {
* @param jobId - Unique identifier of the job to delete
* @returns Promise resolving to the deletion result
*/
async deleteSyncJob(jobId: string) {
async destroy(jobId: string) {
return this.axiosInstance
.delete(`/silo/api/jobs/${jobId}`)
.delete(`/api/jobs/${jobId}`)
.then((res) => res.data)
.catch((error) => {
throw error?.response?.data;
@@ -95,10 +95,10 @@ export class SyncJobService<TSyncJobConfig extends object> {
* @param payload - Partial job configuration data
* @returns Promise resolving to an object containing the inserted ID
*/
async createSyncJobConfig(payload: Partial<TSyncJobConfig>): Promise<{ insertedId: string }> {
async createConfig(payload: Partial<TSyncJobConfig>): Promise<{ insertedId: string }> {
const configPayload = { meta: payload };
return this.axiosInstance
.post(`/silo/api/job-configs`, configPayload)
.post(`/api/job-configs`, configPayload)
.then((res) => res.data)
.catch((error) => {
throw error?.response?.data;
@@ -108,15 +108,12 @@ export class SyncJobService<TSyncJobConfig extends object> {
/**
* @description Initiates a job.
* @param jobId - Unique identifier of the job to start
* @param migrationType - Type of migration, defaults to "JIRA"
* @param migrationType - Type of migration
* @returns Promise resolving to an array of Job objects
*/
async startSyncJob(
jobId: string,
migrationType: TSyncServices = "JIRA"
): Promise<TSyncJobWithConfig<TSyncJobConfig>[]> {
async start(jobId: string, migrationType: TImporterKeys): Promise<TJobWithConfig<TSyncJobConfig>[]> {
return this.axiosInstance
.post(`/silo/api/jobs/run`, { jobId, migrationType })
.post(`/api/jobs/run`, { jobId, migrationType })
.then((res) => res.data)
.catch((error) => {
throw error?.response?.data;
@@ -0,0 +1,6 @@
export enum E_FEATURE_FLAGS {
ISSUE_TYPE_DISPLAY = "ISSUE_TYPE_DISPLAY",
ISSUE_TYPE_SETTINGS = "ISSUE_TYPE_SETTINGS",
}
export type TFeatureFlags = keyof typeof E_FEATURE_FLAGS;
+125 -1
View File
@@ -1 +1,125 @@
export * from "./root";
export * from "./feature-flag";
import {
ExIssueProperty,
ExCycle,
ExIssue,
ExIssueLabel,
PlaneUser,
ExIssuePropertyValue,
ExIssueComment,
ExModule,
ExIssueType,
ExIssuePropertyOption,
} from "@plane/sdk";
export type TServiceCredentials = {
id: string;
source: string;
workspace_id: string;
user_id: string;
user_email: string;
source_access_token: string;
source_refresh_token: string;
source_hostname: string;
target_access_token: string;
};
// authentication configuration
export type TServiceAuthConfiguration = {
isAuthenticated: boolean;
isOAuthEnabled: boolean;
};
// importer types
export enum E_IMPORTER_KEYS {
JIRA = "JIRA",
ASANA = "ASANA",
LINEAR = "LINEAR",
TRELLO = "TRELLO",
}
export type TImporterKeys = keyof typeof E_IMPORTER_KEYS;
// integration types
export enum E_INTEGRATION_KEYS {
GITHUB = "GITHUB",
GITLAB = "GITLAB",
SLACK = "SLACK",
}
export type TIntegrationKeys = keyof typeof E_INTEGRATION_KEYS;
// job types
export enum E_JOB_STATUS {
CREATED = "CREATED",
INITIATED = "INITIATED",
PULLING = "PULLING",
PULLED = "PULLED",
TRANSFORMING = "TRANSFORMING",
TRANSFORMED = "TRANSFORMED",
PUSHING = "PUSHING",
FINISHED = "FINISHED",
ERROR = "ERROR",
}
export type TJobStatus = keyof typeof E_JOB_STATUS;
export type TJob = {
id: string;
config: string;
migration_type: TImporterKeys | TIntegrationKeys;
project_id: string;
workspace_id: string;
workspace_slug: string;
credentials_id: string;
initiator_id: string;
initiator_email: string;
source_user_email: string;
source_hostname: string;
source_task_count: number;
start_time?: Date;
end_time?: Date;
status: TJobStatus;
created_at: Date;
updated_at: Date;
error: string;
total_batch_count: number;
completed_batch_count: number;
transformed_batch_count: number;
};
export type TJobWithConfig<TJobConfig = unknown> = TJob & {
config: {
id: string;
meta: TJobConfig;
};
};
export const propertiesToOmit = [
"id",
"config",
"created_at",
"updated_at",
"start_time",
"end_time",
"project_id",
"workspace_slug",
] as const;
export type TJobConfigResponse = {
insertedId: string;
};
export type TPropertyValuesPayload = Record<string, ExIssuePropertyValue>; // property_id -> property_values
export type TIssuePropertyValuesPayload = Record<string, TPropertyValuesPayload>; // issue_id -> property_values_payload
export type PlaneEntities = {
labels: Partial<ExIssueLabel>[];
issues: Partial<ExIssue>[];
users: Partial<PlaneUser>[];
issue_comments: Partial<ExIssueComment>[];
cycles: Partial<ExCycle>[];
modules: Partial<ExModule>[];
issue_types?: Partial<ExIssueType>[];
issue_properties?: Partial<ExIssueProperty>[];
issue_property_options?: Partial<ExIssuePropertyOption>[];
issue_property_values?: TIssuePropertyValuesPayload;
};
-93
View File
@@ -1,93 +0,0 @@
// service account types
export type TSyncServiceCredentials = {
id: string;
source: string;
workspace_id: string;
user_id: string;
source_access_token: string;
source_refresh_token: string;
target_access_token: string;
};
export type TSyncServiceConfigured = {
isAuthenticated: boolean;
};
// importers
export enum E_IMPORTER_KEYS {
JIRA = "JIRA",
ASANA = "ASANA",
LINEAR = "LINEAR",
TRELLO = "TRELLO",
GITLAB = "GITLAB",
SLACK = "SLACK",
}
export type TImporterKeys = keyof typeof E_IMPORTER_KEYS;
// integrations
export enum E_INTEGRATION_KEYS {
GITHUB = "GITHUB",
}
export type TIntegrationKeys = keyof typeof E_INTEGRATION_KEYS;
// importers and integrations
export type TSyncServices = TImporterKeys | TIntegrationKeys;
export enum E_JOB_STATUS {
INITIATED = "INITIATED",
PULLING = "PULLING",
PULLED = "PULLED",
TRANSFORMING = "TRANSFORMING",
TRANSFORMED = "TRANSFORMED",
PUSHING = "PUSHING",
FINISHED = "FINISHED",
ERROR = "ERROR",
}
export type TSyncJobStatus = keyof typeof E_JOB_STATUS;
export type TSyncJob = {
id: string;
config: string;
migration_type: TSyncServices;
project_id: string;
workspace_id: string;
workspace_slug: string;
credentials_id: string;
initiator_id: string;
initiator_email: string;
source_user_email: string;
source_hostname: string;
source_task_count: number;
target_hostname: string;
start_time?: Date;
end_time?: Date;
status: TSyncJobStatus;
created_at: Date;
updated_at: Date;
error: string;
total_batch_count: number;
completed_batch_count: number;
transformed_batch_count: number;
};
export type TSyncJobWithConfig<TSyncJobConfig = unknown> = TSyncJob & {
config: {
id: string;
meta: TSyncJobConfig;
};
};
export const propertiesToOmit = [
"id",
"config",
"created_at",
"updated_at",
"start_time",
"end_time",
"project_id",
"workspace_slug",
] as const;
export type TSyncJobConfigResponse = {
insertedId: string;
};
+6 -7
View File
@@ -3,24 +3,23 @@
"version": "0.23.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"description": "Github package used by silo for importers and integrations",
"scripts": {
"dev": "tsc --watch",
"build": "tsc && tsc-alias",
"dev": "tsup --watch",
"build": "tsup",
"lint": "eslint --ext .ts src"
},
"description": "Github package used by silo for importers and integrations",
"author": "Plane Engineering",
"license": "AGPL",
"dependencies": {
"@plane/constants": "*",
"@plane/sdk": "*",
"@octokit/auth-app": "^7.1.0",
"@octokit/rest": "^21.0.1",
"axios": "^1.7.2"
},
"devDependencies": {
"@octokit/types": "^13.5.0",
"@octokit/auth-app": "^7.1.0",
"@octokit/rest": "^21.0.2",
"@octokit/openapi-webhooks-types": "^8.3.0",
"@plane/eslint-config": "*",
"@plane/typescript-config": "*",
"tsc-alias": "^1.8.10"
+1
View File
@@ -1 +1,2 @@
export * from "./pull";
export * from "./transform";
@@ -0,0 +1,65 @@
import { ExCycle, ExIssueLabel, ExIssue as PlaneIssue, PlaneUser } from "@plane/sdk";
import { GithubIssue, WebhookGitHubLabel, WebhookGitHubMilestone, WebhookGitHubUser } from "../types";
export const transformPlaneIssue = (
issue: PlaneIssue,
labels: ExIssueLabel[],
owner: string,
repo: string,
userMap: Record<string, WebhookGitHubUser>
): Partial<GithubIssue> => {
const githubIssueNumber = issue.links
?.find((link) => link.name === "Linked GitHub Issue")
?.url.split("/")
.pop();
const allAssignees = issue.assignees;
let allLabels = issue.labels;
let issueLabels = labels.filter((label) => allLabels.includes(label.id));
// If there is a github label, remove it and add a plane label
issueLabels = issueLabels.filter((label) => label.name.toLowerCase() !== "github");
const assignees =
allAssignees?.map((assignee) => userMap[assignee]?.login).filter((assignee) => assignee != undefined) || [];
const ghLabels = issueLabels?.map((label) => transformPlaneLabel(label)) || [];
ghLabels.push({
name: "plane",
color: "438bde",
});
// Remove the part from the issue body when we mention the creator
const htmlToRemove = /<p><em>Issue (updated|created) on GitHub By <\/em><a[^>]*><em>[^<]*<\/em><\/a><\/p>/gi;
const cleanHtml = issue.description_html.replace(htmlToRemove, "");
return {
id: parseInt(issue.external_id || "0"),
number: parseInt(githubIssueNumber || "0"),
title: issue.name,
body: cleanHtml,
owner: owner,
repo: repo,
state: issue.state === "Done" ? "CLOSED" : "OPEN",
created_at: issue.created_at,
assignees: assignees as string[],
labels: ghLabels,
};
};
export const transformPlaneLabel = (label: ExIssueLabel): Partial<WebhookGitHubLabel> => {
return {
name: label.name,
color: label.color.replace("#", ""),
};
};
export const transformPlaneCycle = (cycle: ExCycle): Partial<WebhookGitHubMilestone> => {
return {
id: parseInt(cycle.external_id || "0"),
title: cycle.name,
description: cycle.description,
created_at: cycle.created_at,
due_on: cycle.end_date,
};
};
@@ -0,0 +1,162 @@
import { ExCycle, ExIssueComment, ExIssueLabel, ExIssue as PlaneIssue, PlaneUser } from "@plane/sdk";
import { WebhookGitHubComment, WebhookGitHubIssue, WebhookGitHubLabel, WebhookGitHubMilestone } from "../types";
import { replaceIssueNumber, replaceMentionedGhUsers } from "../helpers";
export const transformGitHubIssue = (
issue: WebhookGitHubIssue,
repository: string,
userMap: Record<string, string>,
workspaceSlug: string,
planeUsers: PlaneUser[],
isUpdate: boolean = false
): Partial<PlaneIssue> => {
const links = [
{
name: "Linked GitHub Issue",
url: `https://github.com/${repository}/issues/${issue.number}`,
},
];
let planeAssignees: string[] = [];
let creator: string | undefined;
if (issue.user && issue.user.type === "User") {
creator = userMap[issue.user.login];
}
let issue_html = `<p>${issue.body || ""}</p>`;
if (!creator) {
const issueBody = issue.body || "";
const currentUserReference = `<a href="${issue.user?.html_url}">${issue.user?.login}</a>`;
// Regular expression to match the existing creator reference
const creatorReferenceRegex = /Issue (created|updated) on GitHub By <a href="[^"]*">[^<]*<\/a>/;
if (creatorReferenceRegex.test(issueBody)) {
// Update existing reference and add new one
const updatedBody = issueBody.replace(creatorReferenceRegex, "");
issue_html = `<p>${updatedBody}\n\n\nIssue ${isUpdate ? "updated" : "created"} on GitHub By ${currentUserReference}</p>`;
} else {
// Add new creator reference
issue_html = `<p>${issueBody}\n\n\nIssue ${isUpdate ? "updated" : "created"} on GitHub By ${currentUserReference}</p>`;
}
}
// Replace the mentioned github users in the issue body
issue_html = replaceMentionedGhUsers(issue_html, workspaceSlug, userMap, planeUsers);
// Replace the issue number with the actual issue number in github
issue_html = replaceIssueNumber(issue_html, repository);
if (issue.assignees) {
planeAssignees = issue.assignees
.map((assignee) => {
if (assignee != null) {
return userMap[assignee.login];
}
})
.filter((assignee) => assignee != undefined) as string[];
}
let labels = issue.labels?.map((label) => (typeof label === "string" ? label : label.name)) || [];
labels = labels.filter((label) => label.toLowerCase() !== "plane");
labels.push("github");
return {
external_id: issue.number.toString(),
external_source: "GITHUB",
created_by: creator,
name: issue.title,
description_html: issue_html,
created_at: issue.created_at,
state: issue.state === "open" ? "Backlog" : "Done",
priority: "none",
labels: labels,
assignees: planeAssignees,
links,
};
};
export const transformGitHubLabel = (label: WebhookGitHubLabel): Partial<ExIssueLabel> => {
return {
name: label.name,
color: `#${label.color}`,
external_id: label.id.toString(),
external_source: "GITHUB",
};
};
export const transformGitHubComment = (
comment: WebhookGitHubComment,
issueId: string,
repository: string,
workspaceSlug: string,
userMap: Record<string, string>,
planeUsers: PlaneUser[],
isUpdate: boolean = false
): Partial<ExIssueComment> => {
let creator: string | undefined;
if (comment.user && comment.user.type === "User") {
creator = userMap[comment.user.login];
}
let comment_html = `<p>${comment.body || ""}</p>`;
if (!creator) {
const commentBody = (comment.body || "").trim();
const currentUserReference = `<a href="${comment.user?.html_url}">${comment.user?.login}</a>`;
// Regular expression to match the existing creator reference
const creatorReferenceRegex = /Comment (created|updated) on GitHub By <a href="[^"]*">[^<]*<\/a>_/;
let updatedBody;
if (creatorReferenceRegex.test(commentBody)) {
// Update existing reference and add new one
updatedBody = commentBody.replace(creatorReferenceRegex, "").trim();
} else {
// Use the original comment body
updatedBody = commentBody;
}
// Add new creator reference
const creatorReference = `Comment ${isUpdate ? "updated" : "created"} on GitHub By ${currentUserReference}`;
// Combine the body and creator reference, replacing multiple newlines with a single one
comment_html = `${updatedBody}\n\n${creatorReference}`
.replace(/\n{3,}/g, "\n\n")
.replace(/^\s+|\s+$/g, "") // Remove leading and trailing whitespace
.replace(/\s*(<\/?p>)\s*/g, "$1"); // Remove spaces around <p> tags
// Only wrap in <p> tags if it's not already wrapped
if (!comment_html.startsWith("<p>") && !comment_html.endsWith("</p>")) {
comment_html = `<p>${comment_html}</p>`;
}
}
comment_html = replaceMentionedGhUsers(comment_html, workspaceSlug, userMap, planeUsers);
comment_html = replaceIssueNumber(comment_html!, repository);
return {
external_id: comment.id.toString(),
external_source: "GITHUB",
created_at: comment.created_at,
created_by: creator || undefined,
comment_html: comment_html,
actor: creator || undefined,
issue: issueId,
};
};
export const transformGitHubMilestone = (milestone: WebhookGitHubMilestone): Partial<ExCycle> => {
return {
external_id: milestone.id.toString(),
external_source: "GITHUB",
name: milestone.title,
description: milestone.description || undefined,
start_date: milestone.created_at,
end_date: milestone.due_on || undefined,
};
};
@@ -0,0 +1,2 @@
export * from "./transform-to-plane";
export * from "./transform-to-github";
@@ -0,0 +1,79 @@
import { PlaneUser } from "@plane/sdk";
export const replaceMentionedGhUsers = (
html: string,
workspaceSlug: string,
userMap: Record<string, string>,
planeUsers: PlaneUser[],
) => {
// Check if there is an @Pattern in there if there is a @Pattern, replace that
// with the github url of that user
const pattern = /@([a-zA-Z0-9-]+)/g;
const matches = html.match(pattern);
if (matches) {
matches.forEach((match) => {
console.log(userMap);
const username = match.slice(1);
console.log(username);
// Only add link if the user does not exists in the userMap
if (!userMap[username]) {
html = html.replace(
match,
`<a href=https://github.com/${username}>From GitHub: ${username}</a>`,
);
} else {
// Get the user from the planeUsers
const user = planeUsers.find((user) => user.id === userMap[username]);
// If the user exist then replace the user with the mention component,
// else keep the link to the github user
if (user) {
const component = createMentionComponent(workspaceSlug, user);
html = html.replace(match, component);
} else {
html = html.replace(
match,
`<a href=https://github.com/${username}>From GitHub: ${username}</a>`,
);
}
}
});
return html;
}
return html;
};
export const createMentionComponent = (
workspaceSlug: string,
user: PlaneUser,
): string => {
return `<mention-component
entity_name="user_mention"
label="${user.display_name}"
entity_identifier="${user.id}"
id="${user.id}"
type="User"
title="${user.display_name}"
subtitle="${user.email ?? ""}"
avatar="${user.avatar}"
redirect_uri="/${workspaceSlug}/profile/${user.id}"
></mention-component>`;
};
export const replaceIssueNumber = (html: string, repo: string) => {
const pattern = /#(\d+)/g;
const matches = html.match(pattern);
if (matches) {
matches.forEach((match) => {
const issueNumber = match.slice(1);
html = html.replace(
match,
`<a href=https://github.com/${repo}/issues/${issueNumber}>${repo} #${issueNumber}</a>`,
);
});
return html;
}
return html;
};
@@ -0,0 +1 @@
export * from "./helpers";
+158 -18
View File
@@ -1,3 +1,4 @@
import { GithubApiProps, GithubIssue } from "@/types";
import { createAppAuth } from "@octokit/auth-app";
import { Octokit } from "@octokit/rest";
@@ -5,22 +6,34 @@ import { Octokit } from "@octokit/rest";
export class GithubService {
private client: Octokit;
constructor(appId: string, privateKey: string, installationId: string) {
this.client = new Octokit({
authStrategy: createAppAuth,
auth: {
appId: appId,
privateKey: privateKey,
installationId: installationId,
},
userAgent: "octokit/rest.js v1.2.3",
constructor(params: GithubApiProps) {
if (params.forUser) {
this.client = new Octokit({
auth: params.accessToken,
});
} else {
this.client = new Octokit({
authStrategy: createAppAuth,
auth: {
appId: params.appId,
privateKey: params.privateKey,
installationId: params.installationId,
},
userAgent: "octokit/rest.js v1.2.3",
});
}
}
async getAccessTokens(installationId: number) {
const token = await this.client.apps.createInstallationAccessToken({
installation_id: installationId,
});
return token;
}
async getRepos() {
const pageRepos = this.client.paginate.iterator(
this.client.apps.listReposAccessibleToInstallation,
);
const pageRepos = this.client.paginate.iterator(this.client.apps.listReposAccessibleToInstallation);
const data: any = [];
for await (const { data: repos } of pageRepos) {
@@ -31,13 +44,39 @@ export class GithubService {
return data;
}
async createIssue(issue: GithubIssue) {
return this.client.issues.create(issue);
}
async updateIssue(id: number, issue: GithubIssue) {
return this.client.issues.update({
issue_number: id,
...issue,
});
}
async createIssueComment(owner: string, repo: string, issue_number: number, body: string) {
return this.client.issues.createComment({
owner,
repo,
issue_number,
body,
});
}
async updateIssueComment(owner: string, repo: string, comment_id: number, body: string) {
return this.client.issues.updateComment({
owner,
repo,
comment_id,
body,
});
}
async getReposForInstallation(installationId: number) {
const pageRepos = this.client.paginate.iterator(
this.client.apps.listReposAccessibleToInstallation,
{
installation_id: installationId,
},
);
const pageRepos = this.client.paginate.iterator(this.client.apps.listReposAccessibleToInstallation, {
installation_id: installationId,
});
const data: any = [];
@@ -49,6 +88,74 @@ 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,
pull_number: number
): Promise<PRClosingReferences> {
const issues = this.client.graphql<PRClosingReferences>({
query: `
query($owner: String!, $repo: String!, $pull_number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pull_number) {
id
closingIssuesReferences (first: 50) {
edges {
node {
id
databaseId
body
number
title
}
}
}
}
}
}
`,
owner,
repo,
pull_number,
});
return issues;
}
async getPullRequestComments(owner: string, repo: string, pull_number: number) {
return this.client.issues.listComments({
owner,
repo,
issue_number: pull_number,
});
}
async updatePullRequestComment(owner: string, repo: string, comment_id: number, body: string) {
return this.client.issues.updateComment({
owner,
repo,
comment_id,
body,
});
}
async createPullRequestComment(owner: string, repo: string, pull_number: number, body: string) {
return this.client.issues.createComment({
owner,
repo,
issue_number: pull_number,
body,
});
}
async searchRepos(query: string) {
return this.client.search.repos({
q: query,
@@ -62,6 +169,14 @@ export class GithubService {
});
}
async getIssue(owner: string, repo: string, issue_number: number) {
return this.client.issues.get({
owner,
repo,
issue_number,
});
}
async getLabels(owner: string, repo: string) {
return this.client.issues.listLabelsForRepo({
owner,
@@ -82,6 +197,12 @@ export class GithubService {
});
}
async getOrganizationMembers(org: string) {
return this.client.orgs.listMembers({
org: org,
});
}
async getUsersForRepo(owner: string, repo: string) {
return this.client.repos.listCollaborators({
owner,
@@ -95,3 +216,22 @@ export class GithubService {
});
}
}
export type PRClosingReferences = {
repository: {
pullRequest: {
id: string;
closingIssuesReferences: {
edges: {
node: {
id: string;
body: string;
number: number;
databaseId: number;
title: string;
};
}[];
};
};
};
};
@@ -1,6 +1,11 @@
import { GithubAuthConfig, GithubAuthorizeState, GithubAuthPayload, TokenResponse } from "@/types";
import { GithubAuthConfig, GithubAuthorizeState, GithubUserAuthPayload, GithubUserAuthState } from "@/types";
import axios from "axios";
export type GithubTokenResponse = {
access_token: string;
scope: string;
};
export class GithubAuthService {
config: GithubAuthConfig;
@@ -15,7 +20,14 @@ export class GithubAuthService {
*/
getAuthUrl(state: GithubAuthorizeState): string {
const stateString = JSON.stringify(state);
return `https://github.com/apps/${this.config.appName}/installations/select_target?state=${stateString}`;
const encodedState = Buffer.from(stateString).toString("base64");
return `https://github.com/apps/${this.config.appName}/installations/select_target?state=${encodedState}`;
}
getUserAuthUrl(state: GithubUserAuthState): string {
const stateString = JSON.stringify(state);
const encodedState = Buffer.from(stateString).toString("base64");
return `https://github.com/login/oauth/authorize?client_id=${this.config.clientId}&redirect_uri=${this.config.callbackUrl}&state=${encodedState}`;
}
/**
@@ -23,34 +35,17 @@ export class GithubAuthService {
* @param payload An object containing the authorization code and state
* @returns A promise that resolves to an object containing the token response and state
*/
async getAccessToken(payload: GithubAuthPayload): Promise<{
response: TokenResponse;
state: GithubAuthorizeState;
async getUserAccessToken(payload: GithubUserAuthPayload): Promise<{
response: string;
state: GithubUserAuthState;
}> {
const { code, state } = payload;
const data = {
const { data: response } = await axios.post("https://github.com/login/oauth/access_token", {
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
code,
redirect_uri: this.config.callbackUrl,
};
const { data: response } = await axios.post(this.config.tokenUrl, data);
});
return { response, state };
}
/**
* Refreshes an existing access token using a refresh token
* @param refresh_token The refresh token to use for obtaining a new access token
* @returns A promise that resolves to the new token response
*/
async getRefreshToken(refresh_token: string): Promise<TokenResponse> {
const data = {
refresh_token: refresh_token,
grant_type: "refresh_token",
};
const { data: response } = await axios.post(this.config.tokenUrl, data);
return response;
}
}
@@ -0,0 +1,35 @@
import { GithubService } from "./api.service";
import { GithubAuthService } from "./auth.service";
export const createGithubAuth = (
appName: string = "",
clientId: string = "",
clientSecret: string = "",
callbackUrl: string
): GithubAuthService => {
if (!appName || !clientId || !clientSecret) {
console.error("[GITHUB] App name, client ID and client secret are required");
}
return new GithubAuthService({
appName,
clientId,
clientSecret,
callbackUrl,
});
};
export const createGithubService = (
GithubAppId: string | undefined,
GithubPrivateKey: string | undefined,
installationId: string
): GithubService => {
if (!GithubAppId || !GithubPrivateKey) {
throw new Error("GithubAppId and GithubPrivateKey are required");
}
return new GithubService({
appId: GithubAppId,
privateKey: GithubPrivateKey,
installationId,
forUser: false,
});
};
@@ -1,2 +1,3 @@
export * from "./builder";
export * from "./auth.service";
export * from "./api.service";
+53 -5
View File
@@ -1,23 +1,47 @@
import { components } from "@octokit/openapi-webhooks-types";
import { RestEndpointMethodTypes } from "@octokit/rest";
export type GithubEntity = {};
export type GithubConfig = {};
export type GithubConfig = {
usermap: Record<string, string>;
};
export type GithubInstallation =
RestEndpointMethodTypes["apps"]["getInstallation"]["response"]["data"];
export type GithubInstallation = RestEndpointMethodTypes["apps"]["getInstallation"]["response"]["data"];
export type GithubRepository =
RestEndpointMethodTypes["apps"]["listReposAccessibleToInstallation"]["response"]["data"]["repositories"];
export type GithubIssue = RestEndpointMethodTypes["issues"]["create"]["parameters"];
export type WebhookGitHubIssue = components["schemas"]["webhooks_issue"];
export type WebhookGitHubLabel = components["schemas"]["webhooks_label"];
export type WebhookGitHubComment = components["schemas"]["webhooks_comment"];
export type WebhookGitHubMilestone = components["schemas"]["webhooks_milestone"];
export type WebhookGitHubUser = components["schemas"]["webhooks_user"];
export type GithubSimpleUser = components["schemas"]["simple-user"];
export type GithubWebhookPayload = components["schemas"];
export type GithubWebhookEvent = keyof GithubWebhookPayload;
export type GithubAuthPayload = {
code: string;
state: GithubAuthorizeState;
};
export type GithubUserAuthPayload = {
code: string;
state: GithubUserAuthState;
};
export type GithubAuthorizeState = {
workspace_slug: string;
workspace_id: string;
plane_api_token: string;
target_host: string;
};
export type GithubUserAuthState = GithubAuthorizeState & {
user_id: string;
};
export type TokenResponse = {
@@ -30,7 +54,31 @@ export type TokenResponse = {
};
export type GithubAuthConfig = {
tokenUrl: string;
callbackUrl: string;
appName: string;
clientId: string;
clientSecret: string;
callbackUrl: string;
};
export type GithubIssueDedupPayload = {
accountId: string;
issueNumber: string;
owner: string;
repositoryId: string;
installationId: string;
repositoryName: string;
};
export type AppAuthParams = {
appId: string;
privateKey: string;
installationId: string;
forUser?: false;
};
export type UserAuthParams = {
forUser: true;
accessToken: string;
};
export type GithubApiProps = AppAuthParams | UserAuthParams;
-1
View File
@@ -2,7 +2,6 @@
"extends": "@plane/typescript-config/base.json",
"compilerOptions": {
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig, Options } from "tsup";
export default defineConfig((options: Options) => ({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
clean: false,
external: ["react"],
...options,
}));
+9
View File
@@ -0,0 +1,9 @@
// /** @type {import("eslint").Linter.Config} */
// module.exports = {
// root: true,
// extends: ["@plane/eslint-config/library.js"],
// parser: "@typescript-eslint/parser",
// parserOptions: {
// project: true,
// },
// };
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@silo/gitlab",
"version": "0.23.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"dev": "tsc --watch",
"build": "tsc && tsc-alias",
"lint": "eslint src --ext .ts"
},
"description": "Gitlab package used by silo for importers and integrations",
"author": "Plane Engineering",
"license": "AGPL",
"dependencies": {
"@plane/constants": "*",
"@plane/sdk": "*",
"zod": "^3.23.8",
"axios": "^1.7.2"
},
"devDependencies": {
"typescript": "^5.3.3",
"@plane/eslint-config": "*",
"@plane/typescript-config": "*",
"tsc-alias": "^1.8.10"
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./pull";
export * from "./transform";
+1
View File
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,78 @@
import { ExCycle, ExIssueLabel, ExIssue as PlaneIssue } from "@plane/sdk";
import {
GitlabIssue,
GitlabLabel,
GitlabMilestone,
GitlabUser,
} from "../types";
export const transformPlaneIssue = (
issue: PlaneIssue,
labels: ExIssueLabel[],
projectId: number,
userMap: Record<string, GitlabUser>,
): Partial<GitlabIssue> => {
const gitlabIssueIid = issue.links
?.find((link) => link.name === "Linked GitLab Issue")
?.url.split("/")
.pop();
const allAssignees = issue.assignees;
let allLabels = issue.labels;
let issueLabels = labels.filter((label) => allLabels.includes(label.id));
// If there is a gitlab label, remove it and add a plane label
issueLabels = issueLabels.filter(
(label) => label.name.toLowerCase() !== "gitlab",
);
const assignees =
allAssignees
?.map((assignee) => userMap[assignee])
.filter((assignee) => assignee != undefined) || [];
const glLabels =
issueLabels?.map((label) => transformPlaneLabel(label)) || [];
glLabels.push({
name: "plane",
color: "#438bde",
});
// Remove the part from the issue body when we mention the creator
const htmlToRemove =
/<p><em>Issue (updated|created) on GitLab By <\/em><a[^>]*><em>[^<]*<\/em><\/a><\/p>/gi;
const cleanHtml = issue.description_html.replace(htmlToRemove, "");
return {
id: parseInt(issue.external_id || "0"),
iid: parseInt(gitlabIssueIid || "0"),
title: issue.name,
description: cleanHtml,
project_id: projectId,
state: issue.state === "Done" ? "closed" : "opened",
created_at: issue.created_at,
assignees: assignees,
labels: glLabels.map((label) => label.name ?? ""),
};
};
export const transformPlaneLabel = (
label: ExIssueLabel,
): Partial<GitlabLabel> => {
return {
name: label.name,
color: label.color,
};
};
export const transformPlaneCycle = (
cycle: ExCycle,
): Partial<GitlabMilestone> => {
return {
id: parseInt(cycle.external_id || "0"),
title: cycle.name,
description: cycle.description,
created_at: cycle.created_at,
due_date: cycle.end_date,
};
};
@@ -0,0 +1,175 @@
import {
ExCycle,
ExIssueComment,
ExIssueLabel,
ExIssue as PlaneIssue,
PlaneUser,
} from "@plane/sdk";
import {
GitlabIssue,
GitlabLabel,
GitlabMilestone,
GitlabNote,
} from "../types";
import { replaceIssueNumber, replaceMentionedGlUsers } from "../helpers";
export const transformGitlabIssue = (
issue: GitlabIssue,
projectId: number,
userMap: Record<string, string>,
workspaceSlug: string,
planeUsers: PlaneUser[],
isUpdate: boolean = false,
): Partial<PlaneIssue> => {
const links = [
{
name: "Linked GitLab Issue",
url: issue.web_url,
},
];
let planeAssignees: string[] = [];
let creator: string | undefined;
if (issue.author) {
creator = userMap[issue.author.username];
}
let issue_html = `<p>${issue.description || ""}</p>`;
if (!creator) {
const issueBody = issue.description || "";
const currentUserReference = `<a href="${issue.author?.web_url}">${issue.author?.name}</a>`;
const creatorReferenceRegex =
/Issue (created|updated) on GitLab By <a href="[^"]*">[^<]*<\/a>/;
if (creatorReferenceRegex.test(issueBody)) {
const updatedBody = issueBody.replace(creatorReferenceRegex, "");
issue_html = `<p>${updatedBody}\n\n\nIssue ${isUpdate ? "updated" : "created"} on GitLab By ${currentUserReference}</p>`;
} else {
issue_html = `<p>${issueBody}\n\n\nIssue ${isUpdate ? "updated" : "created"} on GitLab By ${currentUserReference}</p>`;
}
}
issue_html = replaceMentionedGlUsers(
issue_html,
workspaceSlug,
userMap,
planeUsers,
);
issue_html = replaceIssueNumber(issue_html, projectId.toString());
if (issue.assignees) {
planeAssignees = issue.assignees
.map((assignee) => userMap[assignee.username])
.filter((assignee) => assignee != undefined) as string[];
}
let labels = issue.labels || [];
labels = labels.filter((label) => label.toLowerCase() !== "plane");
labels.push("gitlab");
return {
external_id: issue.iid.toString(),
external_source: "GITLAB",
created_by: creator,
name: issue.title,
description_html: issue_html,
created_at: issue.created_at,
state: issue.state === "opened" ? "Backlog" : "Done",
priority: "none",
labels: labels,
assignees: planeAssignees,
links,
};
};
export const transformGitlabLabel = (
label: GitlabLabel,
): Partial<ExIssueLabel> => {
return {
name: label.name,
color: label.color,
external_id: label.id.toString(),
external_source: "GITLAB",
};
};
export const transformGitlabComment = (
comment: GitlabNote,
issueId: string,
projectId: number,
workspaceSlug: string,
userMap: Record<string, string>,
planeUsers: PlaneUser[],
isUpdate: boolean = false,
): Partial<ExIssueComment> => {
let creator: string | undefined;
if (comment.author) {
creator = userMap[comment.author.username];
}
let comment_html = `<p>${comment.body || ""}</p>`;
if (!creator) {
const commentBody = (comment.body || "").trim();
// const currentUserReference = `<a href="${comment.author?.web_url}">${comment.author?.name}</a>`;
const currentUserReference = `<a href="${"https://gitlab.com"}">${comment.author?.name}</a>`;
const creatorReferenceRegex =
/Comment (created|updated) on GitLab By <a href="[^"]*">[^<]*<\/a>_/;
let updatedBody: string;
if (creatorReferenceRegex.test(commentBody)) {
updatedBody = commentBody.replace(creatorReferenceRegex, "").trim();
} else {
updatedBody = commentBody;
}
const creatorReference = `Comment ${isUpdate ? "updated" : "created"} on GitLab By ${currentUserReference}`;
comment_html = `${updatedBody}\n\n${creatorReference}`
.replace(/\n{3,}/g, "\n\n")
.replace(/^\s+|\s+$/g, "")
.replace(/\s*(<\/?p>)\s*/g, "$1");
if (!comment_html.startsWith("<p>") && !comment_html.endsWith("</p>")) {
comment_html = `<p>${comment_html}</p>`;
}
}
comment_html = replaceMentionedGlUsers(
comment_html,
workspaceSlug,
userMap,
planeUsers,
);
comment_html = replaceIssueNumber(comment_html!, projectId.toString());
return {
external_id: comment.id.toString(),
external_source: "GITLAB",
created_at: comment.created_at,
created_by: creator || undefined,
comment_html: comment_html,
actor: creator || undefined,
issue: issueId,
};
};
export const transformGitlabMilestone = (
milestone: GitlabMilestone,
): Partial<ExCycle> => {
return {
external_id: milestone.id.toString(),
external_source: "GITLAB",
name: milestone.title,
description: milestone.description || undefined,
start_date: milestone.start_date || milestone.created_at,
end_date: milestone.due_date || undefined,
};
};
@@ -0,0 +1,2 @@
export * from "./transform-to-plane";
export * from "./transform-to-gitlab";
+10
View File
@@ -0,0 +1,10 @@
import { PlaneUser } from "@plane/sdk";
// Mock implementations for missing helpers
export const replaceIssueNumber = (text: string, projectId: string) => text;
export const replaceMentionedGlUsers = (
text: string,
workspaceSlug: string,
userMap: Record<string, string>,
planeUsers: PlaneUser[],
) => text;
+3
View File
@@ -0,0 +1,3 @@
export * from "./etl";
export * from "./types";
export * from "./services";
@@ -0,0 +1,143 @@
import { GitlabUser } from "@/types";
import axios, { AxiosInstance } from "axios";
export class GitLabService {
private client: AxiosInstance;
constructor(
access_token: string,
refresh_token: string,
refresh_callback: (
access_token: string,
refresh_token: string,
) => Promise<void>,
hostname: string = "gitlab.com",
) {
this.client = axios.create({
baseURL: "https://" + hostname + "/api/v4",
headers: {
Authorization: `Bearer ${access_token}`,
},
});
this.client.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
const response = await axios.post("https://gitlab.com/oauth/token", {
client_id: process.env.GITLAB_CLIENT_ID,
client_secret: process.env.GITLAB_CLIENT_SECRET,
refresh_token: refresh_token,
grant_type: "refresh_token",
});
await refresh_callback(
response.data.access_token,
response.data.refresh_token,
);
const new_access_token = response.data.access_token;
this.client.defaults.headers.Authorization = `Bearer ${new_access_token}`;
return this.client.request(error.config);
}
return Promise.reject(error);
},
);
}
async createMergeRequestComment(
projectId: number,
mergeRequestIid: number,
body: string,
) {
const response = await this.client.post(
`/projects/${projectId}/merge_requests/${mergeRequestIid}/notes`,
{ body },
);
return response.data;
}
async getMergeRequestComments(projectId: number, mergeRequestIid: number) {
const response = await this.client.get(
`/projects/${projectId}/merge_requests/${mergeRequestIid}/notes`,
);
return response.data;
}
async updateMergeRequestComment(
projectId: number,
mergeRequestIid: number,
noteId: number,
body: string,
) {
const response = await this.client.put(
`/projects/${projectId}/merge_requests/${mergeRequestIid}/notes/${noteId}`,
{ body },
);
return response.data;
}
async getUser(): Promise<GitlabUser> {
const response = await this.client.get("/user");
return response.data;
}
async getRepos() {
const response = await this.client.get("/projects");
return response.data;
}
async createIssue(
projectId: number,
issue: { title: string; description: string },
) {
const response = await this.client.post(
`/projects/${projectId}/issues`,
issue,
);
return response.data;
}
async updateIssue(
projectId: number,
issueId: number,
issue: { title?: string; description?: string },
) {
const response = await this.client.put(
`/projects/${projectId}/issues/${issueId}`,
issue,
);
return response.data;
}
async createIssueComment(projectId: number, issueId: number, body: string) {
const response = await this.client.post(
`/projects/${projectId}/issues/${issueId}/notes`,
{ body },
);
return response.data;
}
async updateIssueComment(projectId: number, noteId: number, body: string) {
const response = await this.client.put(
`/projects/${projectId}/issues/notes/${noteId}`,
{ body },
);
return response.data;
}
async getIssues(projectId: number) {
const response = await this.client.get(`/projects/${projectId}/issues`);
return response.data;
}
async getLabels(projectId: number) {
const response = await this.client.get(`/projects/${projectId}/labels`);
return response.data;
}
async getUsersForRepo(projectId: number) {
const response = await this.client.get(`/projects/${projectId}/members`);
return response.data;
}
}
@@ -0,0 +1,71 @@
import axios from "axios";
import {
GitLabAuthConfig,
GitLabAuthorizeState,
GitLabAuthPayload,
GitLabTokenResponse,
} from "../types/auth";
const DEFAULT_SCOPES = [
"api",
"read_api",
"read_user",
"read_repository",
"profile",
"email",
];
export class GitLabAuthService {
config: GitLabAuthConfig;
scopes: string[];
constructor(config: GitLabAuthConfig, scopes: string[] = DEFAULT_SCOPES) {
this.config = config;
this.scopes = scopes;
}
private getScopesString(): string {
return encodeURIComponent(this.scopes.join(" "));
}
/**
* Generates the authorization URL for GitLab OAuth
* @param state The state object to be passed to GitLab
* @returns The full authorization URL as a string
*/
getAuthUrl(state: GitLabAuthorizeState): string {
const stateString = JSON.stringify(state);
const encodedState = Buffer.from(stateString).toString("base64");
let hostname = this.config.host || "gitlab.com";
return `https://${hostname}/oauth/authorize?client_id=${this.config.clientId}&redirect_uri=${this.config.redirectUri}&response_type=code&state=${encodedState}&scope=${this.getScopesString()}`;
}
/**
* Exchanges the authorization code for an access token
* @param payload An object containing the authorization code and state
* @returns A promise that resolves to an object containing the token response and state
*/
async getAccessToken(payload: GitLabAuthPayload): Promise<{
response: GitLabTokenResponse;
state: GitLabAuthorizeState;
}> {
const { code, state } = payload;
const { data: response } = await axios.post<GitLabTokenResponse>(
"https://gitlab.com/oauth/token",
{
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
code,
grant_type: "authorization_code",
redirect_uri: this.config.redirectUri,
},
);
const decodedState = JSON.parse(
Buffer.from(state, "base64").toString(),
) as GitLabAuthorizeState;
return { response, state: decodedState };
}
}
@@ -0,0 +1,31 @@
import { GitLabAuthService } from "./auth.service";
import { GitLabService } from "./api.service";
export const createGitLabAuth = (props: {
host?: string;
clientId: string | undefined;
clientSecret: string | undefined;
redirectUri: string;
}): GitLabAuthService => {
const { host, clientId, clientSecret, redirectUri } = props;
if (!clientId || !clientSecret) {
console.error("[GITLAB] Client ID and client secret are required");
}
return new GitLabAuthService({
host,
clientId: clientId ?? "",
clientSecret: clientSecret ?? "",
redirectUri,
});
};
export const createGitLabService = (
access_token: string,
refresh_token: string,
refresh_callback: (
access_token: string,
refresh_token: string,
) => Promise<void>,
hostname: string = "gitlab.com",
): GitLabService =>
new GitLabService(access_token, refresh_token, refresh_callback, hostname);
@@ -0,0 +1,3 @@
export * from "./api.service";
export * from "./auth.service";
export * from "./builder";
+29
View File
@@ -0,0 +1,29 @@
export type GitLabAuthConfig = {
host?: string;
clientId: string;
clientSecret: string;
redirectUri: string;
};
export type GitLabAuthorizeState = {
user_id: string;
workspace_slug: string;
workspace_id: string;
plane_api_token: string;
gitlab_hostname: string;
target_host: string;
};
export type GitLabAuthPayload = {
code: string;
state: string;
};
export type GitLabTokenResponse = {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
created_at: number;
};
+149
View File
@@ -0,0 +1,149 @@
// GitlabIssue type
export interface GitlabIssue {
id: number;
iid: number;
project_id: number;
title: string;
description: string;
state: "opened" | "closed";
created_at: string;
updated_at: string;
closed_at: string | null;
labels: string[];
milestone: GitlabMilestone | null;
assignees: GitlabUser[];
author: GitlabUser;
confidential: boolean;
due_date: string | null;
web_url: string;
}
// GitlabLabel type
export interface GitlabLabel {
id: number;
name: string;
color: string;
description: string | null;
text_color: string;
}
// GitlabMilestone type
export interface GitlabMilestone {
id: number;
iid: number;
project_id: number;
title: string;
description: string;
state: "active" | "closed";
created_at: string;
updated_at: string;
due_date: string | null;
start_date: string | null;
web_url: string;
}
// GitlabUser type
export interface GitlabUser {
id: number;
username: string;
name: string;
state: "active" | "blocked";
avatar_url: string;
web_url: string;
}
// Additional types that might be useful
// GitlabProject type
export interface GitlabProject {
id: number;
name: string;
description: string;
web_url: string;
avatar_url: string | null;
git_ssh_url: string;
git_http_url: string;
namespace: {
id: number;
name: string;
path: string;
kind: string;
full_path: string;
};
default_branch: string;
visibility: "private" | "internal" | "public";
}
// GitlabMergeRequest type
export interface GitlabMergeRequest {
id: number;
iid: number;
project_id: number;
title: string;
description: string;
state: "opened" | "closed" | "locked" | "merged";
created_at: string;
updated_at: string;
merged_at: string | null;
closed_at: string | null;
target_branch: string;
source_branch: string;
user_notes_count: number;
upvotes: number;
downvotes: number;
author: GitlabUser;
assignees: GitlabUser[];
reviewers: GitlabUser[];
source_project_id: number;
target_project_id: number;
labels: string[];
work_in_progress: boolean;
milestone: GitlabMilestone | null;
merge_when_pipeline_succeeds: boolean;
merge_status: "can_be_merged" | "cannot_be_merged";
sha: string;
merge_commit_sha: string | null;
squash_commit_sha: string | null;
discussion_locked: boolean;
should_remove_source_branch: boolean;
force_remove_source_branch: boolean;
reference: string;
web_url: string;
time_stats: {
time_estimate: number;
total_time_spent: number;
human_time_estimate: string | null;
human_total_time_spent: string | null;
};
squash: boolean;
task_completion_status: {
count: number;
completed_count: number;
};
}
export interface GitlabNote {
id: number;
body: string;
attachment: string | null;
author: {
id: number;
username: string;
email: string;
name: string;
state: string;
created_at: string;
};
created_at: string;
updated_at: string;
system: boolean;
noteable_id: number;
noteable_type: "Issue" | "MergeRequest" | "Snippet" | "Epic";
project_id: number;
noteable_iid: number;
resolvable: boolean;
confidential: boolean;
internal: boolean;
imported?: boolean;
imported_from?: string;
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./common";
export * from "./webhook";
export * from "./auth";
export * from "./schema";
+26
View File
@@ -0,0 +1,26 @@
import z from "zod";
export type MergeRequestEvent =
| "DRAFT_MR_OPENED"
| "MR_OPENED"
| "MR_REVIEW_REQUESTED"
| "MR_READY_FOR_MERGE"
| "MR_MERGED"
| "MR_CLOSED";
const exStateSchema = z.object({
id: z.string(),
name: z.string(),
status: z.literal("to_be_created"),
});
export const gitlabWorkspaceConnectionSchema = z.object({});
export const gitlabEntityConnectionSchema = z.object({
states: z.object({
mergeRequestEventMapping: z.record(
z.custom<MergeRequestEvent>(),
exStateSchema,
),
}),
});
+124
View File
@@ -0,0 +1,124 @@
import {
GitlabIssue,
GitlabLabel,
GitlabMergeRequest,
GitlabNote,
GitlabProject,
GitlabUser,
} from "./common";
// Base webhook event type
export interface GitlabWebhookEvent {
object_kind: string;
event_type?: string;
user: GitlabUser;
project: GitlabProject;
repository: {
name: string;
url: string;
description: string;
homepage: string;
};
}
// Push event
export interface GitlabPushEvent extends GitlabWebhookEvent {
object_kind: "push";
before: string;
after: string;
ref: string;
checkout_sha: string;
commits: {
id: string;
message: string;
title: string;
timestamp: string;
url: string;
author: {
name: string;
email: string;
};
added: string[];
modified: string[];
removed: string[];
}[];
total_commits_count: number;
}
// Issue event
export interface GitlabIssueEvent extends GitlabWebhookEvent {
object_kind: "issue";
object_attributes: GitlabIssue & {
action: string;
url: string;
};
labels: GitlabLabel[];
changes: {
[key: string]: {
previous: any;
current: any;
};
};
assignees: GitlabUser[];
}
// Merge request event
export interface GitlabMergeRequestEvent extends GitlabWebhookEvent {
object_kind: "merge_request";
object_attributes: GitlabMergeRequest & {
action: string;
url: string;
source: GitlabProject;
target: GitlabProject;
last_commit: {
id: string;
message: string;
timestamp: string;
url: string;
author: {
name: string;
email: string;
};
};
};
labels: GitlabLabel[];
changes: {
[key: string]: {
previous: any;
current: any;
};
};
}
// Note event
export interface NoteEvent extends GitlabWebhookEvent {
object_kind: "note";
object_attributes: GitlabNote & {
url: string;
noteable_type: "Issue" | "MergeRequest" | "Snippet" | "Commit";
};
issue?: GitlabIssue;
merge_request?: GitlabMergeRequest;
snippet?: {
id: number;
title: string;
content: string;
author_id: number;
project_id: number;
created_at: string;
updated_at: string;
file_name: string;
type: string;
visibility_level: number;
};
commit?: {
id: string;
message: string;
timestamp: string;
url: string;
author: {
name: string;
email: string;
};
};
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "@plane/typescript-config/base.json",
"compilerOptions": {
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/*"],
"exclude": ["dist", "build", "node_modules"]
}
+2 -1
View File
@@ -16,7 +16,8 @@
"csv-string": "^4.1.1",
"jira.js": "^4.0.1",
"@plane/constants": "*",
"@plane/sdk": "*"
"@plane/sdk": "*",
"@silo/core": "*"
},
"devDependencies": {
"typescript": "^5.3.3",
+151 -4
View File
@@ -3,10 +3,16 @@ import {
Issue as IJiraIssue,
ComponentWithIssueCount,
Comment as JComment,
IssueTypeDetails as JiraIssueTypeDetails,
FieldDetails,
CustomFieldContextProjectMapping,
IssueTypeToContextMapping,
CustomFieldContextOption,
} from "jira.js/out/version3/models";
import {
fetchPaginatedData,
formatDateStringForHHMM,
OPTION_CUSTOM_FIELD_TYPES,
removeArrayObjSpaces,
} from "../helpers";
import { JiraService } from "@/services";
@@ -16,6 +22,9 @@ import {
JiraComponent,
JiraSprint,
PaginatedResponse,
JiraIssueField,
JiraIssueFieldOptions,
JiraCustomFieldKeys,
} from "@/types";
export function pullUsers(users: string): ImportedJiraUser[] {
@@ -84,8 +93,11 @@ export async function pullSprints(
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
throw new Error(`Could not fetch sprints, something went wrong`);
} catch (e: any) {
console.error(
"Could not fetch sprints, something went wrong",
e.response?.data
);
}
return jiraSprints;
}
@@ -105,8 +117,11 @@ export async function pullComponents(
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
throw new Error(`Could not fetch components, something went wrong`);
} catch (e: any) {
console.error(
"Could not fetch components, something went wrong",
e.response?.data
);
}
return jiraComponents;
}
@@ -147,3 +162,135 @@ export const pullCommentsInBatches = async (
}
return comments;
};
export const pullIssueTypes = async (
client: JiraService,
projectId: string
): Promise<JiraIssueTypeDetails[]> => {
return await client.getProjectIssueTypes(projectId);
};
export const pullIssueFields = async (
client: JiraService,
issueTypes: JiraIssueTypeDetails[],
projectId: string
): Promise<JiraIssueField[]> => {
// initialize custom fields
const customFields: JiraIssueField[] = [];
try {
// initialize fields
const fields: FieldDetails[] = await client.getCustomFields();
// get all field contexts
for (const field of fields) {
// skip if field has no id
if (!field.id) continue;
// Get project contexts for each field
const projectPageFieldContexts: CustomFieldContextProjectMapping[] = [];
try {
await fetchPaginatedData(
(startAt) =>
client.getProjectFieldContexts(field.id as string, startAt),
(values) =>
projectPageFieldContexts.push(
...(values as CustomFieldContextProjectMapping[])
),
"values"
);
} catch (e: any) {
console.error(
`Could not fetch field contexts for field ${field.id}`,
e.response?.data
);
}
// get field values for each issue
const fieldProjectContext = projectPageFieldContexts?.filter(
(context) => context?.projectId === projectId
);
// get field values for each issue type
if (fieldProjectContext?.length) {
// get context ids
const contextIds: number[] = fieldProjectContext
.filter((context) => !!context?.contextId)
.map((context) => Number(context?.contextId));
const issueTypeContexts: IssueTypeToContextMapping[] = [];
// get issue type contexts
try {
await fetchPaginatedData(
(startAt) =>
client.getIssueTypeFieldContexts(
field.id as string,
contextIds,
startAt
),
(values) =>
issueTypeContexts.push(
...(values as IssueTypeToContextMapping[])
),
"values"
);
} catch (e: any) {
console.error(
`Could not fetch issue type contexts for field ${field.id}`,
e.response?.data
);
}
// get issue type for each issue type context
if (!issueTypeContexts) continue;
for (const issueTypeContext of issueTypeContexts) {
const issueType = issueTypes.find(
(issueType) => issueType.id === issueTypeContext.issueTypeId
);
if (!issueType) continue;
const fieldOptions: JiraIssueFieldOptions[] = [];
if (
OPTION_CUSTOM_FIELD_TYPES.includes(
field.schema?.custom as JiraCustomFieldKeys
)
) {
// get field options
await fetchPaginatedData(
(startAt) =>
client.getIssueFieldOptions(
field.id as string,
Number(issueTypeContext.contextId),
startAt
),
(values: CustomFieldContextOption[]) => {
values.map((value) => {
if (field.id)
fieldOptions.push({ ...value, fieldId: field.id });
});
},
"values"
);
}
// add field to custom fields
customFields.push({
...field,
scope: {
project: { id: projectId },
type: issueType.id,
},
options: fieldOptions,
});
}
}
}
} catch (e: any) {
console.error(e.response?.data);
}
// return custom fields
return customFields;
};
+88 -3
View File
@@ -1,10 +1,14 @@
import { IssueTypeDetails as JiraIssueTypeDetails } from "jira.js/out/version3/models";
import {
ExCycle,
ExIssueComment,
ExIssueLabel,
ExIssueType,
ExModule,
ExIssue as PlaneIssue,
ExIssueProperty,
PlaneUser,
ExIssuePropertyOption,
} from "@plane/sdk";
import {
IJiraIssue,
@@ -14,20 +18,27 @@ import {
JiraComment,
JiraComponent,
JiraSprint,
JiraCustomFieldKeys,
JiraIssueField,
JiraIssueFieldOptions,
} from "@/types";
import {
getFormattedDate,
getPropertyAttributes,
getPropertyValues,
getRandomColor,
getTargetAttachments,
getTargetPriority,
getTargetState,
SUPPORTED_CUSTOM_FIELD_ATTRIBUTES,
} from "../helpers";
import { TPropertyValuesPayload } from "@silo/core";
export const transformIssue = (
issue: IJiraIssue,
resourceUrl: string,
stateMap: IStateConfig[],
priorityMap: IPriorityConfig[],
priorityMap: IPriorityConfig[]
): Partial<PlaneIssue> => {
const targetState = getTargetState(stateMap, issue.fields.status);
const targetPriority = getTargetPriority(priorityMap, issue.fields.priority);
@@ -67,6 +78,7 @@ export const transformIssue = (
priority: targetPriority ?? "none",
labels: issue.fields.labels,
parent: issue.fields.parent?.id,
type_id: issue.fields.issuetype?.id,
} as unknown as PlaneIssue;
};
@@ -78,7 +90,7 @@ export const transformLabel = (label: string): Partial<ExIssueLabel> => {
};
export const transformComment = (
comment: JiraComment,
comment: JiraComment
): Partial<ExIssueComment> => {
return {
external_id: comment.id,
@@ -118,7 +130,7 @@ export const transformSprint = (sprint: JiraSprint): Partial<ExCycle> => {
};
export const transformComponent = (
component: JiraComponent,
component: JiraComponent
): Partial<ExModule> => {
return {
external_id: component.component.id ?? "",
@@ -127,3 +139,76 @@ export const transformComponent = (
issues: component.issues.map((issue) => issue.id),
};
};
export const transformIssueType = (
issueType: JiraIssueTypeDetails
): Partial<ExIssueType> => {
return {
name: issueType.name,
description: issueType.description,
is_active: true,
external_id: issueType.id,
external_source: "JIRA",
};
};
export const transformIssueFields = (
issueField: JiraIssueField
): Partial<ExIssueProperty> | undefined => {
if (
!issueField.schema ||
!issueField.schema.custom ||
!issueField.scope?.type ||
!SUPPORTED_CUSTOM_FIELD_ATTRIBUTES[
issueField.schema.custom as JiraCustomFieldKeys
]
) {
return undefined;
}
return {
external_id: issueField.id,
external_source: "JIRA",
display_name: issueField.name,
type_id: issueField.scope?.type,
is_required: false,
is_active: true,
...getPropertyAttributes(issueField),
};
};
export const transformIssueFieldOptions = (
issueFieldOption: JiraIssueFieldOptions
): Partial<ExIssuePropertyOption> => {
return {
external_id: issueFieldOption.id,
external_source: "JIRA",
name: issueFieldOption.value,
is_active: issueFieldOption.disabled ? false : true,
property_id: issueFieldOption.fieldId,
};
};
export const transformIssuePropertyValues = (
issue: IJiraIssue,
planeIssueProperties: Map<string, Partial<ExIssueProperty>>,
jiraCustomFieldMap: Map<string, string>
): TPropertyValuesPayload => {
// Get all custom fields that are present in the issue and are also present in the plane issue properties
const customFieldKeysToTransform = Object.keys(issue.fields).filter(
(key) => key.startsWith("customfield_") && planeIssueProperties.has(key)
);
// Get transformed values for property_id -> property_values
const propertyValuesPayload: TPropertyValuesPayload = {};
customFieldKeysToTransform.forEach((key) => {
const property = planeIssueProperties.get(key);
if (property && property.external_id && jiraCustomFieldMap.has(key)) {
propertyValuesPayload[property.external_id] = getPropertyValues(
jiraCustomFieldMap.get(key) as JiraCustomFieldKeys,
issue.fields[key],
(issue.renderedFields as any)?.[key]
);
}
});
return propertyValuesPayload;
};
@@ -0,0 +1,83 @@
import {
EIssuePropertyRelationType,
EIssuePropertyType,
ExIssueProperty,
} from "@plane/sdk";
import { JiraCustomFieldKeys } from "../types/custom-fields";
import { getTextPropertySettings } from "@silo/core";
export const SUPPORTED_CUSTOM_FIELD_ATTRIBUTES: Record<
JiraCustomFieldKeys,
Partial<ExIssueProperty>
> = {
"com.atlassian.jira.plugin.system.customfieldtypes:textfield": {
property_type: EIssuePropertyType.TEXT,
relation_type: undefined,
is_multi: false,
settings: getTextPropertySettings("single-line"),
},
"com.atlassian.jira.plugin.system.customfieldtypes:url": {
property_type: EIssuePropertyType.TEXT,
relation_type: undefined,
is_multi: false,
settings: getTextPropertySettings("single-line"),
},
"com.atlassian.jira.plugin.system.customfieldtypes:userpicker": {
property_type: EIssuePropertyType.RELATION,
relation_type: EIssuePropertyRelationType.USER,
is_multi: false,
},
"com.atlassian.jira.plugin.system.customfieldtypes:select": {
property_type: EIssuePropertyType.OPTION,
relation_type: undefined,
is_multi: false,
},
"com.atlassian.jira.plugin.system.customfieldtypes:float": {
property_type: EIssuePropertyType.DECIMAL,
relation_type: undefined,
is_multi: false,
},
"com.atlassian.jira.plugin.system.customfieldtypes:textarea": {
property_type: EIssuePropertyType.TEXT,
relation_type: undefined,
is_multi: false,
settings: getTextPropertySettings("multi-line"),
},
"com.atlassian.jira.plugin.system.customfieldtypes:multicheckboxes": {
property_type: EIssuePropertyType.OPTION,
relation_type: undefined,
is_multi: true,
},
"com.atlassian.jira.plugin.system.customfieldtypes:datetime": {
property_type: EIssuePropertyType.DATETIME,
relation_type: undefined,
is_multi: false,
},
"com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons": {
property_type: EIssuePropertyType.OPTION,
relation_type: undefined,
is_multi: false,
},
"com.atlassian.jira.plugin.system.customfieldtypes:multiselect": {
property_type: EIssuePropertyType.OPTION,
relation_type: undefined,
is_multi: true,
},
"com.atlassian.jira.plugin.system.customfieldtypes:datepicker": {
property_type: EIssuePropertyType.DATETIME,
relation_type: undefined,
is_multi: false,
},
"com.atlassian.jira.plugin.system.customfieldtypes:multiuserpicker": {
property_type: EIssuePropertyType.RELATION,
relation_type: EIssuePropertyRelationType.USER,
is_multi: true,
},
};
export const OPTION_CUSTOM_FIELD_TYPES: JiraCustomFieldKeys[] = [
"com.atlassian.jira.plugin.system.customfieldtypes:select",
"com.atlassian.jira.plugin.system.customfieldtypes:multicheckboxes",
"com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons",
"com.atlassian.jira.plugin.system.customfieldtypes:multiselect",
];
+162 -2
View File
@@ -1,10 +1,24 @@
import { IPriorityConfig, IStateConfig, PaginatedResponse } from "@/types";
import { ExIssueAttachment, ExState } from "@plane/sdk";
import {
IPriorityConfig,
IStateConfig,
JiraCustomFieldKeys,
JiraIssueField,
PaginatedResponse,
} from "@/types";
import {
ExIssueAttachment,
ExState,
ExIssueProperty,
ExIssuePropertyValue,
TPropertyValue,
} from "@plane/sdk";
import {
Attachment as JiraAttachment,
Priority as JiraPriority,
StatusDetails as JiraState,
} from "jira.js/out/version3/models";
import { SUPPORTED_CUSTOM_FIELD_ATTRIBUTES } from "./custom-field-etl";
import { getFormattedDate } from "./date";
export const getTargetState = (
stateMap: IStateConfig[],
@@ -81,3 +95,149 @@ export const fetchPaginatedData = async <T>(
}
}
};
export const getPropertyAttributes = (
jiraIssueField: JiraIssueField
): Partial<ExIssueProperty> => {
if (!jiraIssueField.schema || !jiraIssueField.schema.custom) {
return {};
}
return {
...SUPPORTED_CUSTOM_FIELD_ATTRIBUTES[
jiraIssueField.schema.custom as JiraCustomFieldKeys
],
};
};
export const getPropertyValues = (
customFieldType: JiraCustomFieldKeys,
value: any,
renderedValue: any
): ExIssuePropertyValue => {
const propertyValues: ExIssuePropertyValue = [];
const commonPropertyProp: Partial<TPropertyValue> = {
external_source: "JIRA",
external_id: undefined,
};
if (!value) return [];
switch (customFieldType) {
case "com.atlassian.jira.plugin.system.customfieldtypes:textfield":
// Handle textfield
propertyValues.push({
...commonPropertyProp,
value: value,
});
break;
case "com.atlassian.jira.plugin.system.customfieldtypes:url":
// Handle url
propertyValues.push({
...commonPropertyProp,
value: value,
});
break;
case "com.atlassian.jira.plugin.system.customfieldtypes:userpicker":
// Handle userpicker
if (value.accountId && value.displayName) {
propertyValues.push({
...commonPropertyProp,
external_id: value.accountId,
value: value.displayName,
});
}
break;
case "com.atlassian.jira.plugin.system.customfieldtypes:select":
// Handle single select
propertyValues.push({
...commonPropertyProp,
external_id: value.id,
value: value.id,
});
break;
case "com.atlassian.jira.plugin.system.customfieldtypes:float":
// Handle float
propertyValues.push({
...commonPropertyProp,
value: value,
});
break;
case "com.atlassian.jira.plugin.system.customfieldtypes:textarea":
// Handle textarea
if (renderedValue) {
propertyValues.push({
...commonPropertyProp,
value: renderedValue,
});
}
break;
case "com.atlassian.jira.plugin.system.customfieldtypes:multicheckboxes":
// Handle multicheckboxes
if (Array.isArray(value)) {
value.forEach((val) => {
propertyValues.push({
...commonPropertyProp,
external_id: val.id,
value: val.id,
});
});
}
break;
case "com.atlassian.jira.plugin.system.customfieldtypes:datetime":
// Handle datetime
const formattedDate = getFormattedDate(value); // Format it to datetime format
if (formattedDate) {
propertyValues.push({
...commonPropertyProp,
value: formattedDate,
});
}
break;
case "com.atlassian.jira.plugin.system.customfieldtypes:radiobuttons":
// Handle radiobuttons
propertyValues.push({
...commonPropertyProp,
external_id: value.id,
value: value.id,
});
break;
case "com.atlassian.jira.plugin.system.customfieldtypes:multiselect":
// Handle multiselect
if (Array.isArray(value)) {
value.forEach((val) => {
propertyValues.push({
...commonPropertyProp,
external_id: val.id,
value: val.id,
});
});
}
break;
case "com.atlassian.jira.plugin.system.customfieldtypes:datepicker":
// Handle datepicker
propertyValues.push({
...commonPropertyProp,
value: value,
});
break;
case "com.atlassian.jira.plugin.system.customfieldtypes:multiuserpicker":
// Handle multiuserpicker
if (Array.isArray(value)) {
value.forEach((val) => {
if (val.accountId && val.displayName) {
propertyValues.push({
...commonPropertyProp,
external_id: val.accountId,
value: val.displayName,
});
}
});
}
break;
default:
console.warn(`Unhandled custom field type: ${customFieldType}`);
}
return propertyValues;
};
+3 -1
View File
@@ -1,3 +1,5 @@
export * from "./etl";
export * from "./date";
export * from "./string";
export * from "./etl";
export * from "./constants";
export * from "./custom-field-etl";
+107 -43
View File
@@ -3,53 +3,69 @@ import { Version3Client } from "jira.js/out/version3";
import axios, { AxiosError } from "axios";
import { Board } from "jira.js/out/agile";
import { JiraProps, JiraResource } from "@/types";
import { PageString } from "jira.js/out/version3/models";
import {
FieldDetails,
PageString,
PageProject,
} from "jira.js/out/version3/models";
export class JiraService {
private jiraClient: Version3Client;
private accessToken: string;
private refreshToken: string;
private accessToken: string = "";
private refreshToken: string = "";
constructor(props: JiraProps) {
this.accessToken = props.accessToken;
this.jiraClient = new Version3Client({
host: `https://api.atlassian.com/ex/jira/${props.cloudId}`,
authentication: {
oauth2: {
accessToken: props.accessToken,
if (props.isPAT === false) {
this.accessToken = props.accessToken;
this.jiraClient = new Version3Client({
host: `https://api.atlassian.com/ex/jira/${props.cloudId}`,
authentication: {
oauth2: {
accessToken: props.accessToken,
},
},
},
});
});
this.refreshToken = props.refreshToken as string;
this.jiraClient.handleFailedResponse = async (request) => {
const error = request as AxiosError;
if (error.response?.status === 401) {
try {
const { access_token, refresh_token, expires_in } =
await props.refreshTokenFunc(this.refreshToken);
this.refreshToken = refresh_token;
this.jiraClient = new Version3Client({
host: `https://api.atlassian.com/ex/jira/${props.cloudId}`,
authentication: {
oauth2: {
accessToken: access_token,
this.refreshToken = props.refreshToken as string;
this.jiraClient.handleFailedResponse = async (request) => {
const error = request as AxiosError;
if (error.response?.status === 401) {
try {
const { access_token, refresh_token, expires_in } =
await props.refreshTokenFunc(this.refreshToken);
this.refreshToken = refresh_token;
this.jiraClient = new Version3Client({
host: `https://api.atlassian.com/ex/jira/${props.cloudId}`,
authentication: {
oauth2: {
accessToken: access_token,
},
},
},
});
await props.refreshTokenCallback({
access_token,
refresh_token,
expires_in,
});
return request;
} catch (error) {
console.log("Error while refreshing token");
console.log(error);
});
await props.refreshTokenCallback({
access_token,
refresh_token,
expires_in,
});
return request;
} catch (error) {
console.log("Error while refreshing token");
console.log(error);
}
}
}
throw error;
};
throw error;
};
} else {
this.jiraClient = new Version3Client({
host: props.hostname,
authentication: {
basic: {
email: props.userEmail,
apiToken: props.patToken,
},
},
});
}
}
async getCurrentUser() {
@@ -68,6 +84,10 @@ export class JiraService {
return this.jiraClient.issueFields.getFields();
}
async getCurrentResource() {
return this.jiraClient.projects;
}
async getResourceStatuses() {
return this.jiraClient.status.search();
}
@@ -110,7 +130,7 @@ export class JiraService {
async getBoardSprintsIssues(
boardId: number,
sprintId: number,
startAt: number,
startAt: number
) {
const board = new Board(this.jiraClient);
return board.getBoardIssuesForSprint({
@@ -150,17 +170,61 @@ export class JiraService {
});
}
/* TODO: Confirm the endpoint */
async getProjectIssueTypes(projectId: string) {
return this.jiraClient.issueTypes.getIssueTypesForProject({
projectId: projectId as unknown as number,
});
}
async getCustomFields() {
const fields: FieldDetails[] =
await this.jiraClient.issueFields.getFields();
const customFields: FieldDetails[] = fields.filter(
(field) => field.schema?.custom
);
return customFields;
}
async getProjectFieldContexts(fieldId: string, startAt = 0) {
return this.jiraClient.issueCustomFieldContexts.getProjectContextMapping({
fieldId: fieldId,
startAt: startAt,
});
}
async getIssueTypeFieldContexts(
fieldId: string,
contextIds: number[],
startAt = 0
) {
return this.jiraClient.issueCustomFieldContexts.getIssueTypeMappingsForContexts(
{
fieldId: fieldId,
contextId: contextIds,
startAt: startAt,
}
);
}
async getIssueFieldOptions(fieldId: string, contextId: number, startAt = 0) {
return this.jiraClient.issueCustomFieldOptions.getOptionsForContext({
fieldId: fieldId,
contextId: contextId,
startAt: startAt,
});
}
async getProjectIssueCreateMeta(projectId: string) {
return this.jiraClient.issues.getCreateIssueMeta({
projectIds: [projectId],
expand: "projects.issuetypes.fields",
});
}
async getProjectIssues(
projectKey: string,
startAt = 0,
createdAfter?: string,
createdAfter?: string
) {
return this.jiraClient.issueSearch.searchForIssuesUsingJql({
jql: createdAfter
@@ -228,11 +292,11 @@ export class JiraService {
},
(error) => {
return Promise.reject(error);
},
}
);
const response = await axiosInstance.get(
"/oauth/token/accessible-resources",
"/oauth/token/accessible-resources"
);
return response.data;
@@ -1,6 +1,6 @@
import { JiraAuthProps, JiraAuthState } from "@/types";
import axios from "axios";
import { JIRA_SCOPES } from "@plane/constants";
import { JIRA_SCOPES } from "../helpers";
export type JiraTokenResponse = {
access_token: string;

Some files were not shown because too many files have changed in this diff Show More