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:
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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
export * from "./auth";
|
||||
export * from "./endpoints";
|
||||
export * from "./issue";
|
||||
export * from "./payment";
|
||||
export * from "./silo"
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
export * from "./types"
|
||||
export * from "./types";
|
||||
export * from "./webhook-types";
|
||||
export * from "./issue-types";
|
||||
|
||||
@@ -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[];
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./pull";
|
||||
export * from "./transform";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Vendored
+1
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./date";
|
||||
export * from "./string";
|
||||
export * from "./etl";
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,3 +3,6 @@ export * from "./services";
|
||||
|
||||
// types
|
||||
export * from "./types";
|
||||
|
||||
// helpers
|
||||
export * from "./helpers";
|
||||
|
||||
+9
-7
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
+18
-21
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 +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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"extends": "@plane/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
@@ -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,
|
||||
// },
|
||||
// };
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./pull";
|
||||
export * from "./transform";
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./common";
|
||||
export * from "./webhook";
|
||||
export * from "./auth";
|
||||
export * from "./schema";
|
||||
@@ -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,
|
||||
),
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "./etl";
|
||||
export * from "./date";
|
||||
export * from "./string";
|
||||
export * from "./etl";
|
||||
export * from "./constants";
|
||||
export * from "./custom-field-etl";
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user