diff --git a/.env.example b/.env.example index 2191ab6859..08da6601c2 100644 --- a/.env.example +++ b/.env.example @@ -44,3 +44,6 @@ NGINX_PORT=80 SILO_BASE_URL= MONGO_DB_URL="mongodb://plane-mongodb:27017/" + +SILO_DB=silo +SILO_DB_URL=postgresql://plane:plane@plane-db/silo diff --git a/.github/workflows/build-branch-cloud.yml b/.github/workflows/build-branch-cloud.yml index 2a01dc9e4b..b88bdbd201 100644 --- a/.github/workflows/build-branch-cloud.yml +++ b/.github/workflows/build-branch-cloud.yml @@ -52,6 +52,7 @@ jobs: build_admin: ${{ steps.changed_files.outputs.admin_any_changed }} build_space: ${{ steps.changed_files.outputs.space_any_changed }} build_live: ${{ steps.changed_files.outputs.live_any_changed }} + build_silo: ${{ steps.changed_files.outputs.silo_any_changed }} build_apiserver: ${{ steps.changed_files.outputs.apiserver_any_changed }} build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }} build_monitor: ${{ steps.changed_files.outputs.monitor_any_changed }} @@ -60,6 +61,7 @@ jobs: dh_img_space: ${{ steps.set_env_variables.outputs.DH_IMG_SPACE }} dh_img_admin: ${{ steps.set_env_variables.outputs.DH_IMG_ADMIN }} dh_img_live: ${{ steps.set_env_variables.outputs.DH_IMG_LIVE }} + dh_img_silo: ${{ steps.set_env_variables.outputs.DH_IMG_SILO }} dh_img_backend: ${{ steps.set_env_variables.outputs.DH_IMG_BACKEND }} dh_img_proxy: ${{ steps.set_env_variables.outputs.DH_IMG_PROXY }} dh_img_monitor: ${{ steps.set_env_variables.outputs.DH_IMG_MONITOR }} @@ -84,6 +86,7 @@ jobs: echo "DH_IMG_SPACE=space-cloud" >> $GITHUB_OUTPUT echo "DH_IMG_ADMIN=admin-cloud" >> $GITHUB_OUTPUT echo "DH_IMG_LIVE=live-cloud" >> $GITHUB_OUTPUT + echo "DH_IMG_SILO=silo-cloud" >> $GITHUB_OUTPUT echo "DH_IMG_BACKEND=backend-cloud" >> $GITHUB_OUTPUT echo "DH_IMG_PROXY=proxy-cloud" >> $GITHUB_OUTPUT echo "DH_IMG_MONITOR=monitor-cloud" >> $GITHUB_OUTPUT @@ -165,31 +168,33 @@ jobs: - packages/** - "package.json" - "yarn.lock" - - "tsconfig.json" - "turbo.json" space: - space/** - packages/** - "package.json" - "yarn.lock" - - "tsconfig.json" - "turbo.json" web: - web/** - packages/** - "package.json" - "yarn.lock" - - "tsconfig.json" - "turbo.json" live: - live/** - packages/** - 'package.json' - 'yarn.lock' - - 'tsconfig.json' - 'turbo.json' monitor: - monitor/** + silo: + - silo/** + - packages/** + - 'package.json' + - 'yarn.lock' + - 'turbo.json' branch_build_push_admin: if: ${{ needs.branch_build_setup.outputs.build_admin == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} @@ -355,6 +360,33 @@ jobs: buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} + branch_build_push_silo: + if: ${{ needs.branch_build_setup.outputs.build_silo == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + name: Build-Push Silo Docker Image + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + steps: + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + - name: Silo Build and Push + uses: ./.github/actions/build-push-action + with: + build-release: ${{ needs.branch_build_setup.outputs.build_release }} + build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} + release-version: ${{ needs.branch_build_setup.outputs.release_version }} + docker-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} + harbor-push: ${{ needs.branch_build_setup.outputs.harbor_push }} + harbor-username: ${{ secrets.HARBOR_USERNAME }} + harbor-token: ${{ secrets.HARBOR_TOKEN }} + harbor-registry: ${{ vars.HARBOR_REGISTRY }} + harbor-project: ${{ vars.HARBOR_PROJECT }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_live }} + build-context: . + dockerfile-path: ./silo/Dockerfile.silo + branch_build_push_apiserver: if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push API Server Docker Image @@ -396,6 +428,7 @@ jobs: branch_build_push_web, branch_build_push_space, branch_build_push_live, + branch_build_push_silo, branch_build_push_apiserver, ] env: diff --git a/.github/workflows/build-branch-ee.yml b/.github/workflows/build-branch-ee.yml index 9d67b99417..3ca14ac19f 100644 --- a/.github/workflows/build-branch-ee.yml +++ b/.github/workflows/build-branch-ee.yml @@ -53,6 +53,7 @@ jobs: build_apiserver: ${{ steps.changed_files.outputs.apiserver_any_changed }} build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }} build_monitor: ${{ steps.changed_files.outputs.monitor_any_changed }} + build_silo: ${{ steps.changed_files.outputs.silo_any_changed }} artifact_upload_to_s3: ${{ steps.set_env_variables.outputs.artifact_upload_to_s3 }} artifact_s3_suffix: ${{ steps.set_env_variables.outputs.artifact_s3_suffix }} @@ -63,6 +64,7 @@ jobs: dh_img_backend: ${{ steps.set_env_variables.outputs.DH_IMG_BACKEND }} dh_img_proxy: ${{ steps.set_env_variables.outputs.DH_IMG_PROXY }} dh_img_monitor: ${{ steps.set_env_variables.outputs.DH_IMG_MONITOR }} + dh_img_silo: ${{ steps.set_env_variables.outputs.DH_IMG_SILO }} harbor_push: ${{ steps.set_env_variables.outputs.HARBOR_PUSH }} build_release: ${{ steps.set_env_variables.outputs.BUILD_RELEASE }} build_prerelease: ${{ steps.set_env_variables.outputs.BUILD_PRERELEASE }} @@ -93,6 +95,7 @@ jobs: echo "DH_IMG_BACKEND=backend-enterprise" >> $GITHUB_OUTPUT echo "DH_IMG_PROXY=proxy-enterprise" >> $GITHUB_OUTPUT echo "DH_IMG_MONITOR=monitor-enterprise" >> $GITHUB_OUTPUT + echo "DH_IMG_SILO=silo-enterprise" >> $GITHUB_OUTPUT BUILD_RELEASE=false BUILD_PRERELEASE=false @@ -169,6 +172,13 @@ jobs: - "yarn.lock" - "tsconfig.json" - "turbo.json" + silo: + - silo/** + - packages/** + - 'package.json' + - 'yarn.lock' + - 'tsconfig.json' + - 'turbo.json' live: - live/** - packages/** @@ -300,6 +310,37 @@ jobs: buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} + branch_build_push_silo: + if: ${{ needs.branch_build_setup.outputs.build_silo == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} + name: Build-Push Silo Docker Image + runs-on: ubuntu-20.04 + needs: [branch_build_setup] + steps: + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + - name: Silo Build and Push + uses: ./.github/actions/build-push-action + with: + build-release: ${{ needs.branch_build_setup.outputs.build_release }} + build-prerelease: ${{ needs.branch_build_setup.outputs.build_prerelease }} + release-version: ${{ needs.branch_build_setup.outputs.release_version }} + docker-username: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }} + harbor-push: ${{ needs.branch_build_setup.outputs.harbor_push }} + harbor-username: ${{ secrets.HARBOR_USERNAME }} + harbor-token: ${{ secrets.HARBOR_TOKEN }} + harbor-registry: ${{ vars.HARBOR_REGISTRY }} + harbor-project: ${{ vars.HARBOR_PROJECT }} + docker-image-owner: makeplane + docker-image-name: ${{ needs.branch_build_setup.outputs.dh_img_silo }} + build-context: . + dockerfile-path: ./silo/Dockerfile.silo + buildx-driver: ${{ needs.branch_build_setup.outputs.gh_buildx_driver }} + buildx-version: ${{ needs.branch_build_setup.outputs.gh_buildx_version }} + buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} + buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} + branch_build_push_apiserver: if: ${{ needs.branch_build_setup.outputs.build_apiserver == 'true' || github.event_name == 'workflow_dispatch' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} name: Build-Push API Server Docker Image @@ -455,6 +496,7 @@ jobs: branch_build_push_apiserver, branch_build_push_proxy, branch_build_push_monitor, + branch_build_push_silo, upload_artifacts_s3, ] env: diff --git a/docker-compose-cloud.yml b/docker-compose-cloud.yml new file mode 100644 index 0000000000..3387225331 --- /dev/null +++ b/docker-compose-cloud.yml @@ -0,0 +1,287 @@ +x-silo-env: &silo-env + environment: + - PORT=3000 + - BATCH_SIZE=${BATCH_SIZE:-60} + - MQ_PREFETCH_COUNT=${MQ_PREFETCH_COUNT:-5} + - APP_BASE_URL=${APP_BASE_URL:-http://web:3000} + - SILO_API_BASE_URL=${SILO_API_BASE_URL:-http://localhost:5050} + - DB_URL=${DB_URL:-postgresql://plane:plane@plane-db/silo} + - AMQP_URL=${AMQP_URL:-amqp://guest:guest@plane-mq:5672/} + - REDIS_URL=${REDIS_URL:-redis://plane-redis:6379/} + - SENTRY_DSN=${SENTRY_DSN:-" "} + - JIRA_CLIENT_ID=${JIRA_CLIENT_ID:-""} + - JIRA_CLIENT_SECRET=${JIRA_CLIENT_SECRET:-""} + - LINEAR_CLIENT_ID=${LINEAR_CLIENT_ID:-""} + - LINEAR_CLIENT_SECRET=${LINEAR_CLIENT_SECRET:-""} + +x-monitor-env: &monitor-env + environment: + - SERVICE_HTTP_WEB=web:3000 + - SERVICE_HTTP_API=api:8000 + - SERVICE_HTTP_LIVE=live:3000 + - SERVICE_HTTP_PROXY=proxy:80 + - SERVICE_HTTP_MINIO=plane-minio:9090 + - SERVICE_TCP_REDIS=plane-redis:6379 + - SERVICE_TCP_POSTGRES=plane-db:5432 + - TRUSTED_PROXIES=${TRUSTED_PROXIES:-0.0.0.0/0} + +x-proxy-env: &proxy-env + environment: + - SITE_ADDRESS=${SITE_ADDRESS:-localhost:80} + - CERT_EMAIL=${CERT_EMAIL:-admin@example.com} + - CERT_ACME_CA=${CERT_ACME_CA:-} + - CERT_ACME_DNS=${CERT_ACME_DNS:-} + - BUCKET_NAME=${BUCKET_NAME:-uploads} + - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} + - LISTEN_HTTP_PORT=${LISTEN_HTTP_PORT:-80} + - LISTEN_HTTPS_PORT=${LISTEN_HTTPS_PORT:-443} + +x-app-env: &app-env + environment: + - WEB_URL=${WEB_URL:-http://localhost} + - DEBUG=${DEBUG:-0} + - SENTRY_DSN=${SENTRY_DSN:-""} + - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"} + - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-} + - API_BASE_URL=${API_BASE_URL:-http://api:8000} + # Gunicorn Workers + - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} + #DB SETTINGS + - PGHOST=${PGHOST:-plane-db} + - PGDATABASE=${PGDATABASE:-plane} + - POSTGRES_USER=${POSTGRES_USER:-plane} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-plane} + - POSTGRES_DB=${POSTGRES_DB:-plane} + - PGDATA=${PGDATA:-/var/lib/postgresql/data} + - SILO_DB=${SILO_DB:-silo} + - DATABASE_URL=${DATABASE_URL:-postgresql://plane:plane@plane-db/plane} + # REDIS SETTINGS + - REDIS_HOST=${REDIS_HOST:-plane-redis} + - REDIS_PORT=${REDIS_PORT:-6379} + - REDIS_URL=${REDIS_URL:-redis://plane-redis:6379/} + # RabbitMQ Settings + - RABBITMQ_HOST=${RABBITMQ_HOST:-plane-mq} + - RABBITMQ_PORT=${RABBITMQ_PORT:-5672} + - RABBITMQ_DEFAULT_USER=${RABBITMQ_USER:-plane} + - RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD:-plane} + - RABBITMQ_DEFAULT_VHOST=${RABBITMQ_VHOST:-plane} + - RABBITMQ_VHOST=${RABBITMQ_VHOST:-plane} + - AMQP_URL=${AMQP_URL:-amqp://plane:plane@plane-mq:5672/plane} + # Application secret + - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} + # DATA STORE SETTINGS + - USE_MINIO=${USE_MINIO:-1} + - AWS_REGION=${AWS_REGION:-""} + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-"access-key"} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-"secret-key"} + - AWS_S3_ENDPOINT_URL=${AWS_S3_ENDPOINT_URL:-http://plane-minio:9000} + - AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME:-uploads} + - MINIO_ROOT_USER=${MINIO_ROOT_USER:-"access-key"} + - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-"secret-key"} + - BUCKET_NAME=${BUCKET_NAME:-uploads} + - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} + - SSL_VERIFY=${SSL_VERIFY:-1} + - FEATURE_FLAG_SERVER_BASE_URL=${FEATURE_FLAG_SERVER_BASE_URL:-http://monitor:8080} + - PAYMENT_SERVER_BASE_URL=${PAYMENT_SERVER_BASE_URL:-http://monitor:8080} + +x-live-env: &live-env + environment: + - API_BASE_URL=${API_BASE_URL:-http://api:8000} + - REDIS_HOST=${REDIS_HOST:-plane-redis} + - REDIS_PORT=${REDIS_PORT:-6379} + - REDIS_URL=${REDIS_URL:-redis://plane-redis:6379/} + +services: + admin: + <<: *app-env + image: registry.plane.tools/plane/admin-enterprise:${APP_RELEASE_VERSION} + restart: unless-stopped + command: node admin/server.js admin + deploy: + replicas: ${ADMIN_REPLICAS:-1} + depends_on: + - api + - web + + silo: + <<: *silo-env + image: registry.plane.tools/plane/silo-enterprise:${APP_RELEASE_VERSION} + restart: always + command: node silo/start.js + depends_on: + plane-mq: + condition: service_healthy + silo_migrator: + condition: service_completed_successfully + + silo_migrator: + <<: *silo-env + image: registry.plane.tools/plane/silo-enterprise:${APP_RELEASE_VERSION} + restart: "no" + command: npm --prefix ./silo run db:migrate + depends_on: + plane-db: + condition: service_healthy + plane-mq: + condition: service_healthy + + web: + <<: *app-env + image: registry.plane.tools/plane/web-enterprise:${APP_RELEASE_VERSION} + restart: unless-stopped + command: node web/server.js web + deploy: + replicas: ${WEB_REPLICAS:-1} + depends_on: + - api + - worker + + space: + <<: *app-env + image: registry.plane.tools/plane/space-enterprise:${APP_RELEASE_VERSION} + restart: unless-stopped + command: node space/server.js space + deploy: + replicas: ${SPACE_REPLICAS:-1} + depends_on: + - api + - web + + live: + <<: *live-env + image: registry.plane.tools/plane/live-enterprise:${APP_RELEASE_VERSION} + restart: unless-stopped + command: node live/dist/server.js live + deploy: + replicas: ${LIVE_REPLICAS:-1} + depends_on: + - api + - web + + monitor: + <<: *monitor-env + image: registry.plane.tools/plane/monitor-enterprise:${APP_RELEASE_VERSION} + restart: on-failure:5 + volumes: + - ${INSTALL_DIR}/data/monitor:/app + + api: + <<: *app-env + image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION} + restart: unless-stopped + command: ./bin/docker-entrypoint-api-ee.sh + deploy: + replicas: ${API_REPLICAS:-1} + volumes: + - ${INSTALL_DIR}/logs/api:/code/plane/logs + depends_on: + - plane-db + - plane-redis + + worker: + <<: *app-env + image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION} + restart: unless-stopped + command: ./bin/docker-entrypoint-worker.sh + volumes: + - ${INSTALL_DIR}/logs/worker:/code/plane/logs + depends_on: + - api + - plane-db + - plane-redis + + beat-worker: + <<: *app-env + image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION} + restart: unless-stopped + command: ./bin/docker-entrypoint-beat.sh + volumes: + - ${INSTALL_DIR}/logs/beat-worker:/code/plane/logs + depends_on: + - api + - plane-db + - plane-redis + + migrator: + <<: *app-env + image: registry.plane.tools/plane/backend-enterprise:${APP_RELEASE_VERSION} + restart: "no" + command: ./bin/docker-entrypoint-migrator.sh + volumes: + - ${INSTALL_DIR}/logs/migrator:/code/plane/logs + depends_on: + - plane-db + - plane-redis + + plane-db: + <<: *app-env + image: registry.plane.tools/plane/postgres:15.5-alpine + restart: unless-stopped + command: > + bash -c ' + docker-entrypoint.sh postgres & + sleep 5 && + result=$(PGPASSWORD=${POSTGRES_PASSWORD} psql -U $POSTGRES_USER -tAc "SELECT 1 FROM pg_database WHERE datname='${SILO_DB}'") && + echo "$result" | grep -q 1 && result=0 || result=1 && + if [ $result -eq 0 ]; then + echo "Database '${SILO_DB}' already exists" + else + PGPASSWORD=${POSTGRES_PASSWORD} psql -U $POSTGRES_USER -c "CREATE DATABASE ${SILO_DB};" + echo "Created database '${SILO_DB}'" + fi && + wait + ' + healthcheck: + test: ["CMD-SHELL", "PGPASSWORD=${POSTGRES_PASSWORD} psql -U $POSTGRES_USER -tAc \"SELECT CASE WHEN EXISTS(SELECT 1 FROM pg_database WHERE datname='${SILO_DB}') THEN 1 ELSE 0 END AS ${SILO_DB}, CASE WHEN EXISTS(SELECT 1 FROM pg_database WHERE datname='${POSTGRES_DB}') THEN 1 ELSE 0 END AS ${POSTGRES_DB}\" | grep -q '1|1' && echo 'Both databases exist' || (echo 'One or both databases are missing' && exit 1)"] + interval: 30s + timeout: 10s + retries: 3 + # volumes: + # - ${INSTALL_DIR}/data/db:/var/lib/postgresql/data + + plane-redis: + <<: *app-env + image: registry.plane.tools/plane/valkey:7.2.5-alpine + restart: unless-stopped + volumes: + - ${INSTALL_DIR}/data/redis:/data + + plane-mq: + <<: *app-env + image: registry.plane.tools/plane/rabbitmq:3.13.6-management-alpine + restart: unless-stopped + healthcheck: + test: rabbitmq-diagnostics -q ping + interval: 10s + timeout: 10s + retries: 5 + volumes: + - ${INSTALL_DIR}/data/mq:/var/lib/rabbitmq + + plane-minio: + <<: *app-env + image: registry.plane.tools/plane/minio:latest + restart: unless-stopped + command: server /export --console-address ":9090" + volumes: + - ${INSTALL_DIR}/data/minio/uploads:/export + - ${INSTALL_DIR}/data/minio/data:/data + + # Comment this if you already have a reverse proxy running + proxy: + <<: *proxy-env + image: registry.plane.tools/plane/caddy:latest + restart: unless-stopped + ports: + - ${LISTEN_HTTP_PORT:-80}:${LISTEN_HTTP_PORT:-80} + - ${LISTEN_HTTPS_PORT:-443}:${LISTEN_HTTPS_PORT:-443} + volumes: + - ${INSTALL_DIR}/Caddyfile:/etc/caddy/Caddyfile + - ${INSTALL_DIR}/caddy/config:/config + - ${INSTALL_DIR}/caddy/data:/data + depends_on: + - web + - api + - space + - admin + - live diff --git a/docker-compose.yml b/docker-compose.yml index 5163abbc55..df1d30e828 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,19 @@ +x-silo-env: &silo-env + environment: + - PORT=3000 + - BATCH_SIZE=${BATCH_SIZE:-60} + - MQ_PREFETCH_COUNT=${MQ_PREFETCH_COUNT:-5} + - APP_BASE_URL=${APP_BASE_URL:-http://web:3000} + - SILO_API_BASE_URL=${SILO_API_BASE_URL:-http://localhost:5050} + - DB_URL=${DB_URL:-postgresql://plane:plane@plane-db/silo} + - AMQP_URL=${AMQP_URL:-amqp://guest:guest@plane-mq:5672/} + - REDIS_URL=${REDIS_URL:-redis://plane-redis:6379/} + - SENTRY_DSN=${SENTRY_DSN:-" "} + - JIRA_CLIENT_ID=${JIRA_CLIENT_ID:-""} + - JIRA_CLIENT_SECRET=${JIRA_CLIENT_SECRET:-""} + - LINEAR_CLIENT_ID=${LINEAR_CLIENT_ID:-""} + - LINEAR_CLIENT_SECRET=${LINEAR_CLIENT_SECRET:-""} + services: web: container_name: web @@ -37,6 +53,43 @@ services: - api - web + live: + container_name: live + build: + context: . + dockerfile: ./live/Dockerfile.live + args: + DOCKER_BUILDKIT: 1 + restart: always + command: node live/dist/server.js + + silo: + <<: *silo-env + container_name: silo + build: + context: . + dockerfile: ./silo/Dockerfile.silo + args: + DOCKER_BUILDKIT: 1 + restart: always + command: > + /bin/sh -c " + npm --prefix ./silo run db:migrate + node silo/start.js + " + depends_on: + plane-mq: + condition: service_healthy + + # monitor: + # container_name: monitor + # build: + # context: . + # dockerfile: ./monitor/Dockerfile + # args: + # DOCKER_BUILDKIT: 1 + # restart: always + api: container_name: api build: @@ -45,7 +98,7 @@ services: args: DOCKER_BUILDKIT: 1 restart: always - command: ./bin/docker-entrypoint-api.sh + command: ./bin/docker-entrypoint-api-ee.sh env_file: - ./apiserver/.env depends_on: @@ -108,21 +161,17 @@ services: - plane-db - plane-redis - live: - container_name: plane-live - build: - context: . - dockerfile: ./live/Dockerfile.live - args: - DOCKER_BUILDKIT: 1 - restart: always - command: node live/dist/server.js - plane-db: container_name: plane-db image: postgres:15.7-alpine restart: always - command: postgres -c 'max_connections=1000' + command: > + bash -c " + docker-entrypoint.sh postgres & + sleep 10 + psql -U $$POSTGRES_USER -d $$POSTGRES_DB -c 'CREATE DATABASE silo;' + wait + " volumes: - pgdata:/var/lib/postgresql/data env_file: @@ -132,6 +181,12 @@ services: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} PGDATA: /var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s plane-redis: container_name: plane-redis @@ -142,14 +197,21 @@ services: plane-mq: container_name: plane-mq - image: rabbitmq:3.13.6-management-alpine + image: rabbitmq:management restart: always + ports: + - "15672:15672" env_file: - .env environment: RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER} RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD} RABBITMQ_DEFAULT_VHOST: ${RABBITMQ_VHOST} + healthcheck: + test: rabbitmq-diagnostics -q ping + interval: 10s + timeout: 10s + retries: 5 volumes: - rabbitmq_data:/var/lib/rabbitmq diff --git a/package.json b/package.json index 9dbd99c753..b693a88203 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "space", "admin", "live", + "silo", + "packages/silo/*", "packages/*" ], "scripts": { diff --git a/packages/constants/index.ts b/packages/constants/index.ts index cb114ae3aa..e521f50f70 100644 --- a/packages/constants/index.ts +++ b/packages/constants/index.ts @@ -1,3 +1,4 @@ export * from "./auth"; export * from "./issue"; export * from "./payment"; +export * from "./silo" diff --git a/packages/constants/silo.ts b/packages/constants/silo.ts new file mode 100644 index 0000000000..4874c3b846 --- /dev/null +++ b/packages/constants/silo.ts @@ -0,0 +1,20 @@ +export const JIRA_SCOPES = [ + "offline_access", + "read:jira-work", + "read:me", + "read:jira-user", + "read:workflow:jira", + "read:board-scope:jira-software", + "read:project:jira", + "read:epic:jira-software", + "read:sprint:jira-software", + "read:issue-details:jira", + "read:jql:jira", + "read:project.component:jira", + "read:group:jira", + "read:application-role:jira", + "read:avatar:jira", + "read:user:jira", + "read:attachment:jira", + "read:issue-meta:jira", +]; diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json deleted file mode 100644 index 0e7f98a7fd..0000000000 --- a/packages/eslint-config-custom/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "eslint-config-custom", - "private": true, - "version": "1.3.1", - "main": "index.js", - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "^7.1.1", - "@typescript-eslint/parser": "^7.1.1", - "eslint": "^8.57.0", - "eslint-config-next": "^14.1.0", - "eslint-config-prettier": "^9.1.0", - "eslint-config-turbo": "^1.12.4", - "eslint-plugin-import": "^2.29.1", - "eslint-plugin-react": "^7.33.2", - "typescript": "5.4.5" - } -} diff --git a/packages/eslint-config/next.js b/packages/eslint-config/next.js index 543cd131a4..7834855cbd 100644 --- a/packages/eslint-config/next.js +++ b/packages/eslint-config/next.js @@ -76,6 +76,11 @@ module.exports = { group: "external", position: "after", }, + { + pattern: "@silo/**", + group: "external", + position: "after", + }, { pattern: "@/**", group: "internal", diff --git a/packages/helpers/package.json b/packages/helpers/package.json index 05f7371ce1..0de82be3b1 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -15,7 +15,7 @@ "devDependencies": { "@types/node": "^22.5.4", "@types/react": "^18.3.5", - "typescript": "^5.6.2", + "typescript": "^5.3.3", "tsup": "^7.2.0" }, "dependencies": { diff --git a/packages/sdk/.eslintrc.js b/packages/sdk/.eslintrc.js new file mode 100644 index 0000000000..b3410a5e4e --- /dev/null +++ b/packages/sdk/.eslintrc.js @@ -0,0 +1,36 @@ +const { resolve } = require("node:path"); +const project = resolve(process.cwd(), "tsconfig.json"); + +module.exports = { + root: true, + extends: ["custom"], + parser: "@typescript-eslint/parser", + settings: { + "import/resolver": { + typescript: { + project, + }, + node: { + moduleDirectory: ["node_modules", "."], + }, + }, + }, + parserOptions: { + ecmaVersion: 2020, + sourceType: "module", + project: project, + }, + rules: { + "import/order": [ + "error", + { + groups: ["builtin", "external", "internal", "parent", "sibling"], + pathGroupsExcludedImportTypes: ["builtin", "internal", "react"], + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + }, + ], + }, +}; diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 0000000000..66f82e4601 --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,21 @@ +{ + "name": "@plane/sdk", + "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 --ext .ts src" + }, + "dependencies": { + "axios": "^1.7.7", + "lodash": "^4.17.21" + }, + "devDependencies": { + "@plane/eslint-config": "*", + "@plane/typescript-config": "*", + "tsc-alias": "^1.8.10" + } +} diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts new file mode 100644 index 0000000000..7715fca541 --- /dev/null +++ b/packages/sdk/src/client.ts @@ -0,0 +1,34 @@ +import { CycleService } from "@/services/cycle.service"; +import { IssueCommentService } from "@/services/issue-comment.service"; +import { IssueService } from "@/services/issue.service"; +import { LabelService } from "@/services/label.service"; +import { ModuleService } from "@/services/module.service"; +import { ProjectService } from "@/services/project.service"; +import { StateService } from "@/services/state.service"; +import { UserService } from "@/services/user.service"; +// types +import { ClientOptions } from "@/types/types"; + +export class Client { + options: ClientOptions; + users: UserService; + label: LabelService; + state: StateService; + issue: IssueService; + cycles: CycleService; + modules: ModuleService; + project: ProjectService; + issueComment: IssueCommentService; + + constructor(options: ClientOptions) { + this.options = options; + this.label = new LabelService(options); + this.state = new StateService(options); + this.issue = new IssueService(options); + this.users = new UserService(options); + this.project = new ProjectService(options); + this.issueComment = new IssueCommentService(options); + this.cycles = new CycleService(options); + this.modules = new ModuleService(options); + } +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 0000000000..d2ec2302c2 --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,2 @@ +export * from "./client"; +export * from "./types"; diff --git a/packages/sdk/src/lib/constants.ts b/packages/sdk/src/lib/constants.ts new file mode 100644 index 0000000000..7b3c94aaef --- /dev/null +++ b/packages/sdk/src/lib/constants.ts @@ -0,0 +1,15 @@ +export const PLANE_PRIORITIES = [ + "urgent", + "high", + "medium", + "low", + "none", +] as const; + +export const generateHexCode = () => { + const hexCode = Math.floor(Math.random() * 0xffffff) + .toString(16) + .padStart(6, "0"); + return `#${hexCode}`; +}; + diff --git a/packages/sdk/src/services/api.service.ts b/packages/sdk/src/services/api.service.ts new file mode 100644 index 0000000000..1cda3ee8aa --- /dev/null +++ b/packages/sdk/src/services/api.service.ts @@ -0,0 +1,52 @@ +import axios, { AxiosInstance } from "axios"; +// types +import { ClientOptions } from "@/types/types"; + +export abstract class APIService { + private axiosInstance: AxiosInstance; + + constructor(options: ClientOptions) { + const { baseURL } = options; + this.axiosInstance = axios.create({ + baseURL, + headers: { "X-API-Key": options.apiToken }, + }); + this.setupInterceptors(); + } + + private setupInterceptors() { + this.axiosInstance.interceptors.response.use( + (response) => response, + (error) => { + if (error.response && error.response.status === 401) { + console.log("401 error"); + } + return Promise.reject(error); + } + ); + } + + get(url: string, config = {}) { + return this.axiosInstance.get(url, config); + } + + post(url: string, data = {}, config = {}) { + return this.axiosInstance.post(url, data, config); + } + + put(url: string, data = {}, config = {}) { + return this.axiosInstance.put(url, data, config); + } + + patch(url: string, data = {}, config = {}) { + return this.axiosInstance.patch(url, data, config); + } + + delete(url: string, data?: any, config = {}) { + return this.axiosInstance.delete(url, { data, ...config }); + } + + request(config = {}) { + return this.axiosInstance(config); + } +} diff --git a/packages/sdk/src/services/cycle.service.ts b/packages/sdk/src/services/cycle.service.ts new file mode 100644 index 0000000000..aaf6b4d192 --- /dev/null +++ b/packages/sdk/src/services/cycle.service.ts @@ -0,0 +1,80 @@ +import { APIService } from "@/services/api.service"; +// types +import { + ClientOptions, + ExcludedProps, + ExCycle, + Optional, + Paginated, +} from "@/types/types"; + +export class CycleService extends APIService { + constructor(options: ClientOptions) { + super(options); + } + + async list(slug: string, projectId: string): Promise> { + return this.get(`/api/v1/workspaces/${slug}/projects/${projectId}/cycles/`) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async create( + slug: string, + projectId: string, + payload: Omit, ExcludedProps> + ): Promise { + return this.post( + `/api/v1/workspaces/${slug}/projects/${projectId}/cycles/`, + payload + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async update( + slug: string, + projectId: string, + cycleId: string, + payload: Omit, ExcludedProps> + ) { + return this.patch( + `/api/v1/workspaces/${slug}/projects/${projectId}/cycles/${cycleId}/`, + payload + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async destroy(slug: string, projectId: string, cycleId: string) { + return this.delete( + `/api/v1/workspaces/${slug}/projects/${projectId}/cycles/${cycleId}/` + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async addIssues( + slug: string, + projectId: string, + cycleId: string, + issueIds: string[] + ) { + return this.post( + `/api/v1/workspaces/${slug}/projects/${projectId}/cycles/${cycleId}/cycle-issues/`, + { issues: issueIds } + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/sdk/src/services/issue-comment.service.ts b/packages/sdk/src/services/issue-comment.service.ts new file mode 100644 index 0000000000..be6b829f4a --- /dev/null +++ b/packages/sdk/src/services/issue-comment.service.ts @@ -0,0 +1,78 @@ +import { APIService } from "@/services/api.service"; +// types +import { + ClientOptions, + ExcludedProps, + ExIssueComment, + ExIssueLabel, + Optional, + Paginated, +} from "@/types/types"; + +export class IssueCommentService extends APIService { + constructor(options: ClientOptions) { + super(options); + } + + async list( + slug: string, + projectId: string, + issueId: string + ): Promise> { + return this.get( + `/api/v1/workspaces/${slug}/projects/${projectId}/issues/${issueId}/comments/` + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async create( + slug: string, + projectId: string, + issueId: string, + payload: Omit, ExcludedProps> + ) { + return this.post( + `/api/v1/workspaces/${slug}/projects/${projectId}/issues/${issueId}/comments/`, + payload + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async update( + slug: string, + projectId: string, + issueId: string, + commentId: string, + payload: Omit, ExcludedProps> + ) { + return this.patch( + `/api/v1/workspaces/${slug}/projects/${projectId}/issues/${issueId}/comments/${commentId}/`, + payload + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async destroy( + slug: string, + projectId: string, + issueId: string, + commentId: string + ) { + return this.delete( + `/api/v1/workspaces/${slug}/projects/${projectId}/issues/${issueId}/comments/${commentId}/` + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/sdk/src/services/issue.service.ts b/packages/sdk/src/services/issue.service.ts new file mode 100644 index 0000000000..8397410687 --- /dev/null +++ b/packages/sdk/src/services/issue.service.ts @@ -0,0 +1,130 @@ +import { APIService } from "@/services/api.service"; +// types +import { + ClientOptions, + ExcludedProps, + ExIssue, + ExIssueAttachment, + Optional, + Paginated, +} from "@/types/types"; + +export class IssueService extends APIService { + constructor(options: ClientOptions) { + super(options); + } + + async list(slug: string, projectId: string): Promise> { + return this.get(`/api/v1/workspaces/${slug}/projects/${projectId}/issues/`) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async create( + slug: string, + projectId: string, + payload: Omit, ExcludedProps> + ): Promise { + return this.post( + `/api/v1/workspaces/${slug}/projects/${projectId}/issues/`, + payload + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async update( + slug: string, + projectId: string, + issueId: string, + payload: Omit, ExcludedProps> + ) { + return this.patch( + `/api/v1/workspaces/${slug}/projects/${projectId}/issues/${issueId}/`, + payload + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async destroy(slug: string, projectId: string, issueId: string) { + return this.delete( + `/api/v1/workspaces/${slug}/projects/${projectId}/issues/${issueId}/` + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async createLink( + slug: string, + projectId: string, + issueId: string, + title: string, + url: string + ) { + return this.post( + `/api/v1/workspaces/${slug}/projects/${projectId}/issues/${issueId}/links/`, + { + title: title, + url: url, + } + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async uploadIssueAttachment( + workspaceSlug: string, + projectId: string, + issueId: string, + file: FormData + ): Promise { + return this.post( + `/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-attachments/`, + file + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getIssueWithExternalId( + workspaceSlug: string, + projectId: string, + externalId: string, + externalSource: string + ): Promise { + return this.get( + `/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issues/?external_id=${externalId}&external_source=${externalSource}` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getIssueAttachments( + workspaceSlug: string, + projectId: string, + issueId: string + ): Promise { + return this.get( + `/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/issue-attachments/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/sdk/src/services/label.service.ts b/packages/sdk/src/services/label.service.ts new file mode 100644 index 0000000000..51ba1423d9 --- /dev/null +++ b/packages/sdk/src/services/label.service.ts @@ -0,0 +1,67 @@ +import { APIService } from "@/services/api.service"; +// types +import { + ClientOptions, + ExcludedProps, + ExIssueLabel, + Optional, + Paginated, +} from "@/types/types"; + +export class LabelService extends APIService { + constructor(options: ClientOptions) { + super(options); + } + + async list( + slug: string, + projectId: string + ): Promise> { + return this.get(`/api/v1/workspaces/${slug}/projects/${projectId}/labels/`) + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } + + async create( + slug: string, + projectId: string, + payload: Omit, ExcludedProps> + ): Promise { + return this.post( + `/api/v1/workspaces/${slug}/projects/${projectId}/labels/`, + payload + ) + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } + + async update( + slug: string, + projectId: string, + labelId: string, + payload: Omit, ExcludedProps> + ) { + return this.patch( + `/api/v1/workspaces/${slug}/projects/${projectId}/labels/${labelId}/`, + payload + ) + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } + + async destroy(slug: string, projectId: string, labelId: string) { + return this.delete( + `/api/v1/workspaces/${slug}/projects/${projectId}/labels/${labelId}/` + ) + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } +} diff --git a/packages/sdk/src/services/module.service.ts b/packages/sdk/src/services/module.service.ts new file mode 100644 index 0000000000..a1327b98b6 --- /dev/null +++ b/packages/sdk/src/services/module.service.ts @@ -0,0 +1,81 @@ +import { APIService } from "@/services/api.service"; +// types +import { + ClientOptions, + ExcludedProps, + ExModule, + Optional, + Paginated, +} from "@/types/types"; + +export class ModuleService extends APIService { + constructor(options: ClientOptions) { + super(options); + } + + async list(slug: string, projectId: string): Promise> { + return this.get(`/api/v1/workspaces/${slug}/projects/${projectId}/modules/`) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async create( + slug: string, + projectId: string, + payload: Omit, ExcludedProps> + ): Promise { + return this.post( + `/api/v1/workspaces/${slug}/projects/${projectId}/modules/`, + payload + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async update( + slug: string, + projectId: string, + moduleId: string, + payload: Omit, ExcludedProps> + ) { + return this.patch( + `/api/v1/workspaces/${slug}/projects/${projectId}/modules/${moduleId}/`, + payload + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async destroy(slug: string, projectId: string, moduleId: string) { + return this.delete( + `/api/v1/workspaces/${slug}/projects/${projectId}/modules/${moduleId}/` + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async addIssues( + slug: string, + projectId: string, + moduleId: string, + moduleName: string, + issueIds: string[] + ) { + return this.post( + `/api/v1/workspaces/${slug}/projects/${projectId}/modules/${moduleId}/module-issues/`, + { name: moduleName, issues: issueIds } + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/sdk/src/services/project.service.ts b/packages/sdk/src/services/project.service.ts new file mode 100644 index 0000000000..c4a062fef2 --- /dev/null +++ b/packages/sdk/src/services/project.service.ts @@ -0,0 +1,34 @@ +import { APIService } from "@/services/api.service"; +// types +import { + ClientOptions, + ExcludedProps, + ExProject, + Optional, + Paginated, +} from "@/types/types"; + +export class ProjectService extends APIService { + constructor(options: ClientOptions) { + super(options); + } + + async list(slug: string): Promise> { + return this.get(`/api/v1/workspaces/${slug}/projects/`) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async create( + slug: string, + payload: Omit, ExcludedProps> + ) { + return this.post(`/api/v1/workspaces/${slug}/projects/`, payload) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/sdk/src/services/state.service.ts b/packages/sdk/src/services/state.service.ts new file mode 100644 index 0000000000..2ff866334d --- /dev/null +++ b/packages/sdk/src/services/state.service.ts @@ -0,0 +1,64 @@ +import { APIService } from "@/services/api.service"; +// types +import { + ClientOptions, + ExcludedProps, + Optional, + Paginated, + ExState, +} from "@/types/types"; + +export class StateService extends APIService { + constructor(options: ClientOptions) { + super(options); + } + + async list(slug: string, projectId: string): Promise> { + return this.get(`/api/v1/workspaces/${slug}/projects/${projectId}/states/`) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async create( + slug: string, + projectId: string, + payload: Omit, ExcludedProps> + ): Promise { + return this.post( + `/api/v1/workspaces/${slug}/projects/${projectId}/states/`, + payload + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async update( + slug: string, + projectId: string, + stateId: string, + payload: Omit, ExcludedProps> + ) { + return this.patch( + `/api/v1/workspaces/${slug}/projects/${projectId}/states/${stateId}/`, + payload + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async destroy(slug: string, projectId: string, stateId: string) { + return this.delete( + `/api/v1/workspaces/${slug}/projects/${projectId}/states/${stateId}/` + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/sdk/src/services/user.service.ts b/packages/sdk/src/services/user.service.ts new file mode 100644 index 0000000000..52c36b7784 --- /dev/null +++ b/packages/sdk/src/services/user.service.ts @@ -0,0 +1,39 @@ +import { APIService } from "@/services/api.service"; +// types +import { + ClientOptions, + PlaneUser, + UserCreatePayload, + UserResponsePayload, +} from "@/types/types"; + +export class UserService extends APIService { + constructor(options: ClientOptions) { + super(options); + } + + async create( + workspaceSlug: string, + projectId: string, + payload: UserCreatePayload + ): Promise { + return this.post( + `/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/members/`, + payload + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async list(workspaceSlug: string, projectId: string): Promise { + return this.get( + `/api/v1/workspaces/${workspaceSlug}/projects/${projectId}/members/` + ) + .then((response) => response.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts new file mode 100644 index 0000000000..a58d5755e7 --- /dev/null +++ b/packages/sdk/src/types/index.ts @@ -0,0 +1 @@ +export * from "./types" \ No newline at end of file diff --git a/packages/sdk/src/types/types.ts b/packages/sdk/src/types/types.ts new file mode 100644 index 0000000000..9a9162a936 --- /dev/null +++ b/packages/sdk/src/types/types.ts @@ -0,0 +1,277 @@ +// service types +export type ClientOptions = { + baseURL: string; + apiToken: string; +}; + +/* ----------------- utility --------------------- */ +type ExBase = { + project: string; + workspace: string; + parent: string | null; + + external_id: string; + external_source: string; + + updated_by: string; + created_by: string; + + created_at: string; + update_at: string; +}; + +export type Optional = { + [K in keyof T]?: T[K]; +}; + +export type Paginated = { + grouped_by: null | string; + sub_grouped_by: null | string; + total_count: number; + next_cursor: string; + prev_cursor: string; + next_page_results: boolean; + prev_page_results: boolean; + count: number; + total_pages: number; + total_results: number; + results: T[]; +}; + +export type ExcludedProps = + | "id" + | "created_at" + | "created_by" + | "updated_at" + | "updated_by"; + +/* ----------------- Base Types --------------------- */ +type IIssueLabel = { + id: string; + name: string; + color: string; + parent: string | null; + sort_order: number; +}; + +export type IIssueComment = { + id: string; + issue: string; + actor: string; + comment_html: string; + access: string; + is_member: boolean; +}; + +type IIsssue = { + id: string; + updated_at: string; + point: any; + name: string; + description_html: string; + description_binary: any; + priority: string; + start_date: string; + target_date: string; + sequence_id: number; + sort_order: number; + completed_at: any; + archived_at: any; + is_draft: boolean; + state: string; + estimate_point: any; + assignees: string[]; + labels: string[]; +}; + +export type TStateGroups = + | "backlog" + | "unstarted" + | "started" + | "completed" + | "cancelled"; + +export interface IState { + id: string; + color: string; + default: boolean; + description: string; + group: TStateGroups; + name: string; + sequence: number; +} + +export type TModuleStatus = + | "backlog" + | "planned" + | "in-progress" + | "paused" + | "completed" + | "cancelled"; + +export interface IModule { + total_issues: number; + completed_issues: number; + backlog_issues: number; + started_issues: number; + unstarted_issues: number; + cancelled_issues: number; + total_estimate_points?: number; + completed_estimate_points?: number; + backlog_estimate_points: number; + started_estimate_points: number; + unstarted_estimate_points: number; + cancelled_estimate_points: number; + + id: string; + name: string; + description: string; + description_text: any; + description_html: any; + lead: string | null; + members: string[]; + // link_module?: ILinkDetails[]; + sub_issues?: number; + is_favorite: boolean; + sort_order: number; + // view_props: { + // filters: IIssueFilterOptions; + // }; + status?: TModuleStatus; + archived_at: string | null; + start_date: string | null; + target_date: string | null; +} + +export interface ICycle { + id: string; + total_issues: number; + cancelled_issues: number; + completed_issues: number; + started_issues: number; + unstarted_issues: number; + backlog_issues: number; + created_at: string; + updated_at: string; + name: string; + description: string; + start_date: string | null; + end_date: string | null; + view_props: Record; + sort_order: number; + progress_snapshot: Record; + archived_at: string | null; + logo_props: Record; + owned_by: string; +} + +export type PlaneEntities = { + labels: Optional[]; + issues: Optional[]; + users: Optional[]; + issue_comments: Optional[]; + cycles: Optional[]; + modules: Optional[]; +}; + +export type ExIssueAttachment = { + id: string; + attributes: { + name: string; + size: number; + }; + asset: string; + issue_id: string; + + //need + updated_at: string; + updated_by: string; + + external_id: string; + external_source: string; +}; + +/* ----------------- Project Type --------------------- */ +type IProject = { + id: string; + total_members: number; + total_cycles: number; + total_modules: number; + is_member: boolean; + sort_order: number; + member_role: number; + is_deployed: boolean; + name: string; + description: string; + description_text: any; + description_html: any; + network: number; + identifier: string; + emoji: any; + icon_prop: any; + module_view: boolean; + cycle_view: boolean; + issue_views_view: boolean; + page_view: boolean; + inbox_view: boolean; + is_time_tracking_enabled: boolean; + cover_image: string; + archive_in: number; + close_in: number; + logo_props: Record; + archived_at: string | null; + start_date: string | null; + target_date: string | null; + default_assignee: any; + project_lead: any; + estimate: any; + default_state: any; +}; + +export type ExProject = Partial; + +/* ----------------- Export Types --------------------- */ +export type ExIssueLabel = IIssueLabel & ExBase; +export type ExState = IState & + ExBase & { + status: "to_be_created"; + }; +export type ExIssue = IIsssue & + ExBase & { + links?: { + name: string; + url: string; + }[]; + attachments?: ExIssueAttachment[]; + external_source_state_id?: string; + }; +export type ExIssueComment = IIssueComment & ExBase; +export type ExModule = IModule & + ExBase & { + issues: string[]; + }; +export type ExCycle = ICycle & + ExBase & { + issues: string[]; + }; +type ExUser = { + id: string; + first_name: string; + last_name: string; + avatar: string; + role: number; +}; + +type UserMandatePayload = { + email: string; + display_name: string; +}; + +export type PlaneUser = ExUser & UserMandatePayload; + +export type UserCreatePayload = Omit, "id"> & + UserMandatePayload & { + project_id: string; + }; + +export type UserResponsePayload = ExUser & UserMandatePayload; diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 0000000000..163a34f782 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@plane/typescript-config/base.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "dist", + "jsx": "preserve", + "esModuleInterop": true, + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/silo/core/.eslintrc.js b/packages/silo/core/.eslintrc.js new file mode 100644 index 0000000000..558b8f76ed --- /dev/null +++ b/packages/silo/core/.eslintrc.js @@ -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, + }, +}; diff --git a/packages/silo/core/.prettierrc b/packages/silo/core/.prettierrc new file mode 100644 index 0000000000..87d988f1b2 --- /dev/null +++ b/packages/silo/core/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/packages/silo/core/package.json b/packages/silo/core/package.json new file mode 100644 index 0000000000..ea70266689 --- /dev/null +++ b/packages/silo/core/package.json @@ -0,0 +1,26 @@ +{ + "name": "@silo/core", + "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/**" + }, + "description": "Core functionality and services shared between UI and API", + "author": "Plane Engineering", + "license": "AGPL", + "dependencies": { + "csv-string": "^4.1.1", + "jira.js": "^4.0.1", + "@plane/constants": "*", + "@plane/sdk": "*" + }, + "devDependencies": { + "@plane/eslint-config": "*", + "@plane/typescript-config": "*", + "tsc-alias": "^1.8.10" + } +} diff --git a/packages/silo/core/src/index.ts b/packages/silo/core/src/index.ts new file mode 100644 index 0000000000..88c116d9f0 --- /dev/null +++ b/packages/silo/core/src/index.ts @@ -0,0 +1,5 @@ +// services +export * from "./services"; + +// types +export * from "./types"; diff --git a/packages/silo/core/src/services/index.ts b/packages/silo/core/src/services/index.ts new file mode 100644 index 0000000000..8a230af26f --- /dev/null +++ b/packages/silo/core/src/services/index.ts @@ -0,0 +1,2 @@ +export * from "./sync-cred.service"; +export * from "./sync-job.service"; diff --git a/packages/silo/core/src/services/sync-cred.service.ts b/packages/silo/core/src/services/sync-cred.service.ts new file mode 100644 index 0000000000..5bb2f1e553 --- /dev/null +++ b/packages/silo/core/src/services/sync-cred.service.ts @@ -0,0 +1,29 @@ +import axios, { AxiosInstance } from "axios"; +// types +import { TSyncServiceConfigured, TSyncServices } from "@/types"; + +export class SyncCredService { + public axiosInstance: AxiosInstance; + + constructor(baseURL: string) { + this.axiosInstance = axios.create({ baseURL }); + } + + /** + * @description check if the service is configured + * @param workspaceId: string + * @param userId: string + * @param source: TSyncServices + * @returns TSyncServiceConfigured + */ + async isServiceConfigured( + workspaceId: string, + userId: string, + source: TSyncServices + ): Promise { + return this.axiosInstance + .get(`/silo/api/credentials/${workspaceId}/${userId}/?source=${source}`) + .then((response) => response?.data) + .catch((error) => error?.response?.data); + } +} diff --git a/packages/silo/core/src/services/sync-job.service.ts b/packages/silo/core/src/services/sync-job.service.ts new file mode 100644 index 0000000000..fcc27a5577 --- /dev/null +++ b/packages/silo/core/src/services/sync-job.service.ts @@ -0,0 +1,125 @@ +import axios, { AxiosInstance } from "axios"; +import { TSyncJobWithConfig, TSyncServices, propertiesToOmit } from "@/types"; + +export class SyncJobService { + public axiosInstance: AxiosInstance; + + constructor(baseUrl: string, xApiKey: string) { + this.axiosInstance = axios.create({ baseURL: baseUrl, headers: { "x-api-key": xApiKey } }); + } + + /** + * @description Retrieves all jobs + * @returns Promise resolving to an array of Job objects + */ + async getSyncJobs(source: TSyncServices): Promise[]> { + return this.axiosInstance + .get(`/silo/api/jobs?source=${source}`) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description Fetches a job by its ID. + * @param jobId - Unique identifier of the job to fetch + * @returns Promise resolving to an array of Job objects + */ + async getSyncJobById(jobId: string): Promise> { + return this.axiosInstance + .get(`/silo/api/jobs/?id=${jobId}`) + .then((res) => res.data) + .catch((error) => { + console.log(error); + throw error?.response?.data; + }); + } + + /** + * @description Creates a new job. + * @param workspaceId - ID of the workspace + * @param projectId - ID of the project + * @param payload - Job data, excluding certain properties + * @returns Promise resolving to the created Job object + */ + async createSyncJob( + workspaceId: string, + projectId: string, + payload: Omit>, (typeof propertiesToOmit)[number]> + ) { + // Make workspaceId and projectId required + return this.axiosInstance + .post(`/silo/api/jobs/`, { + ...payload, + workspace_id: workspaceId, + project_id: projectId, + }) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description Updates an existing job. + * @param jobId - Unique identifier of the job to update + * @param payload - Partial job data to update + * @returns Promise resolving to the updated Job object + */ + async updateSyncJob(jobId: string, payload: Partial>) { + return this.axiosInstance + .put(`/silo/api/jobs/${jobId}`, payload) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description Deletes a job. + * @param jobId - Unique identifier of the job to delete + * @returns Promise resolving to the deletion result + */ + async deleteSyncJob(jobId: string) { + return this.axiosInstance + .delete(`/silo/api/jobs/${jobId}`) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description Creates a new job configuration. + * @param payload - Partial job configuration data + * @returns Promise resolving to an object containing the inserted ID + */ + async createSyncJobConfig(payload: Partial): Promise<{ insertedId: string }> { + const configPayload = { meta: payload }; + return this.axiosInstance + .post(`/silo/api/job-configs`, configPayload) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description Initiates a job. + * @param jobId - Unique identifier of the job to start + * @param migrationType - Type of migration, defaults to "JIRA" + * @returns Promise resolving to an array of Job objects + */ + async startSyncJob( + jobId: string, + migrationType: TSyncServices = "JIRA" + ): Promise[]> { + return this.axiosInstance + .post(`/silo/api/jobs/run`, { jobId, migrationType }) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/packages/silo/core/src/types/index.ts b/packages/silo/core/src/types/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/packages/silo/core/src/types/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/packages/silo/core/src/types/root.ts b/packages/silo/core/src/types/root.ts new file mode 100644 index 0000000000..a82c824f31 --- /dev/null +++ b/packages/silo/core/src/types/root.ts @@ -0,0 +1,93 @@ +// 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 = 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; +}; diff --git a/packages/silo/core/tsconfig.json b/packages/silo/core/tsconfig.json new file mode 100644 index 0000000000..48584186c7 --- /dev/null +++ b/packages/silo/core/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@plane/typescript-config/base.json", + "compilerOptions": { + "esModuleInterop": true, + "rootDir": "./src", + "outDir": "./dist", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/*"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/silo/github/.eslintrc.js b/packages/silo/github/.eslintrc.js new file mode 100644 index 0000000000..6ac91b2ef9 --- /dev/null +++ b/packages/silo/github/.eslintrc.js @@ -0,0 +1,36 @@ +// const { resolve } = require("node:path"); +// const project = resolve(process.cwd(), "tsconfig.json"); + +// module.exports = { +// root: true, +// extends: ["custom"], +// parser: "@typescript-eslint/parser", +// settings: { +// "import/resolver": { +// typescript: { +// project, +// }, +// node: { +// moduleDirectory: ["node_modules", "."], +// }, +// }, +// }, +// parserOptions: { +// ecmaVersion: 2020, +// sourceType: "module", +// project: project, +// }, +// rules: { +// "import/order": [ +// "error", +// { +// groups: ["builtin", "external", "internal", "parent", "sibling"], +// pathGroupsExcludedImportTypes: ["builtin", "internal", "react"], +// alphabetize: { +// order: "asc", +// caseInsensitive: true, +// }, +// }, +// ], +// }, +// }; diff --git a/packages/silo/github/.prettierrc b/packages/silo/github/.prettierrc new file mode 100644 index 0000000000..87d988f1b2 --- /dev/null +++ b/packages/silo/github/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/packages/silo/github/package.json b/packages/silo/github/package.json new file mode 100644 index 0000000000..5553ad76cf --- /dev/null +++ b/packages/silo/github/package.json @@ -0,0 +1,28 @@ +{ + "name": "@silo/github", + "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 --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", + "@plane/eslint-config": "*", + "@plane/typescript-config": "*", + "tsc-alias": "^1.8.10" + } +} diff --git a/packages/silo/github/src/etl/index.ts b/packages/silo/github/src/etl/index.ts new file mode 100644 index 0000000000..f78f4add44 --- /dev/null +++ b/packages/silo/github/src/etl/index.ts @@ -0,0 +1 @@ +export * from "./pull"; diff --git a/packages/silo/github/src/etl/pull.ts b/packages/silo/github/src/etl/pull.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/packages/silo/github/src/etl/pull.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/silo/github/src/index.ts b/packages/silo/github/src/index.ts new file mode 100644 index 0000000000..b19f5a601d --- /dev/null +++ b/packages/silo/github/src/index.ts @@ -0,0 +1,3 @@ +export * from "./services"; +export * from "./types"; +export * from "./etl"; diff --git a/packages/silo/github/src/services/api.service.ts b/packages/silo/github/src/services/api.service.ts new file mode 100644 index 0000000000..dca6200e73 --- /dev/null +++ b/packages/silo/github/src/services/api.service.ts @@ -0,0 +1,97 @@ +import { createAppAuth } from "@octokit/auth-app"; +import { Octokit } from "@octokit/rest"; + +// Service connected with octokit and facilitating github data +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", + }); + } + + async getRepos() { + const pageRepos = this.client.paginate.iterator( + this.client.apps.listReposAccessibleToInstallation, + ); + const data: any = []; + + for await (const { data: repos } of pageRepos) { + // @ts-ignore + data.push(...repos); + } + + return data; + } + + async getReposForInstallation(installationId: number) { + const pageRepos = this.client.paginate.iterator( + this.client.apps.listReposAccessibleToInstallation, + { + installation_id: installationId, + }, + ); + + const data: any = []; + + for await (const { data: repos } of pageRepos) { + // @ts-ignore + data.push(...repos); + } + + return data; + } + + async searchRepos(query: string) { + return this.client.search.repos({ + q: query, + }); + } + + async getIssues(owner: string, repo: string) { + return this.client.issues.listForRepo({ + owner, + repo, + }); + } + + async getLabels(owner: string, repo: string) { + return this.client.issues.listLabelsForRepo({ + owner, + repo, + }); + } + + async getProjects(owner: string, repo: string) { + return this.client.projects.listForRepo({ + owner, + repo, + }); + } + + async getProjectIssues(projectId: number) { + return this.client.projects.listColumns({ + project_id: projectId, + }); + } + + async getUsersForRepo(owner: string, repo: string) { + return this.client.repos.listCollaborators({ + owner, + repo, + }); + } + + async getInstallation(installationId: number) { + return this.client.apps.getInstallation({ + installation_id: installationId, + }); + } +} diff --git a/packages/silo/github/src/services/auth.service.ts b/packages/silo/github/src/services/auth.service.ts new file mode 100644 index 0000000000..0dcc7ebdfc --- /dev/null +++ b/packages/silo/github/src/services/auth.service.ts @@ -0,0 +1,56 @@ +import { GithubAuthConfig, GithubAuthorizeState, GithubAuthPayload, TokenResponse } from "@/types"; +import axios from "axios"; + +export class GithubAuthService { + config: GithubAuthConfig; + + constructor(config: GithubAuthConfig) { + this.config = config; + } + + /** + * Generates the authorization URL for Github OAuth + * @param state The state object to be passed to Github + * @returns The full authorization URL as a string + */ + getAuthUrl(state: GithubAuthorizeState): string { + const stateString = JSON.stringify(state); + return `https://github.com/apps/${this.config.appName}/installations/select_target?state=${stateString}`; + } + + /** + * 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: GithubAuthPayload): Promise<{ + response: TokenResponse; + state: GithubAuthorizeState; + }> { + const { code, state } = payload; + + const data = { + 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 { + const data = { + refresh_token: refresh_token, + grant_type: "refresh_token", + }; + + const { data: response } = await axios.post(this.config.tokenUrl, data); + + return response; + } +} diff --git a/packages/silo/github/src/services/index.ts b/packages/silo/github/src/services/index.ts new file mode 100644 index 0000000000..fac115835c --- /dev/null +++ b/packages/silo/github/src/services/index.ts @@ -0,0 +1,2 @@ +export * from "./auth.service"; +export * from "./api.service"; diff --git a/packages/silo/github/src/types/index.ts b/packages/silo/github/src/types/index.ts new file mode 100644 index 0000000000..23c6f2b35a --- /dev/null +++ b/packages/silo/github/src/types/index.ts @@ -0,0 +1,36 @@ +import { RestEndpointMethodTypes } from "@octokit/rest"; + +export type GithubEntity = {}; + +export type GithubConfig = {}; + +export type GithubInstallation = + RestEndpointMethodTypes["apps"]["getInstallation"]["response"]["data"]; +export type GithubRepository = + RestEndpointMethodTypes["apps"]["listReposAccessibleToInstallation"]["response"]["data"]["repositories"]; + +export type GithubAuthPayload = { + code: string; + state: GithubAuthorizeState; +}; + +export type GithubAuthorizeState = { + workspace_slug: string; + workspace_id: string; + plane_api_token: string; +}; + +export type TokenResponse = { + access_token: string; + expires_in: number; + token_type: string; + scope: string; + refresh_token: string; + refresh_token_expires_in: number; +}; + +export type GithubAuthConfig = { + tokenUrl: string; + callbackUrl: string; + appName: string; +}; diff --git a/packages/silo/github/tsconfig.json b/packages/silo/github/tsconfig.json new file mode 100644 index 0000000000..01cb5f34f3 --- /dev/null +++ b/packages/silo/github/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@plane/typescript-config/base.json", + "compilerOptions": { + "esModuleInterop": true, + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "module": "ESNext", + "target": "ES6", + "moduleResolution": "node" + }, + "include": ["src/*"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/packages/silo/jira/.eslintrc.js b/packages/silo/jira/.eslintrc.js new file mode 100644 index 0000000000..b3c4e75214 --- /dev/null +++ b/packages/silo/jira/.eslintrc.js @@ -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, +// }, +// }; diff --git a/packages/silo/jira/package.json b/packages/silo/jira/package.json new file mode 100644 index 0000000000..97203c5677 --- /dev/null +++ b/packages/silo/jira/package.json @@ -0,0 +1,27 @@ +{ + "name": "@silo/jira", + "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": "Jira package used by silo for importers and integrations", + "author": "Plane Engineering", + "license": "AGPL", + "dependencies": { + "csv-string": "^4.1.1", + "jira.js": "^4.0.1", + "@plane/constants": "*", + "@plane/sdk": "*" + }, + "devDependencies": { + "typescript": "^5.3.3", + "@plane/eslint-config": "*", + "@plane/typescript-config": "*", + "tsc-alias": "^1.8.10" + } +} diff --git a/packages/silo/jira/src/etl/index.ts b/packages/silo/jira/src/etl/index.ts new file mode 100644 index 0000000000..ffd29d104e --- /dev/null +++ b/packages/silo/jira/src/etl/index.ts @@ -0,0 +1,2 @@ +export * from "./pull"; +export * from "./transform"; diff --git a/packages/silo/jira/src/etl/pull.ts b/packages/silo/jira/src/etl/pull.ts new file mode 100644 index 0000000000..d3e886c18c --- /dev/null +++ b/packages/silo/jira/src/etl/pull.ts @@ -0,0 +1,149 @@ +import * as CSV from "csv-string"; +import { + Issue as IJiraIssue, + ComponentWithIssueCount, + Comment as JComment, +} from "jira.js/out/version3/models"; +import { + fetchPaginatedData, + formatDateStringForHHMM, + removeArrayObjSpaces, +} from "../helpers"; +import { JiraService } from "@/services"; +import { + ImportedJiraUser, + JiraComment, + JiraComponent, + JiraSprint, + PaginatedResponse, +} from "@/types"; + +export function pullUsers(users: string): ImportedJiraUser[] { + const jiraUsersObject = CSV.parse(users, { output: "objects" }); + return removeArrayObjSpaces(jiraUsersObject) as ImportedJiraUser[]; +} + +export async function pullLabels(client: JiraService): Promise { + const labels: string[] = []; + await fetchPaginatedData( + (startAt) => client.getResourceLabels(startAt), + (values) => labels.push(...(values as string[])), + "values" + ); + return labels; +} + +export async function pullIssues( + client: JiraService, + projectKey: string, + from?: Date +): Promise { + const issues: IJiraIssue[] = []; + await fetchPaginatedData( + (startAt) => + client.getProjectIssues( + projectKey, + startAt, + from ? formatDateStringForHHMM(from) : "" + ), + (values) => issues.push(...(values as IJiraIssue[])), + "issues" + ); + return issues; +} + +export async function pullComments( + issues: IJiraIssue[], + client: JiraService +): Promise { + return await pullCommentsInBatches(issues, 20, client); +} + +export async function pullSprints( + client: JiraService, + projectId: string +): Promise { + const jiraSprints: JiraSprint[] = []; + try { + const boards = await client.getProjectBoards(projectId); + for (const board of boards.values) { + const sprints = await client.getBoardSprints(board.id as number); + for (const sprint of sprints.values) { + const boardIssues: unknown[] = []; + await fetchPaginatedData( + (startAt) => + client.getBoardSprintsIssues( + board.id as number, + sprint.id as number, + startAt + ) as Promise, + (values) => boardIssues.push(...(values as IJiraIssue[])), + "issues" + ); + jiraSprints.push({ sprint, issues: boardIssues as IJiraIssue[] }); + } + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + throw new Error(`Could not fetch sprints, something went wrong`); + } + return jiraSprints; +} + +export async function pullComponents( + client: JiraService, + projectKey: string +): Promise { + const jiraComponents: JiraComponent[] = []; + try { + const jiraComponentObjects: ComponentWithIssueCount[] = + await client.getProjectComponents(projectKey); + for (const component of jiraComponentObjects) { + const issues = await client.getProjectComponentIssues(component.id!); + if (issues.issues) { + jiraComponents.push({ component, issues: issues.issues }); + } + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + throw new Error(`Could not fetch components, something went wrong`); + } + return jiraComponents; +} + +export const pullCommentsForIssue = async ( + issue: IJiraIssue, + client: JiraService +): Promise => { + const comments: JiraComment[] = []; + await fetchPaginatedData( + (startAt) => client.getIssueComments(issue.id, startAt), + (values) => { + const jiraComments = values.map( + (comment): JiraComment => ({ + ...(comment as JComment), + issue_id: issue.id, + }) + ); + comments.push(...jiraComments); + }, + "comments" + ); + return comments; +}; + +export const pullCommentsInBatches = async ( + issues: IJiraIssue[], + batchSize: number, + client: JiraService +): Promise => { + const comments: JiraComment[] = []; + for (let i = 0; i < issues.length; i += batchSize) { + const batch = issues.slice(i, i + batchSize); + const batchComments = await Promise.all( + batch.map((issue) => pullCommentsForIssue(issue, client)) + ); + comments.push(...batchComments.flat()); + } + return comments; +}; diff --git a/packages/silo/jira/src/etl/transform.ts b/packages/silo/jira/src/etl/transform.ts new file mode 100644 index 0000000000..da90de3881 --- /dev/null +++ b/packages/silo/jira/src/etl/transform.ts @@ -0,0 +1,129 @@ +import { + ExCycle, + ExIssueComment, + ExIssueLabel, + ExModule, + ExIssue as PlaneIssue, + PlaneUser, +} from "@plane/sdk"; +import { + IJiraIssue, + ImportedJiraUser, + IPriorityConfig, + IStateConfig, + JiraComment, + JiraComponent, + JiraSprint, +} from "@/types"; +import { + getFormattedDate, + getRandomColor, + getTargetAttachments, + getTargetPriority, + getTargetState, +} from "../helpers"; + +export const transformIssue = ( + issue: IJiraIssue, + resourceUrl: string, + stateMap: IStateConfig[], + priorityMap: IPriorityConfig[], +): Partial => { + const targetState = getTargetState(stateMap, issue.fields.status); + const targetPriority = getTargetPriority(priorityMap, issue.fields.priority); + const attachments = getTargetAttachments(issue.fields.attachment); + const renderedFields = (issue.renderedFields as { description: string }) ?? { + description: "

", + }; + const links = [ + { + name: "Linked Jira Issue", + url: `${resourceUrl}/browse/${issue.key}`, + }, + ]; + let description = renderedFields.description ?? "

"; + if (description === "") { + description = "

"; + } + + issue.fields.labels.push("JIRA IMPORTED"); + + return { + assignees: issue.fields.assignee?.displayName + ? [issue.fields.assignee.displayName] + : [], + links, + external_id: issue.id, + external_source: "JIRA", + created_by: issue.fields.creator?.displayName, + name: issue.fields.summary ?? "Untitled", + description_html: description, + target_date: issue.fields.duedate, + start_date: issue.fields.customfield_10015, + created_at: issue.fields.created, + attachments: attachments, + state: targetState?.id ?? "", + external_source_state_id: targetState?.external_id ?? "", + priority: targetPriority ?? "none", + labels: issue.fields.labels, + parent: issue.fields.parent?.id, + } as unknown as PlaneIssue; +}; + +export const transformLabel = (label: string): Partial => { + return { + name: label, + color: getRandomColor(), + }; +}; + +export const transformComment = ( + comment: JiraComment, +): Partial => { + return { + external_id: comment.id, + external_source: "JIRA", + created_at: getFormattedDate(comment.created), + created_by: comment.author?.displayName, + comment_html: comment.renderedBody ?? "

", + actor: comment.author?.displayName, + issue: comment.issue_id, + }; +}; + +export const transformUser = (user: ImportedJiraUser): Partial => { + const [first_name, last_name] = user.user_name.split(" "); + const role = + user.org_role && user.org_role.toLowerCase().includes("admin") ? 20 : 15; + + return { + email: user.email, + display_name: user.user_name, + first_name: first_name ?? "", + last_name: last_name ?? "", + role, + }; +}; + +export const transformSprint = (sprint: JiraSprint): Partial => { + return { + external_id: sprint.sprint.id.toString(), + external_source: "JIRA", + name: sprint.sprint.name, + start_date: getFormattedDate(sprint.sprint.startDate), + end_date: getFormattedDate(sprint.sprint.endDate), + created_at: getFormattedDate(sprint.sprint.createdDate), + issues: sprint.issues.map((issue) => issue.id), + }; +}; + +export const transformComponent = ( + component: JiraComponent, +): Partial => { + return { + external_id: component.component.id ?? "", + external_source: "JIRA", + name: component.component.name, + issues: component.issues.map((issue) => issue.id), + }; +}; diff --git a/packages/silo/jira/src/helpers/date.ts b/packages/silo/jira/src/helpers/date.ts new file mode 100644 index 0000000000..2f7a9a4723 --- /dev/null +++ b/packages/silo/jira/src/helpers/date.ts @@ -0,0 +1,14 @@ +export const getFormattedDate = ( + date: string | 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; + } +}; diff --git a/packages/silo/jira/src/helpers/etl.ts b/packages/silo/jira/src/helpers/etl.ts new file mode 100644 index 0000000000..4fc45596b8 --- /dev/null +++ b/packages/silo/jira/src/helpers/etl.ts @@ -0,0 +1,83 @@ +import { IPriorityConfig, IStateConfig, PaginatedResponse } from "@/types"; +import { ExIssueAttachment, ExState } from "@plane/sdk"; +import { + Attachment as JiraAttachment, + Priority as JiraPriority, + StatusDetails as JiraState, +} from "jira.js/out/version3/models"; + +export const getTargetState = ( + stateMap: IStateConfig[], + sourceState: JiraState +): ExState | undefined => { + // Assign the external source and external id from jira and return the target state + const targetState = stateMap.find((state: IStateConfig) => { + if (state.source_state.id === sourceState.id) { + state.target_state.external_source = "JIRA"; + state.target_state.external_id = sourceState.id as string; + return state; + } + }); + + return targetState?.target_state; +}; + +export const getTargetAttachments = ( + attachments?: JiraAttachment[] +): Partial => { + if (!attachments) { + return []; + } + const attachmentArray = attachments + .map((attachment: JiraAttachment): Partial => { + return { + external_id: attachment.id ?? "", + external_source: "JIRA", + attributes: { + name: attachment.filename ?? "Untitled", + size: attachment.size ?? 0, + }, + asset: attachment.content ?? "", + }; + }) + .filter((attachment) => attachment !== undefined) as ExIssueAttachment[]; + + return attachmentArray; +}; + +export const getTargetPriority = ( + priorityMap: IPriorityConfig[], + sourcePriority: JiraPriority +): string | undefined => { + const targetPriority = priorityMap.find( + (priority: IPriorityConfig) => + priority.source_priority.name === sourcePriority.name + ); + return targetPriority?.target_priority; +}; + +export const fetchPaginatedData = async ( + fetchFunction: (startAt: number) => Promise, + processFunction: (values: T[]) => void, + listPropertyName: string +) => { + let hasMore = true; + let startAt = 0; + let total = 0; + + while (hasMore) { + const response = await fetchFunction(startAt); + const values = response[listPropertyName] as T[]; // Type assertion + if (response.total == 0) { + break; + } + if (response && response.total && values) { + total = response.total; + processFunction(values); + startAt += values.length; + if (response.total <= startAt) { + hasMore = false; + } + } + } +}; diff --git a/packages/silo/jira/src/helpers/index.ts b/packages/silo/jira/src/helpers/index.ts new file mode 100644 index 0000000000..769cbb073f --- /dev/null +++ b/packages/silo/jira/src/helpers/index.ts @@ -0,0 +1,3 @@ +export * from "./date"; +export * from "./string"; +export * from "./etl"; diff --git a/packages/silo/jira/src/helpers/string.ts b/packages/silo/jira/src/helpers/string.ts new file mode 100644 index 0000000000..5d8dc35dd6 --- /dev/null +++ b/packages/silo/jira/src/helpers/string.ts @@ -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); +}; diff --git a/packages/silo/jira/src/index.ts b/packages/silo/jira/src/index.ts new file mode 100644 index 0000000000..511a8f5882 --- /dev/null +++ b/packages/silo/jira/src/index.ts @@ -0,0 +1,4 @@ +export * from "./helpers"; +export * from "./types"; +export * from "./services"; +export * from "./etl"; diff --git a/packages/silo/jira/src/services/api.service.ts b/packages/silo/jira/src/services/api.service.ts new file mode 100644 index 0000000000..70f5b81afa --- /dev/null +++ b/packages/silo/jira/src/services/api.service.ts @@ -0,0 +1,242 @@ +// services +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"; + +export class JiraService { + private jiraClient: Version3Client; + 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, + }, + }, + }); + + 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); + } + } + throw error; + }; + } + + async getCurrentUser() { + return await this.jiraClient.myself.getCurrentUser(); + } + + async getNumberOfIssues(projectKey: string) { + const issues = await this.jiraClient.issueSearch.searchForIssuesUsingJql({ + jql: `project = ${projectKey}`, + maxResults: 0, + }); + return issues.total; + } + + async getIssueFields() { + return this.jiraClient.issueFields.getFields(); + } + + async getResourceStatuses() { + return this.jiraClient.status.search(); + } + + // async getProjectStatuses(projectId: string) { + // return this.jiraClient.status.search({ + // projectId: projectId, + // }); + // } + + async getProjectStatuses(projectId: string) { + return this.jiraClient.projects.getAllStatuses({ + projectIdOrKey: projectId, + }); + } + + async getFields() { + return this.jiraClient.issueFields.getFields(); + } + + async getProjectComponents(projectId: string) { + return this.jiraClient.projectComponents.getProjectComponents({ + projectIdOrKey: projectId, + }); + } + + async getProjectComponentIssues(componentId: string) { + return this.jiraClient.issueSearch.searchForIssuesUsingJql({ + jql: `component = ${componentId}`, + }); + } + + async getBoardSprints(boardId: number) { + const board = new Board(this.jiraClient); + return board.getAllSprints({ + boardId: boardId, + }); + } + + async getBoardSprintsIssues( + boardId: number, + sprintId: number, + startAt: number, + ) { + const board = new Board(this.jiraClient); + return board.getBoardIssuesForSprint({ + boardId: boardId, + sprintId: sprintId, + startAt: startAt, + }); + } + + async getBoardEpics(boardId: number) { + const board = new Board(this.jiraClient); + return board.getEpics({ + boardId: boardId, + }); + } + + async getProjectBoards(projectId: string) { + const board = new Board(this.jiraClient); + return board.getAllBoards({ + projectKeyOrId: projectId, + }); + } + + async getIssuePriorities() { + return this.jiraClient.issuePriorities.getPriorities(); + } + + async getResourceLabels(startAt = 0): Promise { + return this.jiraClient.labels.getAllLabels({ + startAt: startAt, + }); + } + + async getResourceProjects(startAt: number = 0) { + return this.jiraClient.projects.searchProjects({ + startAt: startAt, + }); + } + + /* TODO: Confirm the endpoint */ + async getProjectIssueTypes(projectId: string) { + return this.jiraClient.issueTypes.getIssueTypesForProject({ + projectId: projectId as unknown as number, + }); + } + + async getProjectIssues( + projectKey: string, + startAt = 0, + createdAfter?: string, + ) { + return this.jiraClient.issueSearch.searchForIssuesUsingJql({ + jql: createdAfter + ? `project = ${projectKey} AND (created >= "${createdAfter}" OR updated >= "${createdAfter}")` + : `project = ${projectKey}`, + expand: "renderedFields", + fields: ["*all"], + startAt, + }); + } + + async getAllLabels() { + const labels: string[] = []; + let startAt = 0; + const maxResults = 1000; + + while (true) { + const response = await this.jiraClient.labels.getAllLabels({ + startAt, + maxResults, + }); + + if (response.values) { + labels.push(...response.values); + } + + if (response.isLast) { + break; + } + + startAt += maxResults; + } + + return labels; + } + + async getNumberOfLabels(projectKey: string) { + const labels = await this.jiraClient.labels.getAllLabels(); + return labels.total; + } + + async getProjectUsers(projectKey: string) { + return this.jiraClient.userSearch.findAssignableUsers({ + project: projectKey, + }); + } + + async getIssueComments(issueId: string, startAt: number) { + return await this.jiraClient.issueComments.getComments({ + issueIdOrKey: issueId, + startAt: startAt, + expand: "renderedBody", + }); + } + + async getResources(): Promise { + const axiosInstance = axios.create({ + baseURL: "https://api.atlassian.com", + }); + + axiosInstance.interceptors.request.use( + async (config: any) => { + config.headers.Authorization = `Bearer ${this.accessToken}`; + return config; + }, + (error) => { + return Promise.reject(error); + }, + ); + + const response = await axiosInstance.get( + "/oauth/token/accessible-resources", + ); + + return response.data; + } +} + +export default JiraService; diff --git a/packages/silo/jira/src/services/auth.service.ts b/packages/silo/jira/src/services/auth.service.ts new file mode 100644 index 0000000000..b447997381 --- /dev/null +++ b/packages/silo/jira/src/services/auth.service.ts @@ -0,0 +1,65 @@ +import { JiraAuthProps, JiraAuthState } from "@/types"; +import axios from "axios"; +import { JIRA_SCOPES } from "@plane/constants"; + +export type JiraTokenResponse = { + access_token: string; + refresh_token: string; + expires_in: number; +}; + +export class JiraAuth { + props: JiraAuthProps; + + constructor(props: JiraAuthProps) { + this.props = props; + } + + getCallbackUrl(hostname: string): string { + // return this.props.callbackURL; + // remove the / at the end of the hostname + const host = hostname.endsWith("/") ? hostname.slice(0, -1) : hostname; + return host + this.props.callbackURL; + } + + getAuthorizationURL(state: JiraAuthState, hostname: string): string { + const scope = JIRA_SCOPES.join(" "); + const callbackURL = this.getCallbackUrl(hostname); + const stateString = JSON.stringify(state); + const encodedState = Buffer.from(stateString).toString("base64"); + const consentURL = `${this.props.authorizeURL}?client_id=${this.props.clientId}&redirect_uri=${callbackURL}&access_type=offline&response_type=code&scope=${scope}&state=${encodedState || ""}`; + return consentURL; + } + + async getAccessToken( + code: string, + state: JiraAuthState, + hostname: string, + ): Promise<{ tokenResponse: JiraTokenResponse; state: JiraAuthState }> { + const data = { + code, + client_id: this.props.clientId, + client_secret: this.props.clientSecret, + redirect_uri: this.getCallbackUrl(hostname), + grant_type: "authorization_code", + }; + + const { data: tokenResponse } = await axios.post(this.props.tokenURL, data); + return { tokenResponse, state }; + } + + async getRefreshToken(refreshToken: string): Promise { + const data = { + client_id: this.props.clientId, + client_secret: this.props.clientSecret, + refresh_token: refreshToken, + grant_type: "refresh_token", + }; + + const { data: response } = await axios.post( + "https://auth.atlassian.com/oauth/token", + data, + ); + return response as JiraTokenResponse; + } +} diff --git a/packages/silo/jira/src/services/builder.ts b/packages/silo/jira/src/services/builder.ts new file mode 100644 index 0000000000..935e31082f --- /dev/null +++ b/packages/silo/jira/src/services/builder.ts @@ -0,0 +1,21 @@ +import { JiraProps } from "@/types"; +import { JiraAuth } from "./auth.service"; +import JiraService from "./api.service"; + +export const createJiraAuth = ( + clientId: string, + clientSecret: string, + callbackURL: string, + authorizeURL: string, + tokenURL: string +): JiraAuth => + new JiraAuth({ + clientId, + clientSecret, + callbackURL, + authorizeURL, + tokenURL, + }); + +export const createJiraService = (props: JiraProps): JiraService => + new JiraService(props); diff --git a/packages/silo/jira/src/services/index.ts b/packages/silo/jira/src/services/index.ts new file mode 100644 index 0000000000..8a2cd5ef3a --- /dev/null +++ b/packages/silo/jira/src/services/index.ts @@ -0,0 +1,3 @@ +export * from "./builder"; +export * from "./api.service"; +export * from "./auth.service"; diff --git a/packages/silo/jira/src/types/index.ts b/packages/silo/jira/src/types/index.ts new file mode 100644 index 0000000000..8c7a9e239d --- /dev/null +++ b/packages/silo/jira/src/types/index.ts @@ -0,0 +1,151 @@ +import { ExProject, ExState } from "@plane/sdk"; +import { + Comment as JComment, + ComponentWithIssueCount, + Priority as JiraPriority, + Project as JiraProject, + StatusDetails as JiraStatus, + FieldDetails, + Issue, + IssueTypeWithStatus as JiraStates, +} from "jira.js/out/version3/models"; + +export type JiraProps = { + cloudId: string; + accessToken: string; + refreshToken: string; + refreshTokenFunc: (refreshToken: string) => Promise<{ + access_token: string; + refresh_token: string; + expires_in: number; + }>; + refreshTokenCallback: (arg0: { + access_token: string; + refresh_token: string; + expires_in: number; + }) => Promise; +}; + +export type JiraResource = { + id: string; + url: string; + name: string; + scopes: string[]; + avatarUrl: string; +}; + +export type ImportedJiraUser = { + user_id: string; + user_name: string; + email: string; + user_status: string; + added_to_org: string; + org_role: string; +}; + +export type JiraComment = JComment & { + issue_id: string; +}; + +export type JiraSprintObject = { + id: number; + name: string; + state: string; + startDate?: string; + endDate?: string; + createdDate?: string; +}; + +export interface PaginatedResponse { + total?: number; + [key: string]: any; // Allow dynamic properties +} + +export type JiraSprint = { + sprint: JiraSprintObject; + issues: IJiraIssue[]; +}; + +export type JiraComponent = { + component: ComponentWithIssueCount; + issues: IJiraIssue[]; +}; + +export type JiraEntity = { + labels: string[]; + issues: IJiraIssue[]; + users: ImportedJiraUser[]; + issue_comments: JiraComment[]; + sprints: JiraSprint[]; + components: JiraComponent[]; + customFields: FieldDetails[]; +}; + +export interface IResource { + id: string; + url: string; + name: string; + scopes: string[]; + avatarUrl: string; +} + +// Define the type for IssueType +export interface IIssueTypeConfig { + name: string; + value: string; +} + +// Define the type for Label +export interface ILabelConfig { + name: string; + value: boolean; +} + +// Define the type for State +export interface IStateConfig { + source_state: JiraStatus; + target_state: ExState; +} + +// Define the type for Priority +export interface IPriorityConfig { + source_priority: JiraPriority; + target_priority: string; +} + +export type JiraConfig = { + issues: number; + // Users are string, as not we are saving the csv string into the config + users: string; + resource: IResource; + project: JiraProject; + planeProject: ExProject; + issueType: string; + label: ILabelConfig[]; + state: IStateConfig[]; + priority: IPriorityConfig[]; +}; + +export type JiraAuthState = { + apiToken: string; + workspaceId: string; + workspaceSlug: string; + userId: string; +}; + +export type JiraAuthPayload = { + state: string; + code: string; +}; + +export type JiraAuthProps = { + clientId: string; + clientSecret: string; + callbackURL: string; + authorizeURL: string; + tokenURL: string; +}; + +export type IJiraIssue = Issue; + +export type { JiraProject, JiraStates, JiraStatus, JiraPriority }; diff --git a/packages/silo/jira/tsconfig.json b/packages/silo/jira/tsconfig.json new file mode 100644 index 0000000000..2eeb5ba6e4 --- /dev/null +++ b/packages/silo/jira/tsconfig.json @@ -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"] +} diff --git a/packages/silo/linear/.eslintrc.js b/packages/silo/linear/.eslintrc.js new file mode 100644 index 0000000000..6ac91b2ef9 --- /dev/null +++ b/packages/silo/linear/.eslintrc.js @@ -0,0 +1,36 @@ +// const { resolve } = require("node:path"); +// const project = resolve(process.cwd(), "tsconfig.json"); + +// module.exports = { +// root: true, +// extends: ["custom"], +// parser: "@typescript-eslint/parser", +// settings: { +// "import/resolver": { +// typescript: { +// project, +// }, +// node: { +// moduleDirectory: ["node_modules", "."], +// }, +// }, +// }, +// parserOptions: { +// ecmaVersion: 2020, +// sourceType: "module", +// project: project, +// }, +// rules: { +// "import/order": [ +// "error", +// { +// groups: ["builtin", "external", "internal", "parent", "sibling"], +// pathGroupsExcludedImportTypes: ["builtin", "internal", "react"], +// alphabetize: { +// order: "asc", +// caseInsensitive: true, +// }, +// }, +// ], +// }, +// }; diff --git a/packages/silo/linear/package.json b/packages/silo/linear/package.json new file mode 100644 index 0000000000..3b9a3be4f1 --- /dev/null +++ b/packages/silo/linear/package.json @@ -0,0 +1,24 @@ +{ + "name": "@silo/linear", + "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": { + "@linear/sdk": "^30.0.0", + "@plane/sdk": "*" + }, + "devDependencies": { + "@plane/eslint-config": "*", + "@plane/typescript-config": "*", + "tsc-alias": "^1.8.10" + } +} diff --git a/packages/silo/linear/src/etl/index.ts b/packages/silo/linear/src/etl/index.ts new file mode 100644 index 0000000000..ffd29d104e --- /dev/null +++ b/packages/silo/linear/src/etl/index.ts @@ -0,0 +1,2 @@ +export * from "./pull"; +export * from "./transform"; diff --git a/packages/silo/linear/src/etl/pull.ts b/packages/silo/linear/src/etl/pull.ts new file mode 100644 index 0000000000..c30c32d4b0 --- /dev/null +++ b/packages/silo/linear/src/etl/pull.ts @@ -0,0 +1,110 @@ +import { LinearService } from "@/services"; +import { Issue, IssueLabel, User } from "@linear/sdk"; +import { LinearComment, LinearCycle, LinearIssueAttachment } from "@/types"; + +export async function pullUsers( + client: LinearService, + teamId: string, +): Promise { + const members = await client.getTeamMembers(teamId); + return members.nodes; +} + +export async function pullLabels(client: LinearService): Promise { + const labels = await client.getIssueLabels(); + return labels.nodes; +} + +export async function pullIssues( + client: LinearService, + teamId: string, +): Promise { + const issues: Issue[] = []; + let cursor: string | undefined; + + do { + const response = await client.getTeamIssues(teamId, cursor); + issues.push(...response.nodes); + cursor = response.pageInfo.endCursor; + } while (cursor); + + return issues; +} + +export async function pullAttachments( + issues: Issue[], + client: LinearService, +): Promise { + const issueIds = issues.map((issue) => issue.id); + const attachments = await client.getIssuesAttachments(issueIds, client); + return attachments; +} + +export async function pullComments( + issues: Issue[], + client: LinearService, +): Promise { + const issueIds = issues.map((issue) => issue.id); + const comments = await client.getIssuesComments(issueIds); + return comments; +} + +export async function pullCycles( + client: LinearService, + teamId: string, +): Promise { + const cycles: LinearCycle[] = []; + try { + const teamCycles = await client.getTeamCycles(teamId); + for (const cycle of teamCycles.nodes) { + const cycleIssues = await client.linearClient.issues({ + filter: { + cycle: { id: { eq: cycle.id } }, + team: { id: { eq: teamId } }, + }, + }); + cycles.push({ cycle, issues: cycleIssues.nodes }); + } + } catch (e) { + throw Error(`Could not fetch cycles, something went wrong`); + } + return cycles; +} + +// export const pullCommentsForIssue = async ( +// issue: Issue, +// client: LinearService, +// ): Promise => { +// const comments: LinearComment[] = []; +// let cursor: string | undefined; +// +// do { +// const response = await client.getIssuesComments(issues); +// const linearComment = response.nodes.map((comment): LinearComment => { +// return { +// ...comment, +// issue_id: issue.id, +// } as unknown as LinearComment; +// }); +// comments.push(...linearComment); +// cursor = response.pageInfo.endCursor; +// } while (cursor); +// +// return comments; +// }; +// +// export const pullCommentsInBatches = async ( +// issues: Issue[], +// batchSize: number, +// client: LinearService, +// ): Promise => { +// const comments: LinearComment[] = []; +// for (let i = 0; i < issues.length; i += batchSize) { +// const batch = issues.slice(i, i + batchSize); +// const batchComments = await Promise.all( +// batch.map((issue) => pullCommentsForIssue(issue, client)), +// ); +// comments.push(...batchComments.flat()); +// } +// return comments; +// }; diff --git a/packages/silo/linear/src/etl/transform.ts b/packages/silo/linear/src/etl/transform.ts new file mode 100644 index 0000000000..ab26915d3c --- /dev/null +++ b/packages/silo/linear/src/etl/transform.ts @@ -0,0 +1,174 @@ +import { IStateConfig, LinearComment, LinearCycle } from "@/types"; +import { + ExIssue as PlaneIssue, + ExIssueComment, + PlaneUser, + ExCycle, + ExIssueAttachment, +} from "@plane/sdk"; +import { getTargetState, getFormattedDate } from "../helpers"; +import { Issue, Comment, User, IssueLabel } from "@linear/sdk"; + +export const transformIssue = async ( + issue: Issue, + teamUrl: string, + users: User[], + labels: IssueLabel[], + stateMap: IStateConfig[], +): Promise> => { + let state; + let resolvedLabels: string[] = []; + await issue.assignee; + + if (issue.labelIds) { + resolvedLabels = issue.labelIds.map((labelId) => { + const foundLabel = labels.find((l) => l.id === labelId); + return foundLabel?.name ?? ""; + }); + } + + const assignee = await breakAndGetAssignee(issue, users); + const parent = await breakAndGetParent(issue); + const creator = await breakAndGetCreator(issue, users); + const targetState = state && getTargetState(stateMap, state); + const links = [ + { + name: "Linked Linear Issue", + url: `${teamUrl}/issue/${issue.identifier}`, + }, + ]; + + const attachments = extractAttachmentsFromDescription( + issue.description || "", + ); + + return { + assignees: assignee ? [assignee] : [], + links, + attachments, + external_id: issue.id, + external_source: "LINEAR", + created_by: creator, + name: issue.title, + description_html: + !issue.description || issue.description == "" + ? "

" + : issue.description, + target_date: getFormattedDate(issue.dueDate?.toString()), + start_date: getFormattedDate(issue.startedAt?.toString()), + created_at: issue.createdAt, + // state: targetState?.id ?? "", + // external_source_state_id: targetState?.external_id ?? "", + priority: issue.priority == 0 ? "none" : issue.priorityLabel.toLowerCase(), + labels: resolvedLabels, + parent: parent, + } as unknown as PlaneIssue; +}; + +export const extractAttachmentsFromDescription = ( + description: string, +): Partial[] => { + const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; + const images: Partial[] = []; + let match; + + while ((match = imageRegex.exec(description)) !== null) { + const [, title, url] = match; + // Get the last part of the url + const id = url.split("/").pop(); + const attachment: Partial = { + external_id: id ?? "", + external_source: "LINEAR", + attributes: { + name: title, + size: 0, + }, + asset: url ?? "", + }; + + images.push(attachment); + } + + return images; +}; + +export const transformComment = ( + comment: LinearComment, + users: User[], +): Partial => { + const creator = users.find((u) => u.id === comment.user_id); + + return { + external_id: comment.id, + external_source: "LINEAR", + created_at: getFormattedDate(comment.createdAt.toString()), + created_by: creator?.displayName, + comment_html: comment.body ?? "

", + actor: creator?.displayName, + issue: comment.issue_id, + }; +}; + +export const transformUser = (user: User): Partial => { + const [first_name, ...lastNameParts] = user.name.split(" "); + const last_name = lastNameParts.join(" "); + + let role = user.admin ? 20 : 15; + + return { + email: user.email, + display_name: user.displayName, + first_name, + last_name, + role, + }; +}; + +export const transformCycle = async ( + cycle: LinearCycle, +): Promise> => { + return { + external_id: cycle.cycle.id, + external_source: "LINEAR", + name: cycle.cycle.name ?? `Cycle ${cycle.cycle.number}`, + start_date: getFormattedDate(cycle.cycle.startsAt.toString()), + end_date: getFormattedDate(cycle.cycle.endsAt.toString()), + created_at: getFormattedDate(cycle.cycle.createdAt.toString()), + issues: cycle.issues.map((issue) => issue.id), + }; +}; + +const breakAndGetAssignee = async ( + issue: Issue, + users: User[], +): Promise => { + if (issue.assignee) { + const assignee = await issue.assignee; + return assignee.displayName; + } + + // @ts-ignore + const assigneeId = issue._assignee.id; + const user = users.find((u) => u.id === assigneeId); + if (user) { + return user.displayName; + } +}; + +const breakAndGetParent = async (issue: Issue): Promise => { + // @ts-ignore + const parent = issue._parent; + if (parent) { + return parent.id; + } +}; + +const breakAndGetCreator = async ( + issue: Issue, + users: User[], +): Promise => { + // @ts-ignore + const creatorId = issue._creator.id; + const user = users.find((u) => u.id === creatorId); + return user?.displayName; +}; diff --git a/packages/silo/linear/src/helpers/date.helper.ts b/packages/silo/linear/src/helpers/date.helper.ts new file mode 100644 index 0000000000..2f7a9a4723 --- /dev/null +++ b/packages/silo/linear/src/helpers/date.helper.ts @@ -0,0 +1,14 @@ +export const getFormattedDate = ( + date: string | 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; + } +}; diff --git a/packages/silo/linear/src/helpers/etl.helper.ts b/packages/silo/linear/src/helpers/etl.helper.ts new file mode 100644 index 0000000000..e4209250da --- /dev/null +++ b/packages/silo/linear/src/helpers/etl.helper.ts @@ -0,0 +1,52 @@ +import { IPriorityConfig, IStateConfig } from "@/types"; +import { WorkflowState } from "@linear/sdk"; +import { ExIssueAttachment, ExState } from "@plane/sdk"; + +export const getTargetState = ( + stateMap: IStateConfig[], + sourceState: WorkflowState +): ExState | undefined => { + const targetState = stateMap.find((state: IStateConfig) => { + if (state.source_state.id === sourceState.id) { + state.target_state.external_source = "LINEAR"; + state.target_state.external_id = sourceState.id; + return state; + } + }); + + return targetState?.target_state; +}; + +export const getTargetAttachments = ( + attachments: string[] +): Partial => { + if (!attachments) { + return []; + } + const attachmentArray = attachments + .map((attachment: string): Partial => { + return { + external_id: attachment, + external_source: "LINEAR", + attributes: { + name: "Attachment", // Linear SDK doesn't provide attachment details, so we use a placeholder + size: 0, + }, + asset: attachment, + }; + }) + .filter((attachment) => attachment !== undefined) as ExIssueAttachment[]; + + return attachmentArray; +}; + +export const getTargetPriority = ( + priorityMap: IPriorityConfig[], + sourcePriority: number +): string | undefined => { + const targetPriority = priorityMap.find( + (priority: IPriorityConfig) => + priority.source_priority.priority === sourcePriority + ); + return targetPriority?.target_priority; +}; diff --git a/packages/silo/linear/src/helpers/index.ts b/packages/silo/linear/src/helpers/index.ts new file mode 100644 index 0000000000..3d20e8870d --- /dev/null +++ b/packages/silo/linear/src/helpers/index.ts @@ -0,0 +1,3 @@ +export * from "./date.helper"; +export * from "./string.helper"; +export * from "./etl.helper"; diff --git a/packages/silo/linear/src/helpers/string.helper.ts b/packages/silo/linear/src/helpers/string.helper.ts new file mode 100644 index 0000000000..5d8dc35dd6 --- /dev/null +++ b/packages/silo/linear/src/helpers/string.helper.ts @@ -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); +}; diff --git a/packages/silo/linear/src/index.ts b/packages/silo/linear/src/index.ts new file mode 100644 index 0000000000..b19f5a601d --- /dev/null +++ b/packages/silo/linear/src/index.ts @@ -0,0 +1,3 @@ +export * from "./services"; +export * from "./types"; +export * from "./etl"; diff --git a/packages/silo/linear/src/services/api.service.ts b/packages/silo/linear/src/services/api.service.ts new file mode 100644 index 0000000000..768f282d13 --- /dev/null +++ b/packages/silo/linear/src/services/api.service.ts @@ -0,0 +1,178 @@ +import { Comment, LinearClient, Team, WorkflowState } from "@linear/sdk"; +import { LinearComment, LinearIssueAttachment } from ".."; + +export type LinearProps = { + accessToken: string; +}; + +export class LinearService { + linearClient: LinearClient; + + constructor(props: LinearProps) { + this.linearClient = new LinearClient({ + accessToken: props.accessToken, + }); + } + + async getCurrentUser() { + return await this.linearClient.viewer; + } + + async getNumberOfIssues(teamId: string) { + const issues = await this.linearClient.issues({ + filter: { + team: { id: { eq: teamId } }, + }, + }); + return issues.nodes.length; + } + + async getIssueLabels() { + return await this.linearClient.issueLabels(); + } + + async getTeams() { + return await this.linearClient.teams(); + } + + async getTeamsWithoutPagination() { + const teams: Team[] = []; + let nextPaginateUUID: string | undefined = undefined; + + while (true) { + const response = await this.linearClient.teams({ + after: nextPaginateUUID, + }); + if (response.nodes) { + teams.push(...response.nodes); + } + if (!response.pageInfo.hasNextPage) { + break; + } + nextPaginateUUID = response.pageInfo.endCursor; + } + + return teams; + } + + async getTeamStatuses(teamId: string) { + const team = await this.linearClient.team(teamId); + return await team.states(); + } + + async getTeamStatusesWithoutPagination(teamId: string) { + const team = await this.linearClient.team(teamId); + const teamStates: WorkflowState[] = []; + let nextPaginateUUID: string | undefined = undefined; + + while (true) { + const response = await team.states({ + after: nextPaginateUUID, + }); + if (response.nodes) { + teamStates.push(...response.nodes); + } + if (!response.pageInfo.hasNextPage) { + break; + } + nextPaginateUUID = response.pageInfo.endCursor; + } + + return teamStates; + } + + async getTeamProjects(teamId: string) { + const team = await this.linearClient.team(teamId); + return await team.projects(); + } + + async getTeamIssues(teamId: string, cursor?: string) { + return await this.linearClient.issues({ + first: 50, + after: cursor, + filter: { + team: { id: { eq: teamId } }, + }, + }); + } + + async getProjectIssues(projectId: string, cursor?: string) { + return await this.linearClient.issues({ + first: 50, + after: cursor, + filter: { + project: { id: { eq: projectId } }, + }, + }); + } + + async getTeamMembers(teamId: string) { + const team = await this.linearClient.team(teamId); + return await team.members(); + } + + async getIssuesAttachments( + issues: string[], + client: LinearService + ): Promise { + const attachments = await this.linearClient.attachments({ + filter: { + title: { neq: "Original issue in Jira" }, + }, + }); + + console.log(attachments); + } + + async getIssuesComments(issues: string[]): Promise { + const comments = await this.linearClient.comments({ + filter: { + issue: { id: { in: issues } }, + }, + }); + + const linearCommentPromises = comments.nodes.map( + async (comment): Promise => { + const brokenIds = this.breakAndGetIds(comment); + return { + ...comment, + issue_id: brokenIds.issue_id, + user_id: brokenIds.user_id, + } as LinearComment; + } + ); + + const linearComments = (await Promise.all( + linearCommentPromises + )) as LinearComment[]; + + return linearComments; + } + + async getTeamCycles(teamId: string) { + const team = await this.linearClient.team(teamId); + return await team.cycles(); + } + + async getIssuePriorities() { + // Linear has fixed priorities: 0 (None), 1 (Urgent), 2 (High), 3 (Medium), 4 (Low) + return [ + { id: 0, name: "None" }, + { id: 1, name: "Urgent" }, + { id: 2, name: "High" }, + { id: 3, name: "Medium" }, + { id: 4, name: "Low" }, + ]; + } + + breakAndGetIds(comment: Comment) { + return { + // @ts-ignore + issue_id: comment._issue.id, + // @ts-ignore + user_id: comment._user.id, + }; + } +} + +export default LinearService; diff --git a/packages/silo/linear/src/services/auth.service.ts b/packages/silo/linear/src/services/auth.service.ts new file mode 100644 index 0000000000..c99fddfce0 --- /dev/null +++ b/packages/silo/linear/src/services/auth.service.ts @@ -0,0 +1,55 @@ +import { LinearAuthProps, LinearAuthState } from "@/types"; +import axios from "axios"; + +export type LinearTokenResponse = { + access_token: string; + refresh_token: string; + expires_in: number; +}; + +export class LinearAuth { + props: LinearAuthProps; + + constructor(props: LinearAuthProps) { + this.props = props; + } + + getCallbackUrl(_state: LinearAuthState, hostname: string): string { + const host = hostname.endsWith("/") ? hostname.slice(0, -1) : hostname; + return host + this.props.callbackURL; + } + + getAuthorizationURL(state: LinearAuthState, hostname: string): string { + const scope = "read,write"; // Linear'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://linear.app/oauth/authorize?client_id=${this.props.clientId}&redirect_uri=${callbackURL}&response_type=code&scope=${scope}&state=${encodedState}`; + return consentURL; + } + + async getAccessToken( + code: string, + state: LinearAuthState, + hostname: string, + ): Promise<{ tokenResponse: LinearTokenResponse; state: LinearAuthState }> { + const params = new URLSearchParams(); + params.append("code", 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("grant_type", "authorization_code"); + + const { data: tokenResponse } = await axios.post( + "https://api.linear.app/oauth/token", + params.toString(), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + return { tokenResponse, state }; + } +} diff --git a/packages/silo/linear/src/services/builder.ts b/packages/silo/linear/src/services/builder.ts new file mode 100644 index 0000000000..3e0354cc07 --- /dev/null +++ b/packages/silo/linear/src/services/builder.ts @@ -0,0 +1,8 @@ +import { LinearAuthProps } from "@/types"; +import { LinearAuth } from "./auth.service"; +import LinearService, { LinearProps } from "./api.service"; + +export const createLinearAuthService = (props: LinearAuthProps): LinearAuth => + new LinearAuth(props); +export const createLinearService = (props: LinearProps): LinearService => + new LinearService(props); diff --git a/packages/silo/linear/src/services/index.ts b/packages/silo/linear/src/services/index.ts new file mode 100644 index 0000000000..8a2cd5ef3a --- /dev/null +++ b/packages/silo/linear/src/services/index.ts @@ -0,0 +1,3 @@ +export * from "./builder"; +export * from "./api.service"; +export * from "./auth.service"; diff --git a/packages/silo/linear/src/types/index.ts b/packages/silo/linear/src/types/index.ts new file mode 100644 index 0000000000..9caf067e27 --- /dev/null +++ b/packages/silo/linear/src/types/index.ts @@ -0,0 +1,67 @@ +import { + Attachment, + Comment, + Cycle, + Issue, + IssueLabel, + IssuePriorityValue, + User, + Team, + WorkflowState, +} from "@linear/sdk"; +import { ExState } from "@plane/sdk"; + +export type LinearAuthState = { + workspaceId: string; + workspaceSlug: string; + apiToken: string; + userId: string; +}; + +export type LinearAuthPayload = { + state: string; + code: string; +}; + +export type LinearAuthProps = { + clientId: string; + clientSecret: string; + callbackURL: string; +}; + +export interface LinearConfig { + teamId: string; + teamUrl: string; + state: IStateConfig[]; +} + +export interface IStateConfig { + source_state: { + id: string; + name: string; + }; + target_state: ExState; +} + +export interface IPriorityConfig { + source_priority: IssuePriorityValue; + target_priority: string; +} + +export interface LinearEntity { + issues: Issue[]; + issue_comments: LinearComment[]; + users: User[]; + cycles: LinearCycle[]; + labels: IssueLabel[]; +} + +export type LinearCycle = { + cycle: Cycle; + issues: Issue[]; +}; + +export type LinearComment = Comment & { issue_id: string; user_id: string }; +export type LinearIssueAttachment = Attachment & { issue_id: string }; + +export type { Team as LinearTeam, WorkflowState as LinearState }; diff --git a/packages/silo/linear/tsconfig.json b/packages/silo/linear/tsconfig.json new file mode 100644 index 0000000000..2de8a05032 --- /dev/null +++ b/packages/silo/linear/tsconfig.json @@ -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"] +} diff --git a/packages/typescript-config/express.json b/packages/typescript-config/express.json new file mode 100644 index 0000000000..c98cc5b968 --- /dev/null +++ b/packages/typescript-config/express.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Default", + "compilerOptions": { + "composite": false, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "inlineSources": false, + "isolatedModules": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noUnusedLocals": false, + "noUnusedParameters": false, + "preserveWatchOutput": true, + "skipLibCheck": true, + "strict": true + }, + "exclude": ["node_modules"] +} diff --git a/setup.sh b/setup.sh index 2376cb0016..87f73d8009 100755 --- a/setup.sh +++ b/setup.sh @@ -10,6 +10,7 @@ cp ./apiserver/.env.example ./apiserver/.env cp ./space/.env.example ./space/.env cp ./admin/.env.example ./admin/.env cp ./live/.env.example ./live/.env +cp ./silo/.env.example ./silo/.env # Generate the SECRET_KEY that will be used by django echo -e "\nSECRET_KEY=\"$(tr -dc 'a-z0-9' < /dev/urandom | head -c50)\"" >> ./apiserver/.env diff --git a/silo/.env.example b/silo/.env.example new file mode 100644 index 0000000000..4a74968f93 --- /dev/null +++ b/silo/.env.example @@ -0,0 +1,43 @@ +# App Environment Variables +BATCH_SIZE=100 +PORT=3000 +MQ_PREFETCH_COUNT=5 +APP_BASE_URL=http://web:3000 +SILO_API_BASE_URL=http://localhost:8080 + +# Database Settings +POSTGRES_USER="plane" +POSTGRES_PASSWORD="plane" +POSTGRES_HOST="plane-db" +POSTGRES_DB="silo" +POSTGRES_PORT=5432 +DB_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + +# RabbitMQ Environment Variables +RABBITMQ_HOST="plane-mq" +RABBITMQ_PORT="5672" +RABBITMQ_USER="plane" +RABBITMQ_PASSWORD="plane" +RABBITMQ_VHOST="plane" +AMQP_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@${RABBITMQ_HOST}:${RABBITMQ_PORT} + +# Redis Environment Variables +REDIS_HOST="plane-redis" +REDIS_PORT="6379" +REDIS_URL=redis://${REDIS_HOST}:${REDIS_PORT}/ + +# Sentry Environment Variables +SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 + +# integrations +# Jira Environment Variables +JIRA_CLIENT_ID=your_jira_client_id +JIRA_CLIENT_SECRET=your_jira_client_secret + +# Linear Environment Variables +LINEAR_CLIENT_ID=your_linear_client_id +LINEAR_CLIENT_SECRET=your_linear_client_secret + +# Github Environment Variables +GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret diff --git a/silo/.prettierignore b/silo/.prettierignore new file mode 100644 index 0000000000..3e58d77f92 --- /dev/null +++ b/silo/.prettierignore @@ -0,0 +1,6 @@ +node_modules +dist +build +src/public +yarn.lock +package-lock.json \ No newline at end of file diff --git a/silo/.prettierrc.json b/silo/.prettierrc.json new file mode 100644 index 0000000000..87d988f1b2 --- /dev/null +++ b/silo/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/silo/Dockerfile.dev b/silo/Dockerfile.dev new file mode 100644 index 0000000000..d2bbd914d7 --- /dev/null +++ b/silo/Dockerfile.dev @@ -0,0 +1,15 @@ +FROM node:18-alpine +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app + +COPY . . +RUN yarn global add turbo +RUN yarn install +EXPOSE 3003 + +ENV TURBO_TELEMETRY_DISABLED 1 + +VOLUME [ "/app/node_modules", "/app/silo/node_modules"] + +CMD ["yarn","dev", "--filter=silo"] diff --git a/silo/Dockerfile.silo b/silo/Dockerfile.silo new file mode 100644 index 0000000000..2be49addf8 --- /dev/null +++ b/silo/Dockerfile.silo @@ -0,0 +1,47 @@ +FROM node:18-alpine AS base +# The web Dockerfile is copy-pasted into our main docs at /docs/handbook/deploying-with-docker. +# Make sure you update this Dockerfile, the Dockerfile in the web workspace and copy that over to Dockerfile in the docs. + +FROM base AS builder +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk update +RUN apk add --no-cache libc6-compat +# Set working directory +WORKDIR /app +RUN yarn global add turbo +COPY . . +RUN turbo prune silo --docker + +# Add lockfile and package.json's of isolated subworkspace +FROM base AS installer +RUN apk update +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# First install dependencies (as they change less often) +COPY .gitignore .gitignore +COPY --from=builder /app/out/json/ . +COPY --from=builder /app/out/yarn.lock ./yarn.lock +RUN yarn install + +# Build the project and its dependencies +COPY --from=builder /app/out/full/ . +COPY turbo.json turbo.json + +ENV TURBO_TELEMETRY_DISABLED 1 + +RUN yarn turbo build --filter=silo + +FROM base AS runner +WORKDIR /app + +COPY --from=installer /app/silo/dist ./silo +COPY --from=installer /app/silo/package.json ./silo/package.json +COPY --from=installer /app/silo/src/db/migrations ./silo/src/db/migrations +COPY --from=installer /app/silo/src/db/config ./silo/src/db/config +COPY --from=installer /app/silo/drizzle.config.js ./silo/drizzle.config.js +COPY --from=installer /app/node_modules ./node_modules + +ENV TURBO_TELEMETRY_DISABLED 1 + +EXPOSE 3000 diff --git a/silo/README.md b/silo/README.md new file mode 100644 index 0000000000..3768654c50 --- /dev/null +++ b/silo/README.md @@ -0,0 +1 @@ +# Silo diff --git a/silo/drizzle.config.js b/silo/drizzle.config.js new file mode 100644 index 0000000000..8b345cf7af --- /dev/null +++ b/silo/drizzle.config.js @@ -0,0 +1,8 @@ +export default { + schema: "./src/db/schema/*", + out: "./src/db/migrations", + dialect: "postgresql", + dbCredentials: { + url: process.env.DB_URL || "" + } +} diff --git a/silo/eslint.config.mjs b/silo/eslint.config.mjs new file mode 100644 index 0000000000..dc45160d0c --- /dev/null +++ b/silo/eslint.config.mjs @@ -0,0 +1,52 @@ +import tsParser from "@typescript-eslint/parser" +import js from "@eslint/js" +import { FlatCompat } from "@eslint/eslintrc" + +const compat = new FlatCompat({ + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all +}) + +export default [ + ...compat.extends("eslint:recommended", "prettier"), + { + languageOptions: { + parser: tsParser + }, + rules: { + "no-useless-escape": "off", + "prefer-const": "error", + "no-irregular-whitespace": "error", + "no-trailing-spaces": "error", + "no-duplicate-imports": "error", + "no-useless-catch": "warn", + "no-case-declarations": "error", + "no-undef": "error", + "no-unreachable": "error", + "arrow-body-style": ["error", "as-needed"], + "@typescript-eslint/no-unused-vars": ["error"], + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-useless-empty-export": "error", + "@typescript-eslint/prefer-ts-expect-error": "error", + "@typescript-eslint/naming-convention": [ + "error", + { + selector: ["function", "variable"], + format: ["camelCase", "snake_case", "UPPER_CASE", "PascalCase"], + leadingUnderscore: "allow" + } + ], + "import/order": [ + "error", + { + groups: ["builtin", "external", "internal", "parent", "sibling"], + pathGroupsExcludedImportTypes: ["builtin", "internal", "react"], + alphabetize: { + order: "asc", + caseInsensitive: true + } + } + ] + } + } +] diff --git a/silo/nodemon.json b/silo/nodemon.json new file mode 100644 index 0000000000..5df3424bb4 --- /dev/null +++ b/silo/nodemon.json @@ -0,0 +1,6 @@ +{ + "watch": ["src"], + "ext": "ts", + "ignore": ["src/public"], + "exec": "NODE_ENV=development ts-node -r tsconfig-paths/register src/start.ts" +} diff --git a/silo/package.json b/silo/package.json new file mode 100644 index 0000000000..9416ed7799 --- /dev/null +++ b/silo/package.json @@ -0,0 +1,64 @@ +{ + "name": "silo", + "version": "0.23.0", + "description": "A simple, lightweight, and fast integrations engine.", + "private": true, + "author": "engineering@plane.so", + "scripts": { + "dev": "turbo run develop", + "develop": "nodemon --config \"./nodemon.json\"/", + "build": "rm -rf ./dist/ && tsup src/start.ts --dts --minify --format cjs --out-dir dist", + "start": "node dist/start.js -p 8080", + "format": "prettier --config .prettierrc.json --write src/**/*.ts", + "lint": "eslint .", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate" + }, + "dependencies": { + "@sentry/node": "^8.27.0", + "@sentry/profiling-node": "^8.27.0", + "amqplib": "^0.10.4", + "axios": "^1.7.7", + "cors": "^2.8.5", + "csv-string": "^4.1.1", + "drizzle-orm": "^0.33.0", + "express": "^4.19.2", + "multer": "^1.4.5-lts.1", + "node-html-parser": "^6.1.13", + "postgres": "^3.4.4", + "redis": "^4.7.0", + "reflect-metadata": "^0.2.2", + "source-map-support": "^0.5.21", + "winston": "^3.14.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "tsup": "^8.3.0", + "@silo/core": "*", + "@silo/github": "*", + "@silo/jira": "*", + "@silo/linear": "*", + "@t3-oss/env-core": "^0.11.1", + "adm-zip": "^0.5.16", + "@linear/sdk": "^30.0.0", + "@plane/sdk": "*", + "@eslint/js": "^9.9.1", + "@plane/typescript-config": "*", + "@types/adm-zip": "^0.5.5", + "@types/amqplib": "^0.10.5", + "@types/cors": "^2.8.17", + "@types/eslint__js": "^8.42.3", + "@types/multer": "^1.4.12", + "@types/redis": "^4.0.11", + "drizzle-kit": "^0.22.4", + "eslint": "^9.9.1", + "nodemon": "^3.1.4", + "prettier": "^3.3.3", + "ts-to-zod": "^3.13.0", + "tsc-alias": "^1.8.10", + "typescript": "^5.3.3", + "typescript-eslint": "^8.4.0" + } +} diff --git a/silo/src/apps/engine/controllers/cred.controller.ts b/silo/src/apps/engine/controllers/cred.controller.ts new file mode 100644 index 0000000000..3db5c2b2ba --- /dev/null +++ b/silo/src/apps/engine/controllers/cred.controller.ts @@ -0,0 +1,50 @@ +import { Controller, Get, Post } from "@/lib"; +import { createOrUpdateCredentials, getCredentialsByWorkspaceId } from "@/db/query"; +import { Request, Response } from "express"; + +@Controller("/credentials") +export class CredentialController { + @Post("/:workspaceId/:userId") + async upsertCredentials(req: Request, res: Response) { + try { + const workspaceId = req.params.workspaceId; + const userId = req.params.userId; + + if (!workspaceId || !userId) { + return res.status(400).json({ error: "Either workspaceId or userId is not provided" }); + } + + const credential = await createOrUpdateCredentials(workspaceId, userId, { + workspace_id: req.params.workspaceId, + ...req.body, + }); + res.status(200).json(credential); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + } + + @Get("/:workspaceId/:userId/") + async getCredentials(req: Request, res: Response) { + try { + // Get the workspaceId from the request params + const workspaceId = req.params.workspaceId; + const userId = req.params.userId; + + if (!workspaceId || !userId) { + return res.status(400).json({ error: "Either workspaceId or userId is not provided" }); + } + + const source = req.query.source as string; + // Fetch all the credentials + const credentials = await getCredentialsByWorkspaceId(workspaceId, userId, source); + + if (!credentials || credentials.length === 0) { + return res.status(401).json({ isAuthenticated: false }); + } + return res.status(200).json({ isAuthenticated: true }); + } catch (error: any) { + return res.status(500).json({ error: error.message }); + } + } +} diff --git a/silo/src/apps/engine/controllers/index.ts b/silo/src/apps/engine/controllers/index.ts new file mode 100644 index 0000000000..6b17868e15 --- /dev/null +++ b/silo/src/apps/engine/controllers/index.ts @@ -0,0 +1,2 @@ +export * from "./job.controller" +export * from "./cred.controller" diff --git a/silo/src/apps/engine/controllers/job.controller.ts b/silo/src/apps/engine/controllers/job.controller.ts new file mode 100644 index 0000000000..8eb254d603 --- /dev/null +++ b/silo/src/apps/engine/controllers/job.controller.ts @@ -0,0 +1,195 @@ +/* + * A job is a fundamental unit of work in the server, a job represent a task + * that needs to be done from the importers or integrations present. A job and + * the job configuration is stored in the database and when the job is run, the + * same is injected inside the worker, which makes use of the job configuration + * and performs the task + */ + +import { Controller, Delete, Get, Post, Put } from "@/lib"; +import { + createJob, + createJobConfig, + deleteJob, + getCredentialsByTargetToken, + getJobById, + getJobByWorkspaceId, + getJobByWorkspaceIdAndSource, + updateJob, +} from "@/db/query"; +import { Request, Response } from "express"; +import taskManager from "@/apps/engine/worker"; +import { TSyncServices } from "@silo/core"; + +@Controller("/jobs") +export class JobController { + @Post("/") + async createJob(req: Request, res: Response) { + try { + if (!req.body.workspace_id) { + res.status(400).json({ message: "Workspace ID is required" }); + return; + } + const job = await createJob({ + ...req.body, + }); + res.status(201).json(job); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + } + + @Get("/") + async getJobs(req: Request, res: Response) { + try { + // check for the params, if empty then return error + if (!req.query) { + res.status(400).json({ message: "Invalid query" }); + return; + } + // Check for the query params and get the jobs according to the functions + // associated with the query params + if (req.query.id) { + const job = await getJobById(req.query.id as string); + res.status(200).json(job); + return; + } else { + // Get the api token from the headers and check if the token is valid + const token = req.headers["x-api-key"]; + if (!token) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + // Get the credentials for the token + const credentials = await getCredentialsByTargetToken((token as string).trim()); + + if (credentials.length == 0) { + res.status(400).json({ message: "No migration jobs available for this token" }); + return; + } + + const targetCredentials = credentials[0]; + if (targetCredentials.workspace_id == null) { + res.status(200).json([]); + return; + } + // Find the jobs based on the workspace ID of the credentials + let jobs = {}; + if (req.query.source) { + jobs = await getJobByWorkspaceIdAndSource(targetCredentials.workspace_id, req.query.source as TSyncServices); + } else { + jobs = await getJobByWorkspaceId(targetCredentials.workspace_id); + } + res.status(200).json(jobs); + } + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + } + + @Put("/:id") + async updateJob(req: Request, res: Response) { + try { + if (req.body.start_time) { + req.body.start_time = new Date(req.body.start_time); + } + + if (req.body.end_time) { + req.body.end_time = new Date(req.body.end_time); + } + + const job = await updateJob(req.params.id, req.body); + + if (job) { + res.status(200).json(job); + } else { + res.status(404).json({ message: "Job not found" }); + } + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + } + + @Delete("/:id") + async deleteJob(req: Request, res: Response) { + try { + const job = await deleteJob(req.params.id); + if (job) { + res.status(200).json({ message: "Job deleted successfully" }); + } else { + res.status(404).json({ message: "Job not found" }); + } + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + } + + @Post("/run") + async runJob(req: Request, res: Response) { + try { + const body = req.body; + if ( + !body || + !body.jobId || + body.jobId == "" || + body.jobId == null || + body.migrationType == "" || + body.migrationType == null + ) { + res.status(400).json({ + message: "Invalid request, expecting (jobId) & (migrationType) to be passed", + }); + return; + } + + // Get the job from the given job id + const jobs = await getJobById(body.jobId); + if (jobs.length == 0) { + res.status(404).json({ + message: `No job with id ${body.jobId} is available to run, please create one.`, + }); + return; + } + + const job = jobs[0]; + // If the job is not finished or error, just send 400 OK, and don't do + // anything + if (job.status && job.status != "FINISHED" && job.status != "ERROR") { + res.status(400).json({ message: "Job already in progress, can't instantiate again" }); + return; + } + // Check if the config is already present, for the particular job or not + if (!job.config || job.migration_type == null) { + res.status(400).json({ + message: "Config for the requested job is not found, make sure to create a config before initiating a job", + }); + return; + } + + await taskManager.registerTask( + { + route: job.migration_type.toLowerCase(), + jobId: job.id, + type: "initiate", + }, + {} + ); + res.status(200).json({ message: "Job initiated successfully" }); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + } +} + +@Controller("/job-configs") +export class JobConfigController { + @Post("/") + async createJobConfig(req: Request, res: Response) { + try { + const jobConfig = await createJobConfig(req.body); + res.status(201).json(jobConfig); + } catch (error: any) { + res.status(500).json({ error: error.message }); + } + } +} diff --git a/silo/src/apps/engine/worker/README.md b/silo/src/apps/engine/worker/README.md new file mode 100644 index 0000000000..21e918e1c4 --- /dev/null +++ b/silo/src/apps/engine/worker/README.md @@ -0,0 +1,5 @@ +## Worker +Worker is responsible for maintaining the core, queue based import actions. The +logical parts where the app related work is done is stored inside worker's root +directory while the base part that will be used under the hood is stored inside +the base directory. diff --git a/silo/src/apps/engine/worker/base/consumer.ts b/silo/src/apps/engine/worker/base/consumer.ts new file mode 100644 index 0000000000..e6b9dfe275 --- /dev/null +++ b/silo/src/apps/engine/worker/base/consumer.ts @@ -0,0 +1,21 @@ +import { MQActorBase } from "./mq" + +export class MQConsumer extends MQActorBase { + startConsuming(callback: (data: any) => void) { + return this.channel.consume( + this.queueName, + (msg: any) => { + if (msg && msg.content) { + callback(msg) + } + }, + { + noAck: false + } + ) + } + + async cancelConsumer(consumer: { consumerTag: string }) { + await this.channel.cancel(consumer.consumerTag) + } +} diff --git a/silo/src/apps/engine/worker/base/index.ts b/silo/src/apps/engine/worker/base/index.ts new file mode 100644 index 0000000000..b351182ceb --- /dev/null +++ b/silo/src/apps/engine/worker/base/index.ts @@ -0,0 +1,3 @@ +export * from "./mq" +export * from "./queue" +export * from "./store" diff --git a/silo/src/apps/engine/worker/base/mq.ts b/silo/src/apps/engine/worker/base/mq.ts new file mode 100644 index 0000000000..c1c1b4925b --- /dev/null +++ b/silo/src/apps/engine/worker/base/mq.ts @@ -0,0 +1,69 @@ +import amqp from "amqplib"; +import { TMQEntityOptions } from "./types"; +import { env } from "@/env"; +import { logger } from "@/logger"; + +export class MQActorBase { + private connection!: amqp.Connection; + exchange: string; + queueName: string; + routingKey: string; + channel!: amqp.Channel; + + constructor(options: TMQEntityOptions) { + this.exchange = "migration_exchange"; + if (options.appType === "extension") { + this.queueName = options.queueName; + this.routingKey = options.routingKey; + } else { + this.queueName = "silo-api"; + this.routingKey = "silo-api"; + } + } + + async connect() { + try { + // create connection + const amqpUrl = env.AMQP_URL || "amqp://localhost"; + logger.info("Connecting to RabbitMq 🐇: ", amqpUrl); + + this.connection = await amqp.connect(amqpUrl, {}); + this.channel = await this.connection.createConfirmChannel(); + + // Declare the Dead Letter Exchange and Queue + const dlxExchange = "dlx_exchange"; + const dlxQueue = "dlx_queue"; + + await this.channel.assertExchange(dlxExchange, "direct", { + durable: true, + }); + await this.channel.assertQueue(dlxQueue, { durable: true }); + await this.channel.bindQueue(dlxQueue, dlxExchange, "dlx_routing_key"); + + await this.channel.assertExchange(this.exchange, "direct", { + durable: true, + }); + + await this.channel.assertQueue(this.queueName, { + durable: true, + arguments: { + "x-dead-letter-exchange": dlxExchange, + "x-dead-letter-routing-key": "dlx_routing_key", + }, + }); + + await this.channel.bindQueue(this.queueName, this.exchange, this.routingKey); + } catch (error) { + throw new Error("Error while connecting to RabbitMq: " + error); + } + } + + async close() { + try { + await this.channel.close(); + await this.connection.close(); + } catch (error) { + console.log("Error while closing RabbitMq connection", error); + } + } +} diff --git a/silo/src/apps/engine/worker/base/producer.ts b/silo/src/apps/engine/worker/base/producer.ts new file mode 100644 index 0000000000..7058ce06f2 --- /dev/null +++ b/silo/src/apps/engine/worker/base/producer.ts @@ -0,0 +1,16 @@ +import { MQActorBase } from "./mq" + +export class MQProducer extends MQActorBase { + async sendMessage(data: any, headers: any, routingKey?: string) { + routingKey = routingKey || this.routingKey + this.channel.publish(this.exchange, routingKey, Buffer.from(JSON.stringify(data)), { + headers, + persistent: false, + mandatory: false + }) + } + + async cancelConsumer(consumer: { consumerTag: string }) { + await this.channel.cancel(consumer.consumerTag) + } +} diff --git a/silo/src/apps/engine/worker/base/queue.ts b/silo/src/apps/engine/worker/base/queue.ts new file mode 100644 index 0000000000..0835da5c4d --- /dev/null +++ b/silo/src/apps/engine/worker/base/queue.ts @@ -0,0 +1,51 @@ +import { env } from "@/env"; +import { MQConsumer } from "./consumer"; +import { MQProducer } from "./producer"; +import { TMQEntityOptions } from "./types"; + +// An encapsulation of RabbitMQ producer and consumer +export class MQ { + private producer: MQProducer; + private consumer: MQConsumer; + + constructor(options: TMQEntityOptions) { + this.producer = new MQProducer(options); + this.consumer = new MQConsumer(options); + } + + async connect() { + try { + await this.producer.connect(); + await this.consumer.connect(); + } catch (error) { + throw new Error("Error while connecting to RabbitMq: " + error); + } + } + + async sendMessage(data: any, headers: any, routingKey?: string) { + await this.producer.sendMessage(data, headers, routingKey); + } + + async startConsuming(callback: (data: any) => void) { + const prefetchCount = Number(env.MQ_PREFETCH_COUNT) ?? 5; + this.consumer.channel.prefetch(prefetchCount); + await this.consumer.startConsuming(callback); + } + + async close() { + try { + await this.consumer.close(); + await this.producer.close(); + } catch (error) { + console.log("Error while closing RabbitMq connection", error); + } + } + + async ackMessage(msg: any) { + this.consumer.channel.ack(msg); + } + + async nackMessage(msg: any) { + this.consumer.channel.nack(msg); + } +} diff --git a/silo/src/apps/engine/worker/base/store.ts b/silo/src/apps/engine/worker/base/store.ts new file mode 100644 index 0000000000..8a4a54b6d7 --- /dev/null +++ b/silo/src/apps/engine/worker/base/store.ts @@ -0,0 +1,44 @@ +import { env } from "@/env"; +import { createClient, RedisClientType, RedisDefaultModules, RedisFunctions, RedisModules, RedisScripts } from "redis"; + +export class Store { + private client!: RedisClientType; + + private jobs: string[] = []; + + constructor() {} + + // Error has to be handled by the caller + public async connect() { + this.client = createClient({ + url: env.REDIS_URL, + }); + await this.client.connect(); + } + + public async get(key: string): Promise { + return await this.client.get(key); + } + + public async set(key: string, value: string): Promise { + this.jobs.push(key); + return await this.client.set(key, value, { + NX: true, + PX: 1000 * 60 * 10, + }); + } + + public async del(key: string): Promise { + const index = this.jobs.indexOf(key); + if (index > -1) { + this.jobs.splice(index, 1); + } + return await this.client.del(key); + } + + public async clean() { + for (const key of this.jobs) { + await this.client.del(key); + } + } +} diff --git a/silo/src/apps/engine/worker/base/types.ts b/silo/src/apps/engine/worker/base/types.ts new file mode 100644 index 0000000000..7763f4691d --- /dev/null +++ b/silo/src/apps/engine/worker/base/types.ts @@ -0,0 +1,12 @@ +export type TAppType = "extension" | "api"; + +export type TMQEntityOptions = + | { + appType: "extension"; + queueName: string; + routingKey: string; + } + | { + appType: "api"; + }; + diff --git a/silo/src/apps/engine/worker/index.ts b/silo/src/apps/engine/worker/index.ts new file mode 100644 index 0000000000..4380dae68a --- /dev/null +++ b/silo/src/apps/engine/worker/index.ts @@ -0,0 +1,12 @@ +import { TaskManager } from "./manager" + +const taskManager = new TaskManager({ + workerTypes: { + jira: "jira", + linear: "linear" + }, + retryAttempts: 3, + retryDelay: 1000 +}) + +export default taskManager diff --git a/silo/src/apps/engine/worker/manager.ts b/silo/src/apps/engine/worker/manager.ts new file mode 100644 index 0000000000..e6d7dda040 --- /dev/null +++ b/silo/src/apps/engine/worker/manager.ts @@ -0,0 +1,129 @@ +import { logger } from "@/logger"; +import { MQ } from "./base"; +import { Store } from "./base"; +import { TaskHandler, TaskHeaders } from "@/types"; +import { JiraDataMigrator } from "@/apps/jira-importer/migrator/jira.migrator"; +import { LinearDataMigrator } from "@/apps/linear-importer/migrator/linear.migrator"; +import { TMQEntityOptions } from "./base/types"; + +class WorkerFactory { + static createWorker(type: string, mq: MQ, store: Store): TaskHandler { + switch (type) { + case "jira": + return new JiraDataMigrator(mq, store); + case "linear": + return new LinearDataMigrator(mq, store); + default: + throw new Error(`Unsupported worker type: ${type}`); + } + } +} + +interface JobWorkerConfig { + workerTypes: { [key: string]: string }; + retryAttempts: number; + retryDelay: number; +} + +export class TaskManager { + private mq: MQ | undefined; + private store: Store | undefined; + private config: JobWorkerConfig; + private workers: Map = new Map(); + + constructor(config: JobWorkerConfig) { + this.config = config; + + process.on("SIGINT", this.cleanup.bind(this)); + process.on("SIGTERM", this.cleanup.bind(this)); + process.on("exit", this.cleanup.bind(this)); + } + + private initQueue = async (options: TMQEntityOptions) => { + try { + this.mq = new MQ(options); + await this.mq.connect(); + } catch (error) { + throw error; + } + }; + + private initStore = async () => { + try { + this.store = new Store(); + await this.store.connect(); + } catch (error) { + throw error; + } + }; + + private cleanup = async () => { + if (this.store) { + await this.store.clean(); + } + }; + + private startConsumer = async () => { + if (!this.mq) return; + try { + await this.mq.startConsuming(async (msg: any) => { + try { + const data = JSON.parse(msg.content.toString()); + const headers = msg.properties.headers; + await this.handleTask(headers.headers, data); + await this.mq?.ackMessage(msg); + } catch (error) { + logger.error("Error processing message:", error); + await this.handleError(msg, error); + } + }); + } catch (error) { + logger.error("Error starting job worker consumer:", error); + } + }; + + private async handleTask(headers: TaskHeaders, data: any) { + const worker = this.workers.get(headers.route); + if (!worker) { + throw new Error(`No worker found for route: ${headers.route}`); + } + await worker.handleTask(headers, data); + } + + private async handleError(msg: any, error: any) { + if (!this.mq) return; + const retryCount = (msg.properties.headers.retry_count || 0) + 1; + if (retryCount <= this.config.retryAttempts) { + await new Promise((resolve) => setTimeout(resolve, this.config.retryDelay)); + await this.mq.nackMessage(msg); + msg.properties.headers.retry_count = retryCount; + } else { + logger.error(`Max retry attempts reached for message: ${msg.content.toString()}`); + await this.mq.ackMessage(msg); + } + } + + public start = async (options: TMQEntityOptions) => { + logger.info("Warming up worker instance, connecting services... ♨️"); + try { + await this.initQueue(options); + await this.initStore(); + await this.startConsumer(); + + for (const [jobType, workerType] of Object.entries(this.config.workerTypes)) { + this.workers.set(jobType, WorkerFactory.createWorker(workerType, this.mq!, this.store!)); + } + } catch (error) { + logger.error(`Something went wrong while initiating job worker 🧨, ${error}`); + } + }; + + public registerTask = async (headers: TaskHeaders, data: any) => { + if (!this.mq) return; + try { + await this.mq.sendMessage(data, { headers }); + } catch (error) { + logger.error("Error pushing to job worker queue:", error); + } + }; +} diff --git a/silo/src/apps/engine/worker/types.ts b/silo/src/apps/engine/worker/types.ts new file mode 100644 index 0000000000..05524015ad --- /dev/null +++ b/silo/src/apps/engine/worker/types.ts @@ -0,0 +1,59 @@ +import { TSyncJobWithConfig } from "@silo/core"; + +export type WorkerEventType = "initiate" | "transform" | "push" | "finished"; +export type UpdateEventType = + | "INITIATED" + | "PULLING" + | "PULLED" + | "TRANSFORMING" + | "TRANSFORMED" + | "PUSHING" + | "FINISHED" + | "ERROR"; + +export type TBatch = { + id: number; + jobId: string; + meta: { + batchId: number; + batch_start: number; + batch_size: number; + batch_end: number; + total: { + [k in keyof TSource]: number; + }; + }; + data: TSource[]; +}; + +// Interface for the transformer, used by the worker to pull, transform and push the data +export interface Migrator { + // Pull the data from the source, taking the configuration of the job + pull: (job: TSyncJobWithConfig) => Promise; + // Batches can be understood as chunks of data, which can be processed in + // parallel, while you keep in mind the performance of the system, it's also + // necessary to keep in mind the relation between the data. For such cases, + // keep in mind that there are two main types of relations, associations and + // entities. Entities represent independent data, which needs to be present for + // continuations of each batch and has to be the same in all the batches like + // users, states, labels etc, while associations are relations that are tied + // like, issue_comments are tied with issues, and issues are tied with cycles + // and modules etc. + batches: (job: TSyncJobWithConfig) => Promise[]>; + // Transform the data from the source to the target + transform: (job: TSyncJobWithConfig, data: TSource[], meta: any) => Promise; + // Push the data to the target system + // Should be a NOOP in case of integrations, and should only be used inside + // the API, to get the data from the queue and push it to the target system + push?: (jobId: string, data: TTarget[], meta: any) => Promise; + // Update the job with the stage and the data + update?: (jobId: string, stage: UpdateEventType, data: any) => Promise; +} + +export interface MigrationController { + pull: (jobId: string) => Promise; + batches: (jobId: string) => Promise[]>; + transform: (jobId: string, data: any[], meta: any) => Promise; + push: (jobId: string, data: any[], meta: any) => Promise; + update: (jobId: string, stage: UpdateEventType, data: any) => Promise; +} diff --git a/silo/src/apps/jira-importer/README.md b/silo/src/apps/jira-importer/README.md new file mode 100644 index 0000000000..af9ba67269 --- /dev/null +++ b/silo/src/apps/jira-importer/README.md @@ -0,0 +1,6 @@ +## Jira Importer +With Jira Importer as a folder, we are bringing a directory level isolation +boundary of code, such that anything that belong inside Jira will reside +inside this particular folder. While we do require to maintain the isolation +between the code, it doesn't forbid us to import any asset from jira inside the +runner or worker. diff --git a/silo/src/apps/jira-importer/auth/auth.ts b/silo/src/apps/jira-importer/auth/auth.ts new file mode 100644 index 0000000000..9772dd664b --- /dev/null +++ b/silo/src/apps/jira-importer/auth/auth.ts @@ -0,0 +1,10 @@ +import { env } from "@/env"; +import { createJiraAuth } from "@silo/jira"; + +export const jiraAuth = createJiraAuth( + env.JIRA_CLIENT_ID, + env.JIRA_CLIENT_SECRET, + "/silo/api/jira/auth/callback", + "https://auth.atlassian.com/authorize", + "https://auth.atlassian.com/oauth/token" +); diff --git a/silo/src/apps/jira-importer/controllers/index.ts b/silo/src/apps/jira-importer/controllers/index.ts new file mode 100644 index 0000000000..421d9a3ec9 --- /dev/null +++ b/silo/src/apps/jira-importer/controllers/index.ts @@ -0,0 +1,334 @@ +import { env } from "@/env"; +import { Controller, Get, Post } from "@/lib"; +import { Request, Response } from "express"; +import { jiraAuth } from "../auth/auth"; +import { + createJiraService, + fetchPaginatedData, + JiraAuthPayload, + JiraAuthState, + JiraProject, + JiraResource, + JiraService, +} from "@silo/jira"; +import { JiraTokenResponse } from "@silo/jira"; +import { createOrUpdateCredentials, getCredentialsByWorkspaceId } from "@/db/query"; +import axios, { AxiosInstance } from "axios"; +import { Credentials } from "@/types"; + +class JiraApiError extends Error { + constructor( + message: string, + public statusCode: number + ) { + super(message); + this.name = "JiraApiError"; + } +} + +@Controller("/jira") +class JiraController { + @Get("/ping") + async ping(_req: Request, res: Response) { + res.send("pong"); + } + + @Post("/auth/url") + async getAuthURL(req: Request, res: Response) { + const body: JiraAuthState = req.body; + if (!body.workspaceId || !body.apiToken) { + return res.status(400).send({ + message: "Bad Request, expected both apiToken and workspaceId to be present.", + }); + } + const baseUrl = env.SILO_API_BASE_URL; + const response = jiraAuth.getAuthorizationURL(body, baseUrl); + res.send(response); + } + + @Get("/auth/callback") + async authCallback(req: Request, res: Response) { + const query: JiraAuthPayload | any = req.query; + if (!query.code) { + return res.status(400).send("code not found in the query params"); + } + const stringifiedJsonState = query.state as string; + // Decode the base64 encoded state string and parse it to JSON + const state: JiraAuthState = JSON.parse(Buffer.from(stringifiedJsonState, "base64").toString()); + let tokenResponse: JiraTokenResponse; + try { + const baseUrl = env.SILO_API_BASE_URL; + const tokenInfo = await jiraAuth.getAccessToken(query.code as string, state, baseUrl); + tokenResponse = tokenInfo.tokenResponse; + } catch (error: any) { + console.log("Error occured while fetching token details", error.response.data); + res.status(400).send(error.response.data); + return; + } + + if (!tokenResponse) { + res.status(400).send("failed to fetch token details"); + return; + } + + // Create a new credentials record in the database for the recieved token + await createOrUpdateCredentials(state.workspaceId, state.userId, { + source_access_token: tokenResponse.access_token, + source_refresh_token: tokenResponse.refresh_token, + target_access_token: state.apiToken, + source: "JIRA", + workspace_id: state.workspaceId, + }); + + try { + // As we are using base path as /jira, we can redirect to /jira + res.redirect(`${env.APP_BASE_URL}/${state.workspaceSlug}/settings/imports/jira/`); + } catch (error: any) { + res.status(500).send(error.response.data); + } + } + + @Post("/auth/refresh") + async refreshAccessToken(req: Request, res: Response) { + const { refreshToken, workspaceId, userId } = req.body; + if (!refreshToken || !workspaceId) { + return res.status(400).send({ message: "Bad Request" }); + } + try { + const { access_token, refresh_token, expires_in } = await jiraAuth.getRefreshToken(refreshToken); + + // Update the credentials record in the database with the new token + await createOrUpdateCredentials(workspaceId, userId, { + source_access_token: access_token, + source_refresh_token: refresh_token, + source: "JIRA", + workspace_id: workspaceId, + }); + + res + .cookie("accessToken", access_token) + .cookie("refreshToken", refresh_token) + .send({ access_token, refresh_token, expires_in }); + } catch (error: any) { + res.status(error.response.status).send(error.response.data); + } + } + + @Post("/resources") + async getResources(req: Request, res: Response) { + try { + const { workspaceId, userId } = req.body; + const credentials = await validateAndGetCredentials(workspaceId, userId); + const axiosInstance = createAxiosInstance(); + + if (!credentials.source_access_token) { + return res.status(401).send({ message: "No access token found" }); + } + const resources = await fetchJiraResources(axiosInstance, credentials); + return res.json(resources); + } catch (error) { + handleError(error, res); + } + } + + @Post("/projects") + async getProjects(req: Request, res: Response) { + const { workspaceId, userId, cloudId } = req.body; + + try { + const jiraClient = await createJiraClient(workspaceId, userId, cloudId); + const projects: JiraProject[] = []; + await fetchPaginatedData( + (startAt) => jiraClient.getResourceProjects(startAt), + (values) => projects.push(...(values as JiraProject[])), + "values" + ); + return res.json(projects); + } catch (error: any) { + return res.status(401).send({ message: error.message }); + } + } + + @Post("/states") + async getStates(req: Request, res: Response) { + const { workspaceId, userId, cloudId, projectId } = req.body; + + try { + const jiraClient = await createJiraClient(workspaceId, userId, cloudId); + const statuses = await jiraClient.getProjectStatuses(projectId); + // const statuses = await jiraClient.getResourceStatuses(); + return res.json(statuses); + } catch (error: any) { + return res.status(401).send({ message: error.message }); + } + } + + @Post("/priorities") + async getPriority(req: Request, res: Response) { + const { workspaceId, userId, cloudId } = req.body; + + try { + const jiraClient = await createJiraClient(workspaceId, userId, cloudId); + const statuses = await jiraClient.getIssuePriorities(); + return res.json(statuses); + } catch (error: any) { + return res.status(401).send({ message: error.message }); + } + } + + @Post("/labels") + async getLabels(req: Request, res: Response) { + const { workspaceId, userId, cloudId } = req.body; + + try { + const jiraClient = await createJiraClient(workspaceId, userId, cloudId); + const labels = await jiraClient.getResourceLabels(); + return res.json(labels); + } catch (error: any) { + return res.status(401).send({ message: error.message }); + } + } + + @Post("/issue-count") + async getIssueCount(req: Request, res: Response) { + const { workspaceId, userId, cloudId, projectId } = req.body; + + try { + const jiraClient = await createJiraClient(workspaceId, userId, cloudId); + const issueCount = await jiraClient.getNumberOfIssues(projectId); + return res.json(issueCount); + } catch (error: any) { + return res.status(401).send({ message: error.message }); + } + } + + @Post("/issue-types") + async getIssueTypes(req: Request, res: Response) { + const { workspaceId, userId, cloudId, projectId } = req.body; + + try { + const jiraClient = await createJiraClient(workspaceId, userId, cloudId); + const statuses = await jiraClient.getProjectIssueTypes(projectId); + return res.json(statuses); + } catch (error: any) { + return res.status(401).send({ message: error.message }); + } + } +} + +const createJiraClient = async (workspaceId: string, userId: string, cloudId: string): Promise => { + const credentials = await getCredentialsByWorkspaceId(workspaceId, userId, "JIRA"); + + if (!credentials || credentials.length === 0) { + throw new Error("No jira credentials available for the given workspaceId and userId"); + } + + const jiraCredentials = credentials[0]; + + if ( + !jiraCredentials.source_access_token || + !jiraCredentials.source_refresh_token || + !jiraCredentials.target_access_token + ) { + throw new Error("No jira credentials available for the given workspaceId and userId"); + } + + const refreshTokenCallback = async ({ + access_token, + refresh_token, + }: { + access_token: string; + refresh_token: string; + }) => { + await createOrUpdateCredentials(workspaceId, userId, { + source_access_token: access_token, + source_refresh_token: refresh_token, + target_access_token: jiraCredentials.target_access_token, + source: "JIRA", + }); + }; + + return createJiraService({ + cloudId: cloudId, + accessToken: jiraCredentials.source_access_token, + refreshToken: jiraCredentials.source_refresh_token, + refreshTokenFunc: jiraAuth.getRefreshToken, + refreshTokenCallback: refreshTokenCallback, + }); +}; + +async function validateAndGetCredentials(workspaceId: string, userId: string) { + const credentials = await getCredentialsByWorkspaceId(workspaceId, userId, "JIRA"); + if (!credentials || credentials.length === 0) { + throw new JiraApiError("No Jira credentials available for the given workspaceId and userId", 401); + } + + const credential = credentials[0]; + if (!credential.source_access_token || !credential.source_refresh_token || !credential.target_access_token) { + throw new JiraApiError("Incomplete Jira credentials for the given workspaceId and userId", 401); + } + + return credential; +} + +function createAxiosInstance(): AxiosInstance { + return axios.create({ + baseURL: "https://api.atlassian.com", + }); +} + +async function fetchJiraResources(axiosInstance: AxiosInstance, credentials: Credentials, isRetry = false) { + try { + const response = await axiosInstance.get("/oauth/token/accessible-resources", { + headers: { + Authorization: `Bearer ${credentials.source_access_token}`, + }, + }); + return response.data; + } catch (error: any) { + if (error.response?.status === 401) { + // To avoid infinite loop, we are checking if the request is a retry + if (isRetry) { + throw new JiraApiError("Invalid access token", 401); + } else { + return await refreshAndRetry(credentials.workspace_id!, credentials.user_id!, credentials, axiosInstance); + } + } + throw error; + } +} + +async function refreshAndRetry( + workspaceId: string, + userId: string, + credentials: Credentials, + axiosInstance: AxiosInstance +): Promise { + if (!credentials.source_refresh_token) { + throw new JiraApiError("No refresh token found", 401); + } + + const newJiraCredentials = await jiraAuth.getRefreshToken(credentials.source_refresh_token); + const updatedCredentials = await createOrUpdateCredentials(workspaceId, userId, { + source_access_token: newJiraCredentials.access_token, + source_refresh_token: newJiraCredentials.refresh_token, + target_access_token: credentials.target_access_token, + source: "JIRA", + }); + + if (!updatedCredentials.source_access_token) { + throw new JiraApiError("No access token found", 401); + } + + return await fetchJiraResources(axiosInstance, updatedCredentials, true); +} + +function handleError(error: any, res: Response) { + console.error("Error occurred:", error); + if (error instanceof JiraApiError) { + return res.status(error.statusCode).send({ message: error.message }); + } + return res.status(500).send({ message: "An unexpected error occurred" }); +} + +export default JiraController; diff --git a/silo/src/apps/jira-importer/helpers/migration-helpers.ts b/silo/src/apps/jira-importer/helpers/migration-helpers.ts new file mode 100644 index 0000000000..d36c195b4d --- /dev/null +++ b/silo/src/apps/jira-importer/helpers/migration-helpers.ts @@ -0,0 +1,155 @@ +import { TSyncServiceCredentials, TSyncJobWithConfig } from "@silo/core"; +import { IPriorityConfig, IStateConfig, JiraComponent, JiraConfig, JiraSprint } from "@silo/jira"; + +import { ExIssueAttachment, ExState } from "@plane/sdk"; +import { createOrUpdateCredentials, getCredentialsByWorkspaceId, getJobById, updateJob } from "@/db/query"; +import { JiraService } from "@silo/jira"; +import { + Issue as IJiraIssue, + Attachment as JiraAttachment, + Priority as JiraPriority, + StatusDetails as JiraState, +} from "jira.js/out/version3/models"; +import { jiraAuth } from "../auth/auth"; + +export async function getJobData(jobId: string): Promise> { + const [jobData] = await getJobById(jobId); + if (!jobData) { + throw new Error(`[${jobId.slice(0, 7)}] No job data or metadata found. Exiting...`); + } + validateJobData(jobData as TSyncJobWithConfig, jobId); + return jobData as TSyncJobWithConfig; +} + +export function validateJobData(jobData: TSyncJobWithConfig, jobId: string): void { + if (!jobData.workspace_id || !jobData.migration_type) { + throw new Error(`[${jobId.slice(0, 7)}] Missing workspace id. Exiting...`); + } + if (!jobData.initiator_id) { + throw new Error(`[${jobId.slice(0, 7)}] Missing initiator id. Exiting...`); + } + if (!jobData.config) { + throw new Error(`[${jobId.slice(0, 7)}] Missing job config. Exiting...`); + } +} + +export const getTargetState = (job: TSyncJobWithConfig, sourceState: JiraState): ExState | undefined => { + /* TODO: Gracefully handle the case */ + if (!job.config) { + return undefined; + } + const stateConfig = job.config.meta.state; + // Assign the external source and external id from jira and return the target state + const targetState = stateConfig.find((state: IStateConfig) => { + if (state.source_state.id === sourceState.id) { + state.target_state.external_source = "JIRA"; + state.target_state.external_id = sourceState.id as string; + return state; + } + }); + + return targetState?.target_state; +}; + +export const getTargetAttachments = ( + _job: TSyncJobWithConfig, + attachments?: JiraAttachment[] +): Partial => { + if (!attachments) { + return []; + } + const attachmentArray = attachments + .map((attachment: JiraAttachment): Partial => { + return { + external_id: attachment.id ?? "", + external_source: "JIRA", + attributes: { + name: attachment.filename ?? "Untitled", + size: attachment.size ?? 0, + }, + asset: attachment.content ?? "", + }; + }) + .filter((attachment) => attachment !== undefined) as ExIssueAttachment[]; + + return attachmentArray; +}; + +export const getTargetPriority = ( + job: TSyncJobWithConfig, + sourcePriority: JiraPriority +): string | undefined => { + if (!job.config) { + return undefined; + } + const priorityConfig = job.config.meta.priority; + const targetPriority = priorityConfig.find( + (priority: IPriorityConfig) => priority.source_priority.name === sourcePriority.name + ); + return targetPriority?.target_priority; +}; + +export const filterSprintsForIssues = (issues: IJiraIssue[], sprints: JiraSprint[]): any[] => { + const issueIds = new Set(issues.map((issue) => issue.id)); + + return sprints + .filter((sprint) => sprint.issues.some((issue: IJiraIssue) => issueIds.has(issue.id))) + .map((sprint) => ({ + ...sprint, + issues: sprint.issues.filter((issue: IJiraIssue) => issueIds.has(issue.id)), + })); +}; + +export const filterComponentsForIssues = (issues: IJiraIssue[], components: JiraComponent[]): JiraComponent[] => { + const issueIds = new Set(issues.map((issue) => issue.id)); + return components + .filter((component) => component.issues.some((issue: IJiraIssue) => issueIds.has(issue.id))) + .map((component) => ({ + ...component, + issues: component.issues.filter((issue: IJiraIssue) => issueIds.has(issue.id)), + })); +}; + +export const resetJobIfStarted = async (jobId: string, job: TSyncJobWithConfig) => { + if (job.start_time) { + await updateJob(jobId, { + total_batch_count: 0, + completed_batch_count: 0, + transformed_batch_count: 0, + end_time: undefined, + error: "", + }); + } +}; + +export const getJobCredentials = async (job: TSyncJobWithConfig): Promise => { + const credentials = await getCredentialsByWorkspaceId(job.workspace_id!, job.initiator_id!, "JIRA"); + if (!credentials || credentials.length === 0) { + throw new Error(`Credentials not available for job ${job.workspace_id}`); + } + return credentials[0] as TSyncServiceCredentials; +}; + +export const createJiraClient = (job: TSyncJobWithConfig, credentials: any): JiraService => { + const refreshTokenCallback = async ({ + access_token, + refresh_token, + }: { + access_token: string; + refresh_token: string; + }) => { + await createOrUpdateCredentials(job.workspace_id, job.initiator_id, { + source_access_token: access_token, + source_refresh_token: refresh_token, + source: "JIRA", + }); + }; + + return new JiraService({ + accessToken: credentials.source_access_token!, + refreshToken: credentials.source_refresh_token!, + cloudId: job.config?.meta.resource.id as string, + refreshTokenFunc: jiraAuth.getRefreshToken.bind(jiraAuth), + refreshTokenCallback: refreshTokenCallback, + }); +}; diff --git a/silo/src/apps/jira-importer/migrator/README.md b/silo/src/apps/jira-importer/migrator/README.md new file mode 100644 index 0000000000..eff5b350fd --- /dev/null +++ b/silo/src/apps/jira-importer/migrator/README.md @@ -0,0 +1,7 @@ + +## Migrators +Migrators server as a heart of the importer. The structure is to declare the +migrator's transformation inside a folder, such that it's flexible enough to add +more migration strategies. Migrators must be created with such atomicity in mind +that even if we try to convert 1 issue, or 100 issue it should support it. + diff --git a/silo/src/apps/jira-importer/migrator/index.ts b/silo/src/apps/jira-importer/migrator/index.ts new file mode 100644 index 0000000000..41348c30e6 --- /dev/null +++ b/silo/src/apps/jira-importer/migrator/index.ts @@ -0,0 +1 @@ +export * from "./jira.migrator"; diff --git a/silo/src/apps/jira-importer/migrator/jira.migrator.ts b/silo/src/apps/jira-importer/migrator/jira.migrator.ts new file mode 100644 index 0000000000..3330d1b142 --- /dev/null +++ b/silo/src/apps/jira-importer/migrator/jira.migrator.ts @@ -0,0 +1,249 @@ +import { TBatch } from "@/apps/engine/worker/types"; +import { PlaneEntities } from "@plane/sdk"; +import { updateJob } from "@/db/query"; +import { env } from "@/env"; +import { BaseDataMigrator } from "@/etl/base-import-worker"; +import { logger } from "@/logger"; +import { TSyncJobWithConfig } from "@silo/core"; +import { + JiraConfig, + JiraEntity, + pullComments, + pullComponents, + pullIssues, + pullLabels, + pullSprints, + pullUsers, +} from "@silo/jira"; +import { Issue as IJiraIssue } from "jira.js/out/version3/models"; +import { + createJiraClient, + filterComponentsForIssues, + filterSprintsForIssues, + getJobCredentials, + getJobData, + resetJobIfStarted, +} from "../helpers/migration-helpers"; +import { + getTransformedComments, + getTransformedComponents, + getTransformedIssues, + getTransformedLabels, + getTransformedSprints, + getTransformedUsers, +} from "./transformers"; + +export class JiraDataMigrator extends BaseDataMigrator { + constructor(mq: any, store: any) { + super(mq, store); + } + + async getJobData(jobId: string): Promise> { + return getJobData(jobId); + } + + async pull(job: TSyncJobWithConfig): Promise { + // Retrieve and validate the job data + await resetJobIfStarted(job.id, job); + + // Obtain the Jira client and the job credentials + const credentials = await getJobCredentials(job); + const client = createJiraClient(job, credentials); + + if (!job.config) { + logger.info(`No Job Config found for the Job, ${job.id} ${job.workspace_slug}`); + return []; + } + + const projectId = job.config.meta.project.id; + const projectKey = job.config.meta.project.key; + + /* -------------- Pull Jira Data --------------- */ + const users = pullUsers(job.config.meta.users); + const labels = await pullLabels(client); + const issues = await pullIssues(client, projectKey, job.start_time); + const sprints = await pullSprints(client, projectId); + const comments = await pullComments(issues, client); + const components = await pullComponents(client, projectKey); + const customFields = await client.getFields(); + /* -------------- Pull Jira Data --------------- */ + + // Update Job for the actual start time of the migration + await updateJob(job.id, { start_time: new Date() }); + + return [ + { + users, + issues, + labels, + sprints, + components, + customFields, + issue_comments: comments, + }, + ]; + } + + // NOOP, as transform will be done as the integration level + // Transforms all the details from Jira to Plane + transform = async (job: TSyncJobWithConfig, data: JiraEntity[]): Promise => { + // Get the job by the job configuration + if (data.length < 1) { + return []; + } + const entities = data[0]; + const transformedIssue = getTransformedIssues(job, entities); + + /* Todo: Remove this antipattern logic when issue types come to plane */ + if (job.config?.meta.issueType) { + if (job.config.meta.issueType === "create_as_label") { + for (const issue of transformedIssue) { + // For each label of an issue, if the transformed labels doesn't + // contain the label, we need to add it to the transformed labels + if (issue.labels) { + issue.labels.forEach((label) => { + if (!entities.labels.includes(label)) { + entities.labels.push(label); + } + }); + } + } + } + } + + // Add a new label for the issues that are imported from Jira + entities.labels.push("JIRA IMPORTED"); + + // Perrforming the transformation of the data from Jira to Plane + const transformedLabels = getTransformedLabels(job, entities.labels); + const transformedUsers = getTransformedUsers(job, entities); + const transformedModules = getTransformedComponents(job, entities); + const transformedComments = getTransformedComments(job, entities); + const transformedSprintsAsCycles = getTransformedSprints(job, entities); + + // Return the transformed data + return [ + { + users: transformedUsers, + issues: transformedIssue, + labels: transformedLabels, + cycles: transformedSprintsAsCycles, + modules: transformedModules, + issue_comments: transformedComments, + }, + ]; + }; + + async batches(job: TSyncJobWithConfig): Promise[]> { + const sourceData = await this.pull(job); + const batchSize = env.BATCH_SIZE ? parseInt(env.BATCH_SIZE) : 40; + + const data = sourceData[0]; + + // Create a map of issues by their external_id for quick lookup + const issueMap = new Map(data.issues.map((issue: any) => [issue.id, issue])); + + // Get all the related issues for a given issue, with DFS. Traverse the + // issues and search for the parent and children of the issue, if the parent + // is found then add it to the related issues, and if the children are + // found, then add them to the related issues too, and mark them as visited. + const getRelatedIssues = (issue: IJiraIssue, visited: Set) => { + const relatedIssues = new Set([issue]); + const stack = [issue]; + + while (stack.length > 0) { + const currentIssue = stack.pop(); + if (!currentIssue || visited.has(currentIssue.id)) continue; + + visited.add(currentIssue.id); + + if (currentIssue.fields.parent?.id && issueMap.has(currentIssue.fields.parent?.id)) { + const parentIssue = issueMap.get(currentIssue.fields.parent?.id); + if (!visited.has(parentIssue.id)) { + relatedIssues.add(parentIssue); + stack.push(parentIssue); + } + } + + for (const [_id, potentialChild] of issueMap) { + if (potentialChild.fields.parent?.id === currentIssue.id && !visited.has(potentialChild.id)) { + relatedIssues.add(potentialChild); + stack.push(potentialChild); + } + } + } + + return Array.from(relatedIssues); + }; + + const visited = new Set(); + const batches: any[][] = []; + let currentBatch: any[] = []; + + // For each issue, get the related issues and add them to the current batch + for (const issue of data.issues) { + if (visited.has(issue.id)) continue; + + const relatedIssues = getRelatedIssues(issue, visited); + currentBatch.push(...relatedIssues); + + if (currentBatch.length >= batchSize) { + batches.push(currentBatch); + currentBatch = []; + } + } + + if (currentBatch.length > 0) { + batches.push(currentBatch); + } + + const finalBatches: TBatch[] = []; + + // Now for every batch we need to figure out the associations, such as + // comments, sprints and components and push that all to the final batch. Do + // understand that sprint and components are linked to issues, so there is a + // possibility that the same sprint or component can be present in multiple + // batches. + for (const [i, batch] of batches.entries()) { + let random = Math.floor(Math.random() * 10000); + const sprints = filterSprintsForIssues(batch, data.sprints); + const components = filterComponentsForIssues(batch, data.components); + const associatedComments = data.issue_comments.filter((comment: any) => + batch.some((issue: any) => issue.id === comment.issue_id) + ); + + finalBatches.push({ + id: random, + jobId: job.id, + meta: { + batchId: random, + batch_start: i * batchSize, + batch_size: batch.length, + batch_end: i * batchSize + batch.length, + total: { + customFields: data.customFields.length, + issues: data.issues.length, + labels: data.labels.length, + users: data.users.length, + issue_comments: data.issue_comments.length, + sprints: data.sprints.length, + components: data.components.length, + }, + }, + data: [ + { + customFields: data.customFields, + issues: batch, + issue_comments: associatedComments, + sprints: sprints, + components: components, + labels: data.labels, + users: data.users, + }, + ], + }); + } + + return finalBatches; + } +} diff --git a/silo/src/apps/jira-importer/migrator/transformers/etl.ts b/silo/src/apps/jira-importer/migrator/transformers/etl.ts new file mode 100644 index 0000000000..5b63a22738 --- /dev/null +++ b/silo/src/apps/jira-importer/migrator/transformers/etl.ts @@ -0,0 +1,83 @@ +import { ExCycle, ExIssueComment, ExIssueLabel, ExModule, ExIssue as PlaneIssue, PlaneUser } from "@plane/sdk"; +import { TSyncJobWithConfig } from "@silo/core"; +import { + IJiraIssue, + IPriorityConfig, + IStateConfig, + JiraConfig, + JiraEntity, + transformComment, + transformComponent, + transformIssue, + transformLabel, + transformSprint, + transformUser, +} from "@silo/jira"; + +/* ------------------ Transformers ---------------------- +The file contains transformers for the jira entities, responsible +for converting the given jira entites into plane entities. The +transformation depends on the types exported by the source core, +the core types need to be maintained in order to get the correct +transformation results +--------------------- Transformers ---------------------- */ + +export const getTransformedIssues = ( + job: TSyncJobWithConfig, + entities: JiraEntity +): Partial[] => { + const resourceUrl = job.config?.meta.resource.url || ""; + const stateMap: IStateConfig[] = job.config?.meta.state || []; + const priorityMap: IPriorityConfig[] = job.config?.meta.priority || []; + + return entities.issues.map((issue: IJiraIssue): Partial => { + const transformedIssue = transformIssue(issue, resourceUrl, stateMap, priorityMap); + + // Handle issue type configuration + if (job.config?.meta.issueType && issue.fields.issuetype?.name) { + const issueTypeValue = job.config.meta.issueType; + if (issueTypeValue === "create_as_label") { + transformedIssue.labels?.push(issue.fields.issuetype.name.toUpperCase()); + } else { + transformedIssue.name = `[${issue.fields.issuetype.name.toUpperCase()}] ${transformedIssue.name}`; + } + } + + return transformedIssue; + }); +}; + +export const getTransformedLabels = ( + _job: TSyncJobWithConfig, + labels: string[] +): Partial[] => { + return labels.map(transformLabel); +}; + +export const getTransformedComments = ( + _job: TSyncJobWithConfig, + entities: JiraEntity +): Partial[] => { + return entities.issue_comments.map(transformComment); +}; + +export const getTransformedUsers = ( + _job: TSyncJobWithConfig, + entities: JiraEntity +): Partial[] => { + return entities.users.map(transformUser); +}; + +export const getTransformedSprints = ( + _job: TSyncJobWithConfig, + entities: JiraEntity +): Partial[] => { + return entities.sprints.map(transformSprint); +}; + +export const getTransformedComponents = ( + _job: TSyncJobWithConfig, + entities: JiraEntity +): Partial[] => { + return entities.components.map(transformComponent); +}; diff --git a/silo/src/apps/jira-importer/migrator/transformers/index.ts b/silo/src/apps/jira-importer/migrator/transformers/index.ts new file mode 100644 index 0000000000..6659e68134 --- /dev/null +++ b/silo/src/apps/jira-importer/migrator/transformers/index.ts @@ -0,0 +1 @@ +export * from "./etl" diff --git a/silo/src/apps/linear-importer/auth/auth.ts b/silo/src/apps/linear-importer/auth/auth.ts new file mode 100644 index 0000000000..08e01bdc62 --- /dev/null +++ b/silo/src/apps/linear-importer/auth/auth.ts @@ -0,0 +1,8 @@ +import { env } from "@/env"; +import { LinearAuth } from "@silo/linear"; + +export const linearAuth = new LinearAuth({ + clientId: env.LINEAR_CLIENT_ID, + clientSecret: env.LINEAR_CLIENT_SECRET, + callbackURL: "/silo/api/linear/auth/callback", +}); diff --git a/silo/src/apps/linear-importer/controllers/index.ts b/silo/src/apps/linear-importer/controllers/index.ts new file mode 100644 index 0000000000..1ed0fae079 --- /dev/null +++ b/silo/src/apps/linear-importer/controllers/index.ts @@ -0,0 +1,152 @@ +import { env } from "@/env"; +import { Controller, Get, Post } from "@/lib"; +import { Request, Response } from "express"; +import { LinearTokenResponse, createLinearService } from "@silo/linear"; +import { createOrUpdateCredentials, getCredentialsByWorkspaceId } from "@/db/query"; +import { LinearAuthPayload, LinearAuthState } from "@silo/linear"; +import { linearAuth } from "../auth/auth"; + +@Controller("/linear") +class LinearController { + @Get("/ping") + async ping(_req: Request, res: Response) { + res.send("pong"); + } + + @Post("/auth/url") + async getAuthURL(req: Request, res: Response) { + const body: LinearAuthState = req.body; + if (!body.workspaceId || !body.apiToken) { + return res.status(400).send({ + message: "Bad Request, expected both apiToken and workspaceId to be present.", + }); + } + const hostname = env.SILO_API_BASE_URL; + const response = linearAuth.getAuthorizationURL(body, hostname); + res.send(response); + } + + @Get("/auth/callback") + async authCallback(req: Request, res: Response) { + const query: LinearAuthPayload | any = req.query; + if (!query.code || !query.state) { + return res.status(400).send("code not found in the query params"); + } + const stringifiedJsonState = query.state as string; + // Decode the base64 encoded state string and parse it to JSON + const state: LinearAuthState = JSON.parse(Buffer.from(stringifiedJsonState, "base64").toString()); + let tokenResponse: LinearTokenResponse; + try { + const hostname = env.SILO_API_BASE_URL; + const tokenInfo = await linearAuth.getAccessToken(query.code as string, state, hostname); + tokenResponse = tokenInfo.tokenResponse; + console.log("tokenResponse", tokenResponse); + } catch (error: any) { + console.log("Error occured while fetching token details", error.response.data); + res.status(400).send(error.response.data); + return; + } + + if (!tokenResponse) { + res.status(400).send("failed to fetch token details"); + return; + } + + // Create a new credentials record in the database for the recieved token + await createOrUpdateCredentials(state.workspaceId, state.userId, { + source_access_token: tokenResponse.access_token, + source_refresh_token: tokenResponse.refresh_token, + target_access_token: state.apiToken, + source: "LINEAR", + workspace_id: state.workspaceId, + }); + + try { + // As we are using base path as /linear, we can redirect to /linear + res.redirect(`${env.APP_BASE_URL}/${state.workspaceSlug}/settings/imports/linear/`); + } catch (error: any) { + res.status(500).send(error.response.data); + } + } + + /** + * @description fetching linear teams + */ + @Post("/teams") + async getTeams(req: Request, res: Response) { + try { + const { workspaceId, userId } = req.body; + if (!workspaceId || !userId) { + return res.status(400).send({ + message: "Bad Request, expected both workspaceId and userId to be present.", + }); + } + const linearServiceInstance = await linearService(workspaceId, userId); + const teams = await linearServiceInstance.getTeamsWithoutPagination(); + res.send(teams); + } catch (error: any) { + res.status(500).send(error.response.data); + } + } + + /** + * @description fetching linear team states + */ + @Post("/team-states") + async getTeamStates(req: Request, res: Response) { + try { + const { workspaceId, userId, teamId } = req.body; + if (!workspaceId || !userId || !teamId) { + return res.status(400).send({ + message: "Bad Request, expected workspaceId, userId, and teamId to be present.", + }); + } + const linearServiceInstance = await linearService(workspaceId, userId); + const teams = await linearServiceInstance.getTeamStatusesWithoutPagination(teamId); + res.send(teams); + } catch (error: any) { + res.status(500).send(error.response.data); + } + } + + /** + * @description fetching linear team issues count + */ + @Post("/team-issue-count") + async getTeamIssuesCount(req: Request, res: Response) { + try { + const { workspaceId, userId, teamId } = req.body; + if (!workspaceId || !userId || !teamId) { + return res.status(400).send({ + message: "Bad Request, expected workspaceId, userId, and teamId to be present.", + }); + } + const linearServiceInstance = await linearService(workspaceId, userId); + const teams = await linearServiceInstance.getNumberOfIssues(teamId); + res.send(teams); + } catch (error: any) { + res.status(500).send(error.response.data); + } + } +} + +const linearService = async (workspaceId: string, userId: string) => { + if (!workspaceId || !userId) { + throw new Error("workspaceId and userId are required"); + } + + const credentials = await getCredentialsByWorkspaceId(workspaceId, userId, "LINEAR"); + if (!credentials || credentials.length <= 0) { + throw new Error("No credentials found for the workspace"); + } + + const credentialsData = credentials[0]; + + if (!credentialsData.source_access_token) { + throw new Error("No target access token found"); + } + + return createLinearService({ accessToken: credentialsData.source_access_token }); +}; + +export default LinearController; diff --git a/silo/src/apps/linear-importer/helpers/generic-helpers.ts b/silo/src/apps/linear-importer/helpers/generic-helpers.ts new file mode 100644 index 0000000000..21ee64b4f0 --- /dev/null +++ b/silo/src/apps/linear-importer/helpers/generic-helpers.ts @@ -0,0 +1,45 @@ +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 getFormattedDate = (date: string | 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; + } +}; + +export const getRandomColor = () => { + return "#" + Math.floor(Math.random() * 16777215).toString(16); +}; diff --git a/silo/src/apps/linear-importer/helpers/migration-helpers.ts b/silo/src/apps/linear-importer/helpers/migration-helpers.ts new file mode 100644 index 0000000000..733bd034af --- /dev/null +++ b/silo/src/apps/linear-importer/helpers/migration-helpers.ts @@ -0,0 +1,62 @@ +import { Issue as LinearIssue } from "@linear/sdk"; +import { getCredentialsByWorkspaceId, getJobById, updateJob } from "@/db/query"; +import { TSyncServiceCredentials, TSyncJobWithConfig } from "@silo/core"; +import { LinearConfig, LinearCycle, LinearService } from "@silo/linear"; + +export async function getJobData(jobId: string): Promise> { + const [jobData] = await getJobById(jobId); + if (!jobData) { + throw new Error(`[${jobId.slice(0, 7)}] No job data or metadata found. Exiting...`); + } + validateJobData(jobData as unknown as TSyncJobWithConfig, jobId); + return jobData as unknown as TSyncJobWithConfig; +} + +export function validateJobData(jobData: TSyncJobWithConfig, jobId: string): void { + if (!jobData.workspace_id || !jobData.migration_type) { + throw new Error(`[${jobId.slice(0, 7)}] Missing workspace id. Exiting...`); + } + if (!jobData.initiator_id) { + throw new Error(`[${jobId.slice(0, 7)}] Missing initiator id. Exiting...`); + } + if (!jobData.config) { + throw new Error(`[${jobId.slice(0, 7)}] Missing job config. Exiting...`); + } +} + +export const filterCyclesForIssues = (issues: LinearIssue[], cycles: LinearCycle[]): any[] => { + const issueIds = new Set(issues.map((issue) => issue.id)); + + return cycles + .filter((cycle) => cycle.issues.some((issue) => issueIds.has(issue.id))) + .map((cycle) => ({ + ...cycle, + issues: cycle.issues.filter((issue) => issueIds.has(issue.id)), + })); +}; + +export const resetJobIfStarted = async (jobId: string, job: TSyncJobWithConfig) => { + if (job.start_time) { + await updateJob(jobId, { + total_batch_count: 0, + completed_batch_count: 0, + transformed_batch_count: 0, + end_time: undefined, + error: "", + }); + } +}; + +export const getJobCredentials = async (job: TSyncJobWithConfig): Promise => { + const credentials = await getCredentialsByWorkspaceId(job.workspace_id!, job.initiator_id!, "LINEAR"); + if (!credentials || credentials.length === 0) { + throw new Error(`Credentials not available for job ${job.workspace_id}`); + } + return credentials[0] as TSyncServiceCredentials; +}; + +export const createLinearClient = (credentials: TSyncServiceCredentials): LinearService => { + return new LinearService({ + accessToken: credentials.source_access_token!, + }); +}; diff --git a/silo/src/apps/linear-importer/migrator/index.ts b/silo/src/apps/linear-importer/migrator/index.ts new file mode 100644 index 0000000000..1b0d16cc62 --- /dev/null +++ b/silo/src/apps/linear-importer/migrator/index.ts @@ -0,0 +1 @@ +export * from "./linear.migrator"; diff --git a/silo/src/apps/linear-importer/migrator/linear.migrator.ts b/silo/src/apps/linear-importer/migrator/linear.migrator.ts new file mode 100644 index 0000000000..2df092883b --- /dev/null +++ b/silo/src/apps/linear-importer/migrator/linear.migrator.ts @@ -0,0 +1,207 @@ +import { MQ, Store } from "@/apps/engine/worker/base"; +import { TBatch } from "@/apps/engine/worker/types"; +import { Issue as LinearIssue } from "@linear/sdk"; +import { PlaneEntities } from "@plane/sdk"; +import { updateJob } from "@/db/query"; +import { env } from "@/env"; +import { BaseDataMigrator } from "@/etl/base-import-worker"; +import { logger } from "@/logger"; +import { TSyncJobWithConfig } from "@silo/core"; +import { pullComments, pullCycles, pullIssues, pullLabels, pullUsers } from "@silo/linear"; +import { getRandomColor } from "../helpers/generic-helpers"; +import { + createLinearClient, + filterCyclesForIssues, + getJobCredentials, + getJobData, + resetJobIfStarted, +} from "../helpers/migration-helpers"; +import { LinearConfig, LinearEntity } from "@silo/linear"; +import { + getTransformedComments, + getTransformedCycles, + getTransformedIssues, + getTransformedLabels, + getTransformedUsers, +} from "./tranformers/etl"; + +export class LinearDataMigrator extends BaseDataMigrator { + constructor(mq: MQ, store: Store) { + super(mq, store); + } + + async getJobData(jobId: string): Promise> { + return getJobData(jobId); + } + + async pull(job: TSyncJobWithConfig): Promise { + await resetJobIfStarted(job.id, job); + const credentials = await getJobCredentials(job); + const client = createLinearClient(credentials); + + if (!job.config) { + return []; + } + + const users = await pullUsers(client, job.config.meta.teamId); + const labels = await pullLabels(client); + const issues = await pullIssues(client, job.config.meta.teamId); + const cycles = await pullCycles(client, job.config.meta.teamId); + const comments = await pullComments(issues, client); + + await updateJob(job.id, { + start_time: new Date(), + }); + + return [ + { + users, + labels, + issues, + cycles, + issue_comments: comments, + }, + ]; + } + + async transform(job: TSyncJobWithConfig, data: LinearEntity[]): Promise { + if (data.length < 1) { + return []; + } + const entities = data[0]; + const transformedIssue = await getTransformedIssues(job, entities); + const transformedLabels = getTransformedLabels(job, entities); + transformedLabels.push({ + name: "Linear Imported", + color: getRandomColor(), + }); + const transformedUsers = getTransformedUsers(job, entities); + const transformedCycles = await getTransformedCycles(job, entities); + const transformedComments = getTransformedComments(job, entities); + + return [ + { + issues: transformedIssue, + labels: transformedLabels, + users: transformedUsers, + cycles: transformedCycles, + issue_comments: transformedComments, + modules: [], + }, + ]; + } + + async batches(job: TSyncJobWithConfig): Promise[]> { + const sourceData = await this.pull(job); + const batchSize = env.BATCH_SIZE ? parseInt(env.BATCH_SIZE) : 40; + + const data = sourceData[0]; + + // Create a map of issues by their external_id for quick lookup + const issueMap = new Map(data.issues.map((issue: LinearIssue) => [issue.id, issue])); + + // Get all the related issues for a given issue, with DFS. Traverse the + // issues and search for the parent and children of the issue, if the parent + // is found then add it to the related issues, and if the children are + // found, then add them to the related issues too, and mark them as visited. + const getRelatedIssues = async (issue: LinearIssue, visited: Set) => { + const relatedIssues = new Set([issue]); + const stack = [issue]; + + while (stack.length > 0) { + const currentIssue = stack.pop(); + if (!currentIssue || visited.has(currentIssue.id)) continue; + + visited.add(currentIssue.id); + + if (issue.parent) { + const parent = await issue.parent; + if (parent && issueMap.has(parent.id)) { + const parentIssue = issueMap.get(parent.id); + if (parentIssue && !visited.has(parentIssue.id)) { + relatedIssues.add(parentIssue); + stack.push(parentIssue); + } + } + } + + for (const [_id, potentialChild] of issueMap) { + if (potentialChild.parent) { + const parent = await potentialChild.parent; + if (parent.id === currentIssue.id && !visited.has(potentialChild.id)) { + relatedIssues.add(potentialChild); + stack.push(potentialChild); + } + } + } + } + + return Array.from(relatedIssues); + }; + + const visited = new Set(); + const batches: any[][] = []; + let currentBatch: any[] = []; + + // For each issue, get the related issues and add them to the current batch + for (const issue of data.issues) { + if (visited.has(issue.id)) continue; + + const relatedIssues = await getRelatedIssues(issue, visited); + currentBatch.push(...relatedIssues); + + if (currentBatch.length >= batchSize) { + batches.push(currentBatch); + currentBatch = []; + } + } + + if (currentBatch.length > 0) { + batches.push(currentBatch); + } + + const finalBatches: TBatch[] = []; + + // Now for every batch we need to figure out the associations, such as + // comments, sprints and components and push that all to the final batch. Do + // understand that sprint and components are linked to issues, so there is a + // possibility that the same sprint or component can be present in multiple + // batches. + for (const [i, batch] of batches.entries()) { + let random = Math.floor(Math.random() * 10000); + const cycles = filterCyclesForIssues(batch, data.cycles); + const associatedComments = data.issue_comments.filter((comment: any) => + batch.some((issue: any) => issue.id === comment.issue_id) + ); + + finalBatches.push({ + id: random, + jobId: job.id, + meta: { + batchId: random, + batch_start: i * batchSize, + batch_size: batch.length, + batch_end: i * batchSize + batch.length, + total: { + issues: data.issues.length, + labels: data.labels.length, + users: data.users.length, + issue_comments: data.issue_comments.length, + cycles: data.cycles.length, + }, + }, + data: [ + { + issues: batch, + issue_comments: associatedComments, + cycles: cycles, + labels: data.labels, + users: data.users, + }, + ], + }); + } + + return finalBatches; + } +} diff --git a/silo/src/apps/linear-importer/migrator/tranformers/etl.ts b/silo/src/apps/linear-importer/migrator/tranformers/etl.ts new file mode 100644 index 0000000000..8b228bcfac --- /dev/null +++ b/silo/src/apps/linear-importer/migrator/tranformers/etl.ts @@ -0,0 +1,91 @@ +import { Issue, IssueLabel } from "@linear/sdk"; +import { ExCycle, ExIssueComment, ExIssueLabel, ExIssue as PlaneIssue, PlaneUser } from "@plane/sdk"; +import { TSyncJobWithConfig } from "@silo/core"; +import { transformComment, transformCycle, transformIssue, transformUser } from "@silo/linear"; +import { getRandomColor } from "../../helpers/generic-helpers"; +import { LinearConfig, LinearEntity } from "@silo/linear"; + +/* ------------------ Transformers ---------------------- +This file contains transformers for Linear entities, responsible +for converting the given Linear entities into Plane entities. The +transformation depends on the types exported by the source core, +and the core types need to be maintained to get the correct +transformation results. +--------------------- Transformers ---------------------- */ + +export const getTransformedIssues = async ( + job: TSyncJobWithConfig, + entities: LinearEntity +): Promise[]> => { + const teamUrl = job.config?.meta.teamUrl || ""; + const stateMap = job.config?.meta.state || []; + + const issuePromises = entities.issues.map(async (issue: Issue) => { + const transformedIssue = await transformIssue(issue, teamUrl, entities.users, entities.labels, stateMap); + // Add a label signifying the issue is imported from Linear + if (transformedIssue.labels) { + transformedIssue.labels.push("Linear Imported"); + } else { + transformedIssue.labels = ["Linear Imported"]; + } + + return transformedIssue; + }); + + return Promise.all(issuePromises); +}; + +export const getTransformedLabels = ( + _job: TSyncJobWithConfig, + entities: LinearEntity +): Partial[] => { + return entities.labels.map((label: IssueLabel): Partial => { + return { + name: label.name, + color: label.color ?? getRandomColor(), + }; + }); +}; + +export const getTransformedComments = ( + _job: TSyncJobWithConfig, + entities: LinearEntity +): Partial[] => { + const commentPromises = entities.issue_comments.map((comment) => { + return transformComment(comment, entities.users); + }); + return commentPromises; +}; + +export const getTransformedUsers = ( + _job: TSyncJobWithConfig, + entities: LinearEntity +): Partial[] => { + return entities.users.map(transformUser); +}; + +export const getTransformedCycles = async ( + _job: TSyncJobWithConfig, + entities: LinearEntity +): Promise[]> => { + const cyclePromises = entities.cycles.map(transformCycle); + return Promise.all(cyclePromises); +}; + +// export const getTransformedTeams = async ( +// _job: Job, +// entities: LinearEntity +// ): Promise[]> => { +// const modulePromises = entities.teams.map(async (team: Team): Promise> => { +// const issues = await team.issues() +// return { +// external_id: team.id, +// external_source: "LINEAR", +// name: team.name, +// description: team.description ?? "", +// issues: issues.nodes.map((issue) => issue.id) +// } +// }) + +// return Promise.all(modulePromises) +// } diff --git a/silo/src/apps/linear-importer/migrator/tranformers/index.ts b/silo/src/apps/linear-importer/migrator/tranformers/index.ts new file mode 100644 index 0000000000..6659e68134 --- /dev/null +++ b/silo/src/apps/linear-importer/migrator/tranformers/index.ts @@ -0,0 +1 @@ +export * from "./etl" diff --git a/silo/src/db/config/db.config.ts b/silo/src/db/config/db.config.ts new file mode 100644 index 0000000000..f55dd32908 --- /dev/null +++ b/silo/src/db/config/db.config.ts @@ -0,0 +1,8 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import * as schema from "../schema"; +import { env } from "@/env"; + +export const connection = postgres(env.DB_URL || ""); + +export const db = drizzle(connection, { schema }); diff --git a/silo/src/db/migrations/0000_harsh_peter_quill.sql b/silo/src/db/migrations/0000_harsh_peter_quill.sql new file mode 100644 index 0000000000..88e36bad5f --- /dev/null +++ b/silo/src/db/migrations/0000_harsh_peter_quill.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS "credentials" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "source" text, + "workspace_id" uuid, + "user_id" uuid, + "source_access_token" text, + "source_refresh_token" text, + "target_access_token" text +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "job_configs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "meta" json DEFAULT '{}'::json +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "config_id" uuid, + "migration_type" varchar, + "project_id" uuid, + "workspace_id" uuid, + "workspace_slug" text, + "initiator_id" uuid, + "initiator_email" text, + "completed_batch_count" integer DEFAULT 0, + "transformed_batch_count" integer DEFAULT 0, + "total_batch_count" integer DEFAULT 0, + "target_hostname" text, + "start_time" timestamp, + "end_time" timestamp, + "status" varchar DEFAULT 'INITIATED', + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now(), + "error" text DEFAULT '' +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "jobs" ADD CONSTRAINT "jobs_config_id_job_configs_id_fk" FOREIGN KEY ("config_id") REFERENCES "public"."job_configs"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "workspace_id_idx" ON "credentials" USING btree ("workspace_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "project_idx" ON "jobs" USING btree ("project_id"); \ No newline at end of file diff --git a/silo/src/db/migrations/meta/0000_snapshot.json b/silo/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000000..21711be961 --- /dev/null +++ b/silo/src/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,264 @@ +{ + "id": "e33e62d7-44ed-4345-9d3c-1a966e7a0bba", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.credentials": { + "name": "credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_access_token": { + "name": "source_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_refresh_token": { + "name": "source_refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_access_token": { + "name": "target_access_token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_id_idx": { + "name": "workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.job_configs": { + "name": "job_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "meta": { + "name": "meta", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'::json" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "migration_type": { + "name": "migration_type", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "workspace_slug": { + "name": "workspace_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initiator_id": { + "name": "initiator_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "initiator_email": { + "name": "initiator_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_batch_count": { + "name": "completed_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "transformed_batch_count": { + "name": "transformed_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_batch_count": { + "name": "total_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "target_hostname": { + "name": "target_hostname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'INITIATED'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "''" + } + }, + "indexes": { + "project_idx": { + "name": "project_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_config_id_job_configs_id_fk": { + "name": "jobs_config_id_job_configs_id_fk", + "tableFrom": "jobs", + "tableTo": "job_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/silo/src/db/migrations/meta/_journal.json b/silo/src/db/migrations/meta/_journal.json new file mode 100644 index 0000000000..14e92b199c --- /dev/null +++ b/silo/src/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1726818003378, + "tag": "0000_harsh_peter_quill", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/silo/src/db/query/credentials.ts b/silo/src/db/query/credentials.ts new file mode 100644 index 0000000000..0a4e654bba --- /dev/null +++ b/silo/src/db/query/credentials.ts @@ -0,0 +1,91 @@ +import { db } from "@/db/config/db.config"; +import { and, eq } from "drizzle-orm"; +import * as schema from "../schema"; + +/* ------------------- Create Job ------------------- */ +// Create the job based on the data that defined +export const createOrUpdateCredentials = async (workspaceId: string, userId: string, credentials: any) => { + // Check if the credentials already exist + const existingCredentials = await db + .select() + .from(schema.credentials) + .where( + and( + eq(schema.credentials.user_id, userId), + eq(schema.credentials.workspace_id, workspaceId), + eq(schema.credentials.source, credentials.source) + ) + ); + + // If the credentials already exist, update them + if (existingCredentials.length > 0) { + const [updatedCredentials] = await db + .update(schema.credentials) + .set(credentials) + .where(eq(schema.credentials.workspace_id, workspaceId)) + .returning(); + return updatedCredentials; + } else { + const [newCredentials] = await db + .insert(schema.credentials) + .values({ + workspace_id: workspaceId, + user_id: userId, + ...credentials, + }) + .returning(); + return newCredentials; + } +}; + +export const createCredentials = async (workspaceId: string, credentials: any) => { + const [newCredentials] = await db + .insert(schema.credentials) + .values({ + workspace_id: workspaceId, + ...credentials, + }) + .returning({ insertedId: schema.jobs.id }); + return newCredentials; +}; + +export const getCredentialsByWorkspaceId = async (workspaceId: string, userId: string, source: string) => { + const credentials = await db + .select() + .from(schema.credentials) + .where( + and( + eq(schema.credentials.user_id, userId), + eq(schema.credentials.workspace_id, workspaceId), + eq(schema.credentials.source, source) + ) + ); + + return credentials; +}; + +export const getCredentialsByOnlyWorkspaceId = async (workspaceId: string, source: string) => { + const credentials = await db + .select() + .from(schema.credentials) + .where(and(eq(schema.credentials.workspace_id, workspaceId), eq(schema.credentials.source, source))); + return credentials; +}; + +export const getCredentialsByTargetToken = async (targetToken: string) => { + try { + const credentials = await db + .select() + .from(schema.credentials) + .where(eq(schema.credentials.target_access_token, targetToken)); + + return credentials; + } catch (error) { + console.error("Error getting credentials by target token", error); + return []; + } +}; + +export const deleteCredentialsBySourceToken = async (sourceToken: string) => { + await db.delete(schema.credentials).where(eq(schema.credentials.source_access_token, sourceToken)); +}; diff --git a/silo/src/db/query/index.ts b/silo/src/db/query/index.ts new file mode 100644 index 0000000000..d35de70ae9 --- /dev/null +++ b/silo/src/db/query/index.ts @@ -0,0 +1,2 @@ +export * from "./credentials" +export * from "./job" diff --git a/silo/src/db/query/job.ts b/silo/src/db/query/job.ts new file mode 100644 index 0000000000..2be4a9fb50 --- /dev/null +++ b/silo/src/db/query/job.ts @@ -0,0 +1,100 @@ +import { db } from "@/db/config/db.config"; +import { and, eq } from "drizzle-orm"; +import * as schema from "../schema"; +import { TSyncServices } from "@silo/core"; + +/* ------------------- Create Job ------------------- */ +// Create the job based on the data that defined +export const createJob = async (jobData: any) => { + const [newJob] = await db.insert(schema.jobs).values(jobData).returning({ insertedId: schema.jobs.id }); + return newJob; +}; + +/* --------------------- Get Job --------------------- */ +export const getJobById = async (id: string) => { + const jobs = await db.select().from(schema.jobs).where(eq(schema.jobs.id, id)); + + const result = jobs.map(async (job) => { + const [jobConfig] = await db + .select() + .from(schema.jobConfigs) + .where(eq(schema.jobConfigs.id, job.config as any)); + return { ...job, config: jobConfig }; + }); + + return await Promise.all(result); +}; + +// Fetch the job and jobconfig from the given workspaceslug +export const getJobByWorkspaceIdAndSource = async (workspaceId: string, source: TSyncServices) => { + // Get the job with the workspace slug + const jobs = await db + .select() + .from(schema.jobs) + .where(and(eq(schema.jobs.workspace_id, workspaceId), eq(schema.jobs.migration_type, source))); + + const result = jobs.map(async (job) => { + const [jobConfig] = await db + .select() + .from(schema.jobConfigs) + .where(eq(schema.jobConfigs.id, job.config as any)); + return { ...job, config: jobConfig }; + }); + + return await Promise.all(result); +}; + +// Fetch the job and jobconfig from the given workspaceslug +export const getJobByWorkspaceId = async (workspaceId: string) => { + // Get the job with the workspace slug + const jobs = await db.select().from(schema.jobs).where(eq(schema.jobs.workspace_id, workspaceId)); + + const result = jobs.map(async (job) => { + const [jobConfig] = await db + .select() + .from(schema.jobConfigs) + .where(eq(schema.jobConfigs.id, job.config as any)); + return { ...job, config: jobConfig }; + }); + + return await Promise.all(result); +}; + +// Fetch the job and jobconfig from the given workspaceslug and projectid +export const getJobByProjectId = async (workspaceId: string, projectId: string) => { + // Get the job with both workspaceslug and projectid + const jobs = await db + .select() + .from(schema.jobs) + .where(and(eq(schema.jobs.workspace_id, workspaceId), eq(schema.jobs.project_id, projectId))); + + const result = jobs.map(async (job) => { + const [jobConfig] = await db + .select() + .from(schema.jobConfigs) + .where(eq(schema.jobConfigs.id, job.config as any)); + return { ...job, config: jobConfig }; + }); + + return await Promise.all(result); +}; + +/* --------------------- Update Job --------------------- */ +// Fetch the job and jobconfig from the given workspaceslug and projectid +export const updateJob = async (id: string, jobData: any) => { + return await db.update(schema.jobs).set(jobData).where(eq(schema.jobs.id, id)); +}; + +export const deleteJob = async (id: string) => { + return await db.delete(schema.jobs).where(eq(schema.jobs.id, id)); +}; + +/* --------------------- Create Job Config --------------------- */ +// Creates the job config based on the data that defined +export const createJobConfig = async (configData: any) => { + const [newJobConfig] = await db + .insert(schema.jobConfigs) + .values(configData) + .returning({ insertedId: schema.jobConfigs.id }); + return newJobConfig; +}; diff --git a/silo/src/db/schema/cred.schema.ts b/silo/src/db/schema/cred.schema.ts new file mode 100644 index 0000000000..167d6803e1 --- /dev/null +++ b/silo/src/db/schema/cred.schema.ts @@ -0,0 +1,21 @@ +import { pgTable, text, uuid, index } from "drizzle-orm/pg-core"; + +export const credentials = pgTable( + "credentials", + { + id: uuid("id").defaultRandom().primaryKey(), + source: text("source"), + workspace_id: uuid("workspace_id"), + user_id: uuid("user_id"), + source_access_token: text("source_access_token"), + source_refresh_token: text("source_refresh_token"), + target_access_token: text("target_access_token"), + }, + (table) => { + return { + // Add indexes on projectId and workspaceSlug, as those would be used mostly for querying jobs + workspaceIdIndex: index("workspace_id_idx").on(table.workspace_id), + }; + }, +); + diff --git a/silo/src/db/schema/index.ts b/silo/src/db/schema/index.ts new file mode 100644 index 0000000000..ac7478fd3b --- /dev/null +++ b/silo/src/db/schema/index.ts @@ -0,0 +1,2 @@ +export * from "./job.schema" +export * from "./cred.schema" diff --git a/silo/src/db/schema/job.schema.ts b/silo/src/db/schema/job.schema.ts new file mode 100644 index 0000000000..7f6c95091d --- /dev/null +++ b/silo/src/db/schema/job.schema.ts @@ -0,0 +1,44 @@ +import { pgTable, text, varchar, uuid, timestamp, json, integer, index } from "drizzle-orm/pg-core" + +export const jobConfigs = pgTable("job_configs", { + id: uuid("id").defaultRandom().primaryKey(), + meta: json("meta").default({}) +}) + +export const jobs = pgTable( + "jobs", + { + id: uuid("id").defaultRandom().primaryKey(), + config: uuid("config_id").references(() => jobConfigs.id), + migration_type: varchar("migration_type", { + enum: ["JIRA", "ASANA", "LINEAR", "TRELLO", "GITHUB", "GITLAB", "SLACK"] + }), + project_id: uuid("project_id"), + workspace_id: uuid("workspace_id"), + workspace_slug: text("workspace_slug"), + initiator_id: uuid("initiator_id"), + initiator_email: text("initiator_email"), + // source info + completed_batch_count: integer("completed_batch_count").default(0), + transformed_batch_count: integer("transformed_batch_count").default(0), + total_batch_count: integer("total_batch_count").default(0), + // target info + target_hostname: text("target_hostname"), + // status + start_time: timestamp("start_time"), + end_time: timestamp("end_time"), + status: varchar("status", { + enum: ["INITIATED", "PULLING", "TRANSFORMING", "PUSHING", "FINISHED", "ERROR"] + }).default("INITIATED"), + // trackers + created_at: timestamp("created_at").defaultNow(), + updated_at: timestamp("updated_at").defaultNow(), + error: text("error").default("") + }, + (table) => { + return { + // Add indexes on projectId and workspaceSlug, as those would be used mostly for querying jobs + projectIdx: index("project_idx").on(table.project_id) + } + } +) diff --git a/silo/src/env.ts b/silo/src/env.ts new file mode 100644 index 0000000000..680aed098c --- /dev/null +++ b/silo/src/env.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import dotenv from "dotenv"; + +dotenv.config(); + +const envSchema = z.object({ + // App Env Variables + BATCH_SIZE: z.string(), + PORT: z.string().min(1), + DB_URL: z.string().min(1), + AMQP_URL: z.string().min(1), + REDIS_URL: z.string().min(1), + SENTRY_DSN: z.string().min(1), + APP_BASE_URL: z.string().min(1), + SILO_API_BASE_URL: z.string().min(1), + MQ_PREFETCH_COUNT: z.string().default("5"), + // Jira Env Variables + JIRA_CLIENT_ID: z.string().min(1), + JIRA_CLIENT_SECRET: z.string().min(1), + // Linear Env Variables + LINEAR_CLIENT_ID: z.string().min(1), + LINEAR_CLIENT_SECRET: z.string().min(1), +}); + +function validateEnv() { + const result = envSchema.safeParse(process.env); + + if (!result.success) { + console.error("❌ Invalid environment variables:", JSON.stringify(result.error.format(), null, 4)); + process.exit(1); + } + + return result.data; +} + +export const env = validateEnv(); diff --git a/silo/src/etl/base-import-worker.ts b/silo/src/etl/base-import-worker.ts new file mode 100644 index 0000000000..3f19e44492 --- /dev/null +++ b/silo/src/etl/base-import-worker.ts @@ -0,0 +1,215 @@ +import { MQ, Store } from "@/apps/engine/worker/base"; +import { TBatch, UpdateEventType } from "@/apps/engine/worker/types"; +import { PlaneEntities } from "@plane/sdk"; +import { updateJob } from "@/db/query"; +import { wait } from "@/helpers/delay"; +import { logger } from "@/logger"; +import { TaskHandler, TaskHeaders } from "@/types"; +import { TSyncJobWithConfig, TSyncJobStatus } from "@silo/core"; +import { getJobForMigration, migrateToPlane } from "./migrator"; + +export abstract class BaseDataMigrator implements TaskHandler { + private mq: MQ; + private store: Store; + + constructor(mq: MQ, store: Store) { + this.mq = mq; + this.store = store; + } + + abstract batches(job: TSyncJobWithConfig): Promise[]>; + abstract transform(job: TSyncJobWithConfig, data: TSourceEntity[], meta: any): Promise; + abstract getJobData(jobId: string): Promise>; + + async handleTask(headers: TaskHeaders, data: any): Promise { + try { + const job = await this.getJobData(headers.jobId); + // Wait for a random amount of time, such that we don't conflict with the + // keys + const randomWait = Math.floor(Math.random() * 3000); + await wait(randomWait); + const processingBatchKey = `silo:${job.workspace_id}:${headers.jobId}`; + const processingBatch = await this.store.get(processingBatchKey); + + if ( + processingBatch != null && + processingBatch != "initiate" && + data.meta && + data.meta.batchId && + processingBatch !== data.meta.batchId + ) { + /* To Be Solved: + * Say if there is only one job with n number of batches, + * is being processed, in that case, we would face a case of juggling, + * we will continuously ack and requeue the messages and pick the same + * messages again after n-1 retries, which is not good. + */ + + /* + * Why not nacking the message directly? + * If we nack the message directly, the message will stay on the same + * position in the queue, as mq try to put the messages at the head + * of the queue when rejected and when we try to consume again, we will + * get the same message, which may be of the same batch, leading to the + * same batch + */ + // Push the message to the queue and requeue the message + await this.pushToQueue(headers, data); + return true; + } else { + // Set the batch as processing + await this.store.set(processingBatchKey, data.meta?.batchId || "initiate"); + } + + switch (headers.type) { + case "initiate": + logger.info( + `[${headers.route.toUpperCase()}][${headers.type.toUpperCase()}] Initiating job 🐼------------------- [${job.id.slice(0, 7)}]` + ); + await this.update(headers.jobId, "PULLING", {}); + const batches = await this.batches(job); + await this.update(headers.jobId, "PULLED", { + total_batch_count: batches.length, + }); + + if (batches.length === 0) { + await this.update(headers.jobId, "FINISHED", { + total_batch_count: batches.length, + completed_batch_count: batches.length, + transformed_batch_count: batches.length, + end_time: new Date(), + }); + return true; + } + for (const batch of batches) { + await wait(1000); + headers.type = "transform"; + this.pushToQueue(headers, batch); + } + + return true; + case "transform": + logger.info( + `[${headers.route.toUpperCase()}][${headers.jobId.slice(0, 7)}] Transforming data for batch 🧹 ------------------- [${data.meta.batchId}]` + ); + this.update(headers.jobId, "TRANSFORMING", {}); + const transformedData = await this.transform(job, data.data, data.meta); + if (transformedData.length !== 0) { + headers.type = "push"; + await this.pushToQueue(headers, { + data: transformedData, + meta: data.meta, + }); + } else { + await this.update(headers.jobId, "FINISHED", {}); + } + await this.update(headers.jobId, "TRANSFORMED", {}); + return true; + case "push": + await this.update(headers.jobId, "PUSHING", {}); + await migrateToPlane(job, data.data, data.meta); + await this.update(headers.jobId, "FINISHED", {}); + // Delete the workspace from the store, as we are done processing the + // job, the worker is free to pick another job from the same workspace + await this.store.del(processingBatchKey); + logger.info( + `[${headers.route.toUpperCase()}][${headers.jobId.slice(0, 7)}] Finished pushing data to batch 🚀 ------------------- [${data.meta.batchId}]` + ); + return true; + default: + break; + } + return true; + } catch (error) { + logger.error("got error while iterating", error); + console.trace(error); + await this.update(headers.jobId, "ERROR", { + error: "Something went wrong while pushing data to plane, ERROR:" + error, + }); + // Inditate that the task has been errored, don't need to requeue, the task + // will be requeued manually + return true; + } + } + + pushToQueue = async (headers: TaskHeaders, data: any) => { + if (!this.mq) return; + try { + // Message should contain jobId, taskName and the task + await this.mq.sendMessage(data, { + headers, + }); + } catch (error) { + console.error(error); + throw new Error("Error pushing to job worker queue"); + } + }; + + update = async (jobId: string, stage: UpdateEventType, data: any): Promise => { + const job = await getJobForMigration(jobId); + + switch (stage) { + case "PULLED": + if (data.total_batch_count) { + await updateJob(jobId, { + total_batch_count: data.total_batch_count, + completed_batch_count: 0, + status: "PULLED", + }); + } + break; + + case "TRANSFORMED": + if (job.transformed_batch_count != null && job.total_batch_count != null) { + if (job.transformed_batch_count + 1 === job.total_batch_count) { + await updateJob(jobId, { + status: "PUSHING", + transformed_batch_count: job.transformed_batch_count + 1, + }); + } else { + await updateJob(jobId, { + transformed_batch_count: job.transformed_batch_count + 1, + }); + } + } + break; + + case "PUSHING": + if (job.transformed_batch_count === job.total_batch_count) { + await updateJob(jobId, { + status: stage, + }); + } + break; + + case "FINISHED": + if (job.completed_batch_count != null && job.total_batch_count != null) { + if (job.completed_batch_count + 1 === job.total_batch_count) { + await updateJob(jobId, { + status: "FINISHED", + end_time: new Date(), + completed_batch_count: job.completed_batch_count + 1, + }); + } else { + await updateJob(jobId, { + completed_batch_count: job.completed_batch_count + 1, + }); + } + } + break; + + case "ERROR": + await updateJob(jobId, { + status: stage, + error: data.error, + }); + break; + + default: + await updateJob(jobId, { + status: stage as any as TSyncJobStatus, + }); + break; + } + }; +} diff --git a/silo/src/etl/index.ts b/silo/src/etl/index.ts new file mode 100644 index 0000000000..9943c68a2c --- /dev/null +++ b/silo/src/etl/index.ts @@ -0,0 +1 @@ +export * from "./migrator" diff --git a/silo/src/etl/migrator/cycles.migrator.ts b/silo/src/etl/migrator/cycles.migrator.ts new file mode 100644 index 0000000000..33894784c6 --- /dev/null +++ b/silo/src/etl/migrator/cycles.migrator.ts @@ -0,0 +1,84 @@ +import { AssertAPIErrorResponse, protect } from "@/lib"; +import { logger } from "@/logger"; +import { ExCycle, ExIssue, Client as PlaneClient } from "@plane/sdk"; +import { getJobData } from "@/apps/jira-importer/helpers/migration-helpers"; + +/* ------------------------------ Cycles Creation ---------------------------- */ +export const createCycles = async ( + jobId: string, + cycles: ExCycle[], + allIssues: ExIssue[], + planeClient: PlaneClient, + workspaceSlug: string, + projectId: string +): Promise => { + const job = await getJobData(jobId); + + for (const cycle of cycles) { + // Create the cycle and get the cycle id + let cycleId = ""; + try { + const createdCycle: any = await protect( + planeClient.cycles.create.bind(planeClient.cycles), + workspaceSlug, + projectId, + cycle + ); + cycleId = createdCycle.id; + } catch (error) { + if (AssertAPIErrorResponse(error)) { + if (error.error && error.error.includes("already exists")) { + logger.info(`[${jobId.slice(0, 7)}] Cycle "${cycle.name}" already exists. Skipping...`); + cycleId = error.id; + } + } else { + logger.error(`[${jobId.slice(0, 7)}] Error while creating the cycle: ${cycle.name}`, error); + } + } + + // Create the cycle issues + if (cycle.issues.length > 0 && cycleId != "") { + // Get all the plane issue ids for the corrensponding cycle issues external ids + const cycleIssueIds = await Promise.all( + cycle.issues + .map(async (issue) => { + const planeIssue = allIssues.find((planeIssue) => planeIssue.external_id === issue); + if (planeIssue) { + return planeIssue.id; + } else { + // fetch the issue from plane if the issue is not found in the allIssues + try { + const fetchedIssue: any = await protect( + planeClient.issue.getIssueWithExternalId.bind(planeClient.issue), + workspaceSlug, + projectId, + issue, + // Dynamically take the source from the config + job.migration_type + ); + return fetchedIssue?.id; + } catch (error) { + logger.error(`[${jobId.slice(0, 7)}] Error while fetching the issue for the cycle: ${issue}`, error); + return undefined; + } + } + }) + .filter((issue) => issue !== undefined) + ); + + if (cycleIssueIds.length > 0) { + try { + await protect( + planeClient.cycles.addIssues.bind(planeClient.cycles), + workspaceSlug, + projectId, + cycleId, + cycleIssueIds.filter((issue: string | undefined) => issue !== undefined) as string[] + ); + } catch (error) { + logger.error(`[${jobId.slice(0, 7)}] Error while adding issues to the cycle: ${cycle.name}`, error); + } + } + } + } +}; diff --git a/silo/src/etl/migrator/helpers.ts b/silo/src/etl/migrator/helpers.ts new file mode 100644 index 0000000000..6a760b2854 --- /dev/null +++ b/silo/src/etl/migrator/helpers.ts @@ -0,0 +1,45 @@ +import { getCredentialsByWorkspaceId, getJobById } from "@/db/query"; +import { TSyncServiceCredentials, TSyncJobWithConfig } from "@silo/core"; + +export const getJobForMigration = async (jobId: string): Promise => { + const jobs = await getJobById(jobId); + if (!jobs || jobs.length === 0) { + throw new Error(`[${jobId.slice(0, 7)}] No job found for the given job id. Exiting...`); + } + + return jobs[0] as TSyncJobWithConfig; +}; + +export const validateJobForMigration = (job: TSyncJobWithConfig) => { + if (!job.workspace_id || !job.migration_type) { + throw new Error(`[${job.id}] No workspace id found in the job data. Exiting...`); + } + + if (!job.initiator_id) { + throw new Error(`[${job.id}] No initiator id found in the job data. Exiting...`); + } + + if (!job.target_hostname || !job.workspace_id || !job.workspace_slug || !job.project_id) { + throw new Error(`[${job.id}] Missing required fields in the job data. Exiting... + Target Hostname: ${job.target_hostname}, + Workspace ID: ${job.workspace_id}, + Workspace Slug: ${job.workspace_slug}, + Project ID: ${job.project_id} + `); + } +}; + +export const getCredentialsForMigration = async (job: TSyncJobWithConfig): Promise => { + // Fetch credentials from the database + const credentials = await getCredentialsByWorkspaceId(job.workspace_id, job.initiator_id, job.migration_type); + if (!credentials || credentials.length === 0) { + throw new Error(`[${job.id}] No credentials found for the workspace id in the job data. Exiting...`); + } + + const targetCredentials = credentials[0]; + if (!targetCredentials.source_access_token) { + throw new Error(`[${job.id}] No source access token or refresh token found in the credentials. Exiting...`); + } + + return credentials[0] as TSyncServiceCredentials; +}; diff --git a/silo/src/etl/migrator/index.ts b/silo/src/etl/migrator/index.ts new file mode 100644 index 0000000000..fbf8113882 --- /dev/null +++ b/silo/src/etl/migrator/index.ts @@ -0,0 +1,2 @@ +export * from "./migrator" +export * from "./helpers" diff --git a/silo/src/etl/migrator/issues.migrator.ts b/silo/src/etl/migrator/issues.migrator.ts new file mode 100644 index 0000000000..c020c7a8b2 --- /dev/null +++ b/silo/src/etl/migrator/issues.migrator.ts @@ -0,0 +1,353 @@ +/* ----------------------------- Issue Creation Utilities ----------------------------- */ +import { ExIssue, ExIssueComment, ExIssueLabel, Client as PlaneClient } from "@plane/sdk"; +import { wait } from "@/helpers/delay"; +import { downloadFile, removeSpanAroundImg, splitStringTillPart } from "@/helpers/utils"; +import { AssertAPIErrorResponse, protect } from "@/lib"; +import { logger } from "@/logger"; +import { IssueCreatePayload, IssueWithParentPayload } from "./types"; + +// A wrapper for better readability +export const createOrphanIssues = async (payload: IssueCreatePayload): Promise => { + return await createIssues(payload); +}; + +// Attaches parent to the issues and creates them +export const createIssuesWithParent = async (payload: IssueWithParentPayload): Promise => { + const { jobId, meta, planeLabels, planeClient, workspaceSlug, projectId, issuesWithParent, createdOrphanIssues } = + payload; + let issueProcessIndex = payload.issueProcessIndex; + let result: ExIssue[] = []; + + for (const issue of issuesWithParent) { + if (issue.parent) { + const parentIssue = createdOrphanIssues.find((orphanIssue: ExIssue) => orphanIssue.external_id === issue.parent); + if (parentIssue) { + issue.parent = parentIssue.id; + } else { + // If the parent issue is not found, then try to find the issue from + // external id and source from the api + try { + const parent = (await protect( + planeClient.issue.getIssueWithExternalId.bind(planeClient.issue), + workspaceSlug, + projectId, + issue.parent, + issue.external_source + )) as ExIssue; + issue.parent = parent.id; + } catch (error) { + logger.error("Error while fetching the parent issue", error); + // Even if we're getting error while fetching the issue, we need to + // skip attaching the parent from the issue + issue.parent = null; + } + } + } + } + + try { + result = await createIssues({ + jobId, + meta, + planeLabels, + issueProcessIndex, + planeClient, + workspaceSlug, + projectId, + users: payload.users, + issues: issuesWithParent, + sourceAccessToken: payload.sourceAccessToken, + }); + } catch (error) { + console.error("Error while creating issue at root", error); + } + + return result; +}; + +export const createIssues = async (payload: IssueCreatePayload): Promise => { + const { jobId, meta, planeLabels, issues, planeClient, workspaceSlug, projectId } = payload; + let issueProcessIndex = payload.issueProcessIndex; + + let result = []; + + const issueWaitTime = Number(process.env.REQUEST_INTERVAL) || 400; + + for (const issue of issues) { + try { + // If there are labels, then get the label ids from the created labels + issue.labels = getPlaneIssueLabels(issue, planeLabels); + issue.created_by = getIssueCreatedBy(issue, payload.users) ?? ""; + issue.assignees = getIssueAssignees(issue, payload.users); + + // Create the issue and issue assets + const createdIssue = await protect( + createOrUpdateIssue, + issue, + meta, + issueProcessIndex, + planeClient, + workspaceSlug, + projectId + ); + await createIssueLinks(jobId, issue, createdIssue.id, planeClient, workspaceSlug, projectId); + await createIssueAttachments( + jobId, + payload.sourceAccessToken, + createdIssue.id, + issue, + planeClient, + workspaceSlug, + projectId + ); + + result.push(createdIssue); + issueProcessIndex++; + + await wait(issueWaitTime); + } catch (error) { + console.log("Error occured inside `CreateIssues`", error); + // Check if the error is an API error response + logger.error(`[${jobId.slice(0, 7)}] Error while creating the issue: ${issue.external_id}`, error); + } + } + + return result; +}; + +export const createOrUpdateIssue = async ( + issue: ExIssue, + meta: any, + issueProcessIndex: number, + planeClient: PlaneClient, + workspaceSlug: string, + projectId: string +): Promise => { + let createdIssue: ExIssue | undefined = undefined; + + try { + createdIssue = await protect(planeClient.issue.create.bind(planeClient.issue), workspaceSlug, projectId, issue); + logger.info( + `[${issue.external_source}][${issue.external_id.slice(0, 5)}...][${meta.batchId}] Created Issue: ${issue.external_id.slice(0, 7)} ----------- [${issueProcessIndex} / ${meta.batch_end}][${meta.total.issues}]` + ); + return createdIssue as ExIssue; + } catch (error) { + if (AssertAPIErrorResponse(error)) { + // Update the issue if the issue already exist + if (error.error.includes("already exists")) { + createdIssue = await protect( + planeClient.issue.update.bind(planeClient.issue), + workspaceSlug, + projectId, + error.id, + issue + ); + logger.info( + `[${issue.external_source}][${issue.external_id.slice(0, 5)}...][${meta.batchId}] Updated Issue: ${issue.external_id.slice(0, 7)} ----------- [${issueProcessIndex} / ${meta.batch_end}][${meta.total.issues}]` + ); + } + } else { + throw error; + } + } + + // I am aware that the issue will either be updated or created + return createdIssue as ExIssue; +}; + +/* ------------------------------ Issue Comment Creation ---------------------------- */ +export const createOrUpdateIssueComment = async ( + _jobId: string, + issueComment: ExIssueComment, + planeClient: PlaneClient, + workspaceSlug: string, + projectId: string, + issueId: string +): Promise => { + // Comment array is used to store the comments that failed to create + try { + await protect( + planeClient.issueComment.create.bind(planeClient.issueComment), + workspaceSlug, + projectId, + issueId, + issueComment + ); + } catch (error) { + if (AssertAPIErrorResponse(error)) { + // Update the comment if the comment already exist + if (error.error.includes("already exists")) { + try { + await protect( + planeClient.issueComment.update.bind(planeClient.issueComment), + workspaceSlug, + projectId, + issueId, + error.id, + issueComment + ); + } catch (error) { + console.log("Error while updating comment", error); + } + } + } else { + console.log("Error while creating comment, other than already exist", error); + } + } +}; + +const createIssueAttachments = async ( + jobId: string, + sourceAccessToken: string, + createdIssueId: string, + issue: ExIssue, + planeClient: PlaneClient, + workspaceSlug: string, + projectId: string +) => { + if (issue.attachments && issue.attachments.length > 0) { + const attachmentPromises = issue.attachments.map(async (attachment) => { + try { + const existingAttachments: any = await protect( + planeClient.issue.getIssueAttachments.bind(planeClient.issue), + workspaceSlug, + projectId, + createdIssueId + ); + try { + // Get the existing attachment for the issue + // Create a temp file to download the attachment + const blob = await downloadFile(attachment.asset, sourceAccessToken); + // Prepare FormData + const attachmentFormData = new FormData(); + attachmentFormData.append("asset", blob, attachment.attributes.name); + attachmentFormData.append("external_id", attachment.external_id); + attachmentFormData.append("external_source", attachment.external_source); + attachmentFormData.append( + "attributes", + JSON.stringify({ + name: attachment.attributes.name, + size: blob.size, + }) + ); + + // Upload the attachment + const response: any = await protect( + planeClient.issue.uploadIssueAttachment.bind(planeClient.issue), + workspaceSlug, + projectId, + createdIssueId, + attachmentFormData + ); + + // Update the description html if the attachment is uploaded successfully + if (response.asset) { + const attachmentUrl = response.asset; + const url = new URL(attachment.asset); + let sourceAttachmentPathname = ""; + if (url.pathname.includes("rest")) { + sourceAttachmentPathname = splitStringTillPart(new URL(attachment.asset).pathname, "rest"); + } else { + sourceAttachmentPathname = attachment.asset; + } + issue.description_html = removeSpanAroundImg( + issue.description_html.replace(sourceAttachmentPathname, attachmentUrl) + ); + } + } catch (error) { + if (AssertAPIErrorResponse(error)) { + if (error.error && error.error.includes("already exists")) { + logger.info( + `[${jobId.slice(0, 7)}] Attachment already exists for the issue "${issue.name}". Skipping...` + ); + // get the attachment from the existing attachments + existingAttachments.find((existingAttachment: any) => { + if (existingAttachment.external_id === attachment.external_id) { + const attachmentUrl = existingAttachment.asset; + const url = new URL(attachment.asset); + let sourceAttachmentPathname = ""; + if (url.pathname.includes("rest")) { + sourceAttachmentPathname = splitStringTillPart(new URL(attachment.asset).pathname, "rest"); + } else { + sourceAttachmentPathname = attachment.asset; + } + issue.description_html = removeSpanAroundImg( + issue.description_html.replace(sourceAttachmentPathname, attachmentUrl) + ); + } + }); + } + } else { + logger.error(`[${jobId.slice(5)}] Error while creating the attachment: ${issue.name}`, error); + } + } + } catch (error) { + console.log("Something went wrong while creating the attachment", error); + logger.error(`[${jobId.slice(0, 7)}] Error while fetching the attachments for the issue: ${issue.name}`, error); + } + }); + + await Promise.all(attachmentPromises); + // Finally update the issue with the updated description html + await protect(planeClient.issue.update.bind(planeClient.issue), workspaceSlug, projectId, createdIssueId, issue); + } +}; + +const createIssueLinks = async ( + jobId: string, + issue: ExIssue, + createdIssueId: string, + planeClient: PlaneClient, + workspaceSlug: string, + projectId: string +) => { + if (issue.links) { + try { + const linkPromises = issue.links.map(async (link) => { + await protect( + planeClient.issue.createLink.bind(planeClient.issue), + workspaceSlug, + projectId, + createdIssueId, + link.name, + link.url + ); + }); + await Promise.all(linkPromises); + } catch (error) { + // @ts-ignore + if (error.error && !error.error.includes("already exists")) { + logger.error(`[${jobId.slice(0, 7)}] Error while creating the link for the issue: ${issue.external_id}`, error); + } + } + } +}; + +/* ------------------------------ Helper Methods ---------------------------- */ +const getPlaneIssueLabels = (issue: ExIssue, planeLabels: ExIssueLabel[]): string[] => { + if (issue.labels) { + return issue.labels + .map((label) => { + const createdLabel = planeLabels.find((createdLabel) => createdLabel.name === label); + if (createdLabel) { + return createdLabel.id; + } + }) + .filter((label) => label !== undefined) as string[]; + } + return []; +}; + +const getIssueCreatedBy = (issue: ExIssue, users: any[]): string | undefined => { + return users.find((user) => user.display_name === issue.created_by)?.id; +}; + +const getIssueAssignees = (issue: ExIssue, users: any[]): string[] => { + const assignedUsers = issue.assignees.map((assignee) => { + const assignedTo = users.find((user) => user.display_name === assignee)?.id; + if (assignedTo) { + return assignedTo; + } + }); + return assignedUsers.filter((user) => user !== undefined) as string[]; +}; diff --git a/silo/src/etl/migrator/labels.migrator.ts b/silo/src/etl/migrator/labels.migrator.ts new file mode 100644 index 0000000000..914b61e0c7 --- /dev/null +++ b/silo/src/etl/migrator/labels.migrator.ts @@ -0,0 +1,34 @@ +import { ExIssueLabel, Client as PlaneClient } from "@plane/sdk"; +import { protect } from "@/lib"; +import { logger } from "@/logger"; + +/* ----------------------------- Label Creation Utilities ----------------------------- */ +export const createLabelsForIssues = async ( + jobId: string, + labels: ExIssueLabel[], + planeClient: PlaneClient, + workspaceSlug: string, + projectId: string +): Promise => { + // TODO: May hit race conditions + let createdLabels: ExIssueLabel[] = []; + + const labelPromises = labels.map(async (label) => { + try { + const createdLabel: any = await protect( + planeClient.label.create.bind(planeClient.label), + workspaceSlug, + projectId, + label + ); + if (createdLabel) { + createdLabels.push(createdLabel); + } + } catch (error) { + logger.error(`[${jobId.slice(0, 7)}] Error while creating the label: ${label.name}`, error); + } + }); + + await Promise.all(labelPromises); + return createdLabels; +}; diff --git a/silo/src/etl/migrator/migrator.ts b/silo/src/etl/migrator/migrator.ts new file mode 100644 index 0000000000..37d31f56dc --- /dev/null +++ b/silo/src/etl/migrator/migrator.ts @@ -0,0 +1,218 @@ +import { + ExCycle, + ExIssue, + ExIssueComment, + ExIssueLabel, + ExModule, + ExState, + Client as PlaneClient, + PlaneEntities, + PlaneUser, +} from "@plane/sdk"; +import { protect } from "@/lib"; +import { logger } from "@/logger"; +import { TSyncJobWithConfig } from "@silo/core"; +import { createCycles } from "./cycles.migrator"; +import { getCredentialsForMigration, validateJobForMigration } from "./helpers"; +import { createIssuesWithParent, createOrphanIssues, createOrUpdateIssueComment } from "./issues.migrator"; +import { createLabelsForIssues } from "./labels.migrator"; +import { createModules } from "./modules.migrator"; +import { createStates } from "./states.migrator"; +import { createUsers } from "./users.migrator"; + +export async function migrateToPlane(job: TSyncJobWithConfig, data: PlaneEntities[], meta: any) { + validateJobForMigration(job); + const credentials = await getCredentialsForMigration(job); + + const planeClient = new PlaneClient({ + baseURL: job.target_hostname, + apiToken: credentials.target_access_token, + }); + + const [planeEntities] = data; + + if (!job.config) { + throw new Error(`[${job.id}] No config found in the job data. Exiting...`); + } + + let planeStates: { target_state: ExState; source_state: any }[] = (job.config?.meta as any).state; + + try { + const metaJobData = job.config?.meta as { + state: { target_state: ExState; source_state: any }[]; + }; + + const statesToCreate = metaJobData.state.filter((state) => state.target_state.status === "to_be_created"); + + const createdStates = await createStates(job.id, statesToCreate, planeClient, job.workspace_slug, job.project_id); + + // create a map for quick lookup of created states by source state id + const createdStatesMap = new Map(createdStates.map((createdState) => [createdState.source_state.id, createdState])); + + // replace the to_be_created states with actually created states + planeStates = metaJobData.state.map((state: any) => { + if (state.target_state.status === "to_be_created") { + return createdStatesMap.get(state.source_state.id) || state; + } + return state; + }); + } catch (error) { + throw new Error( + `[${job.id}] Error while creating the states in the Plane API, which needs to be available to continue the migration` + ); + } + + const { labels, issues: issuesBefore, users, issue_comments, cycles, modules } = planeEntities; + + // Update the state of the issues with all the states created in the previous step + const issues = issuesBefore.map((issue) => { + // we have the states as "" by default if the states aren't created yet in + // Plane, so we need to update the state of the issue with the actual state + // after creating the states above + if (issue.state === "") { + // put the newly created Plane state's id in the issue + issue.state = planeStates.find((state) => { + return state.source_state.id === issue.external_source_state_id; + })?.target_state.id; + } + return issue; + }); + + // Create the users required for the workspace + let planeLabels: ExIssueLabel[] = []; + let planeUsers: PlaneUser[] = []; + + let shouldContinue = true; + + // Get the labels and issues from the Plane API + try { + const response: any = await protect( + planeClient.label.list.bind(planeClient.label), + job.workspace_slug, + job.project_id + ); + planeLabels = response.results; + planeUsers = await protect(planeClient.users.list.bind(planeClient.users), job.workspace_slug, job.project_id); + shouldContinue = false; + } catch (error) { + logger.error( + `[${job.id}] Error while fetching the labels and users from the Plane API, which needs to be available to continue the migration`, + error + ); + throw new Error( + "Error while fetching the labels and users from the Plane API, which needs to be available to continue the migration" + ); + } + + // Update display name for plane existing users, for processing + /* + * Say an existing user in plane has the same user email as the user in the source system, but the display name is different. + * In that case, we need to update the display name of the user in the plane to match the display name of the user in the source system. + * As the importer will use the display name from the source system. + */ + for (const user of users) { + const planeUser = planeUsers.find((planeUser) => planeUser.email === user.email); + if (planeUser && user.display_name) { + planeUser.display_name = user.display_name; + } + } + + /* ------------------- Append Labels and Users -------------------- */ + const usersToAppend = users.filter((user) => !planeUsers.find((planeUser) => planeUser.email === user.email)); + planeUsers.push( + ...(await createUsers(job.id, usersToAppend as PlaneUser[], planeClient, job.workspace_slug, job.project_id)) + ); + const labelsToAppend = labels.filter((label) => !planeLabels.find((planeLabel) => planeLabel.name === label.name)); + planeLabels.push( + ...(await createLabelsForIssues( + job.id, + labelsToAppend as ExIssueLabel[], + planeClient, + job.workspace_slug, + job.project_id + )) + ); + + const orphanIssues = issues.filter((issue) => issue.parent === undefined); + const issuesWithParent = issues.filter((issue) => issue.parent !== undefined); + + /* ------------------- Start Creating the Issues -------------------- */ + // Batch Start Index + let issueProcessIndex = meta.batch_start; + + // Create orphan issues, i.e. issues without a parent + const createdOrphanIssues = await createOrphanIssues({ + jobId: job.id, + meta, + planeLabels, + issueProcessIndex, + planeClient, + workspaceSlug: job.workspace_slug, + projectId: job.project_id, + users: planeUsers, + issues: orphanIssues as ExIssue[], + sourceAccessToken: credentials.source_access_token as string, + }); + + // Create issues with parent present + const createdIssuesWithParent = await createIssuesWithParent({ + jobId: job.id, + meta, + planeLabels, + issueProcessIndex, + planeClient, + workspaceSlug: job.workspace_slug, + projectId: job.project_id, + users: planeUsers, + issuesWithParent: issuesWithParent as ExIssue[], + createdOrphanIssues, + sourceAccessToken: credentials.source_access_token as string, + }); + + const allIssues = [...createdOrphanIssues, ...createdIssuesWithParent]; + + // Create comments for the issues migrated + const commentPromises = issue_comments.map(async (comment) => { + const issue = allIssues.find((issue) => issue.external_id === comment.issue); + const actor = planeUsers.find((user) => user.display_name === comment.actor); + const createdBy = planeUsers.find((user) => user.display_name === comment.created_by); + + if (issue) { + comment.issue = issue.id; + if (actor && createdBy) { + comment.actor = actor.id; + comment.created_by = createdBy.id; + } else { + delete comment.actor; + delete comment.created_by; + } + return createOrUpdateIssueComment( + job.id, + comment as ExIssueComment, + planeClient, + job.workspace_slug, + job.project_id, + issue.id + ); + } + }); + + const cyclesPromise = createCycles( + job.id, + cycles as ExCycle[], + allIssues, + planeClient, + job.workspace_slug, + job.project_id + ); + const modulesPromise = createModules( + job.id, + modules as ExModule[], + allIssues, + planeClient, + job.workspace_slug, + job.project_id + ); + + await Promise.all([...commentPromises, cyclesPromise, modulesPromise]); +} diff --git a/silo/src/etl/migrator/modules.migrator.ts b/silo/src/etl/migrator/modules.migrator.ts new file mode 100644 index 0000000000..df5aa1634c --- /dev/null +++ b/silo/src/etl/migrator/modules.migrator.ts @@ -0,0 +1,85 @@ +import { ExIssue, ExModule, Client as PlaneClient } from "@plane/sdk"; +import { getJobData } from "@/apps/jira-importer/helpers/migration-helpers"; +import { AssertAPIErrorResponse, protect } from "@/lib"; +import { logger } from "@/logger"; + +export const createModules = async ( + jobId: string, + modules: ExModule[], + allIssues: ExIssue[], + planeClient: PlaneClient, + workspaceSlug: string, + projectId: string +): Promise => { + const job = await getJobData(jobId); + + for (const module of modules) { + // Create the cycle and get the cycle id + let moduleId = ""; + /* TODO: User may have changed the module name, in that case taking this + * name won't be appropriate */ + let moduleName = module.name; + try { + const createdModule: any = await protect( + planeClient.modules.create.bind(planeClient.modules), + workspaceSlug, + projectId, + module + ); + moduleId = createdModule.id; + } catch (error) { + if (AssertAPIErrorResponse(error)) { + if (error.error && error.error.includes("already exists")) { + logger.info(`[${jobId.slice(0, 7)}] Module "${module.name}" already exists. Skipping...`); + // Get the id from the module + moduleId = error.id; + } + } else { + logger.error(`[${jobId.slice(0, 7)}] Error while creating the module: ${module.name}`, error); + } + } + + if (module.issues.length > 0 && moduleId != "") { + const moduleIssueIds = await Promise.all( + module.issues + .map(async (issue) => { + const planeIssue = allIssues.find((planeIssue) => planeIssue.external_id === issue); + if (planeIssue) { + return planeIssue.id; + } else { + try { + const fetchedIssue: any = await protect( + planeClient.issue.getIssueWithExternalId.bind(planeClient.issue), + workspaceSlug, + projectId, + issue, + job.migration_type + ); + return fetchedIssue?.id; + } catch (e) { + console.log("Error while fetching issue for module"); + console.log(e); + } + // fetch the issue from plane if the issue is not found in the allIssues + } + }) + .filter((issue) => issue !== undefined) + ); + // If there is any match for the issue ids then add the issues to the module + if (moduleIssueIds.length > 0) { + try { + await protect( + planeClient.modules.addIssues.bind(planeClient.modules), + workspaceSlug, + projectId, + moduleId, + moduleName, + moduleIssueIds as string[] + ); + } catch (error) { + logger.error(`[${jobId.slice(0, 7)}] Error while adding issues to the module: ${module.name}`, error); + } + } + } + } +}; diff --git a/silo/src/etl/migrator/states.migrator.ts b/silo/src/etl/migrator/states.migrator.ts new file mode 100644 index 0000000000..b39c1e24de --- /dev/null +++ b/silo/src/etl/migrator/states.migrator.ts @@ -0,0 +1,42 @@ +import { ExState, Client as PlaneClient } from "@plane/sdk"; +import { protect } from "@/lib"; +import { logger } from "@/logger"; + +/* ----------------------------- State Creation Utilities ----------------------------- */ +export const createStates = async ( + jobId: string, + states: { target_state: ExState; source_state: any }[], + planeClient: PlaneClient, + workspaceSlug: string, + projectId: string +) => { + let createdStates: { source_state: any; target_state: ExState }[] = []; + + const statePromises = states.map(async (state) => { + try { + const strippedPlaneState = { + name: state.target_state.name, + group: state.target_state.group, + color: state.target_state.color, + }; + const createdState: any = await protect( + planeClient.state.create.bind(planeClient.state), + workspaceSlug, + projectId, + strippedPlaneState + ); + + if (createdState) { + createdStates.push({ + source_state: state.source_state, + target_state: createdState, + }); + } + } catch (error) { + logger.error(`[${jobId.slice(0, 7)}] Error while creating the state: ${state.target_state.name}`, error); + } + }); + + await Promise.all(statePromises); + return createdStates; +}; diff --git a/silo/src/etl/migrator/types.ts b/silo/src/etl/migrator/types.ts new file mode 100644 index 0000000000..fdb1f416f4 --- /dev/null +++ b/silo/src/etl/migrator/types.ts @@ -0,0 +1,23 @@ +import { ExIssueLabel, PlaneUser, ExIssue } from "@plane/sdk" +import { Client as PlaneClient } from "@plane/sdk" + +export type IssuePayload = { + jobId: string + meta: any + planeLabels: ExIssueLabel[] + issueProcessIndex: number + planeClient: PlaneClient + workspaceSlug: string + projectId: string + users: PlaneUser[] + sourceAccessToken: string +} + +export type IssueCreatePayload = IssuePayload & { + issues: ExIssue[] +} + +export type IssueWithParentPayload = IssuePayload & { + issuesWithParent: ExIssue[] + createdOrphanIssues: ExIssue[] +} diff --git a/silo/src/etl/migrator/users.migrator.ts b/silo/src/etl/migrator/users.migrator.ts new file mode 100644 index 0000000000..6df86ab9a0 --- /dev/null +++ b/silo/src/etl/migrator/users.migrator.ts @@ -0,0 +1,50 @@ +import { Client as PlaneClient, PlaneUser, UserResponsePayload } from "@plane/sdk"; +import { protect } from "@/lib"; +import { logger } from "@/logger"; +import { AxiosError } from "axios"; + +/* ----------------------------- User Creation Utilities ----------------------------- */ +export const createUsers = async ( + jobId: string, + users: PlaneUser[], + planeClient: PlaneClient, + workspaceSlug: string, + projectId: string +): Promise => { + const createdUsers: UserResponsePayload[] = []; + const createUserPromises = users.map(async (user) => { + try { + const createdUser: any = await protect( + planeClient.users.create.bind(planeClient.users), + workspaceSlug, + projectId, + { + // The display name of the user is assumed to be the equivalent of the + // source username, as it will be used to identify the user in the workspace + display_name: user.display_name ?? "", + email: user.email ?? "", + first_name: user.first_name ?? "", + last_name: user.last_name ?? "", + role: user.role ?? 10, + } + ); + + // Append the created user to the planeUsers + createdUsers.push(createdUser); + } catch (error) { + // User already exists, and we can move ahead with creating other users + if ( + error instanceof AxiosError && + (error.response?.status !== 400 || !error.response.data.error.includes("already exists")) + ) { + logger.error(`[${jobId.slice(0, 7)}] Error while creating the user: ${user.display_name}`, error); + } + + console.log("Error while creating the user: ", user.display_name); + console.log(error); + } + }); + + await Promise.all(createUserPromises); + return createdUsers; +}; diff --git a/silo/src/helpers/delay.ts b/silo/src/helpers/delay.ts new file mode 100644 index 0000000000..654d0b3976 --- /dev/null +++ b/silo/src/helpers/delay.ts @@ -0,0 +1,3 @@ +export async function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} diff --git a/silo/src/helpers/generic-helpers.ts b/silo/src/helpers/generic-helpers.ts new file mode 100644 index 0000000000..076280154e --- /dev/null +++ b/silo/src/helpers/generic-helpers.ts @@ -0,0 +1,45 @@ +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 getFormattedDate = (date: string | 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 + } +} + +export const getRandomColor = () => { + return "#" + Math.floor(Math.random() * 16777215).toString(16) +} diff --git a/silo/src/helpers/utils.ts b/silo/src/helpers/utils.ts new file mode 100644 index 0000000000..af31cd64b9 --- /dev/null +++ b/silo/src/helpers/utils.ts @@ -0,0 +1,62 @@ +import { parse, HTMLElement } from 'node-html-parser'; +import axios from 'axios'; + +export const removeSpanAroundImg = (htmlContent: string): string => { + // Parse the HTML content + const root = parse(htmlContent); + + // Find all tags + const imgTags = root.querySelectorAll('img'); + + imgTags.forEach(img => { + const parent = img.parentNode as HTMLElement; + + // Check if the parent is a tag + if (parent && parent.tagName === 'SPAN') { + // Replace the tag with its children (including the tag) + parent.replaceWith(...parent.childNodes); + } + }); + + // Serialize the modified HTML back to a string + return root.toString(); +} + +export const splitStringTillPart = (input: string, part: string): string => { + // Split the string by '/' + const parts = input.split('/'); + + // Find the index of the part + const index = parts.indexOf(part); + + // If the part is not found, return an empty string or handle the error as needed + if (index === -1) { + return ''; + } + + // Join the parts from the desired index to the end + const result = parts.slice(index).join('/'); + + // Add the leading '/' if needed + return '/' + result; +} + +export const downloadFile = async (url: string, token: string): Promise => { + try { + const response = await axios({ + url, + method: 'GET', + responseType: 'arraybuffer', + headers: { + Authorization: `Bearer ${token}`, + } + }); + + /* TODO: handle error */ + const buffer = Buffer.from(response.data); + const blob = new Blob([buffer], { type: response.headers['content-type'] }); + return blob; + } catch (e) { + throw e + } +} diff --git a/silo/src/lib/controller.ts b/silo/src/lib/controller.ts new file mode 100644 index 0000000000..87e0fda3c4 --- /dev/null +++ b/silo/src/lib/controller.ts @@ -0,0 +1,29 @@ +import { Application, RequestHandler } from "express" + +type HttpMethod = "get" | "post" | "put" | "delete" | "patch" | "options" | "head" + +export function registerControllers(app: Application, Controller: any) { + const instance = new Controller() + const baseRoute = Reflect.getMetadata("baseRoute", Controller) as string + + Object.getOwnPropertyNames(Controller.prototype).forEach((methodName) => { + if (methodName === "constructor") return // Skip the constructor + + const method = Reflect.getMetadata("method", instance, methodName) as HttpMethod + const route = Reflect.getMetadata("route", instance, methodName) as string + const middlewares = + (Reflect.getMetadata("middlewares", instance, methodName) as RequestHandler[]) || [] + + if (method && route) { + const handler = instance[methodName as keyof typeof instance] as unknown + + if (typeof handler === "function") { + ;(app[method] as (path: string, ...handlers: RequestHandler[]) => void)( + `/silo/api${baseRoute}${route}`, + ...middlewares, + handler.bind(instance) + ) + } + } + }) +} diff --git a/silo/src/lib/decorators.ts b/silo/src/lib/decorators.ts new file mode 100644 index 0000000000..0001db60b8 --- /dev/null +++ b/silo/src/lib/decorators.ts @@ -0,0 +1,85 @@ +import "reflect-metadata" +import { RequestHandler } from "express" + +/** + * Controller decorator + * @param baseRoute + * @returns + */ +export function Controller(baseRoute: string = ""): ClassDecorator { + return function (target: Function) { + Reflect.defineMetadata("baseRoute", baseRoute, target) + } +} + +/** + * Controller GET method decorator + * @param baseRoute + * @returns + */ +export function Get(route: string): any { + return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { + Reflect.defineMetadata("method", "get", target, propertyKey) + Reflect.defineMetadata("route", route, target, propertyKey) + } +} +/** + * Controller POST method decorator + * @param baseRoute + * @returns + */ +export function Post(route: string): any { + return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { + Reflect.defineMetadata("method", "post", target, propertyKey) + Reflect.defineMetadata("route", route, target, propertyKey) + } +} + +/** + * Controller PATCH method decorator + * @param baseRoute + * @returns + */ +export function Patch(route: string): any { + return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { + Reflect.defineMetadata("method", "patch", target, propertyKey) + Reflect.defineMetadata("route", route, target, propertyKey) + } +} + +/** + * Controller PUT method decorator + * @param baseRoute + * @returns + */ +export function Put(route: string): any { + return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { + Reflect.defineMetadata("method", "put", target, propertyKey) + Reflect.defineMetadata("route", route, target, propertyKey) + } +} + +/** + * Controller DELETE method decorator + * @param baseRoute + * @returns + */ +export function Delete(route: string): any { + return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { + Reflect.defineMetadata("method", "delete", target, propertyKey) + Reflect.defineMetadata("route", route, target, propertyKey) + } +} + +/** + * Middle method decorator + * @param baseRoute + * @returns + */ +export function Middleware(middleware: RequestHandler): any { + return function (target: Object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { + const middlewares = Reflect.getMetadata("middlewares", target, propertyKey) || [] + middlewares.push(middleware) + Reflect.defineMetadata("middlewares", middlewares, target, propertyKey) + } +} diff --git a/silo/src/lib/errors.ts b/silo/src/lib/errors.ts new file mode 100644 index 0000000000..db18f89021 --- /dev/null +++ b/silo/src/lib/errors.ts @@ -0,0 +1,50 @@ +import { wait } from "@/helpers/delay"; +import { AxiosError } from "axios"; + +export type APIRatelimitResponse = { + error_code: number; + error_message: string; +}; + +export type APIErrorResponse = { + id: string; + error: string; +}; + +export function AssertAPIRateLimitResponse(error: any): error is APIRatelimitResponse { + return error.error_code && error.error_message; +} + +export function AssertAPIErrorResponse(error: any): error is APIErrorResponse { + return error.id && error.error; +} + +/* Protect Call is an error handling wrapper, that takes care of common errors */ +export async function protect(fn: (...args: any[]) => Promise, ...args: any[]): Promise { + const MAX_RETRIES = 5; + const RETRY_DELAY = 60000; // 60 seconds + + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + return await fn(...args); + } catch (error) { + // Check if the error is an API rate limit error + if (AssertAPIRateLimitResponse(error)) { + console.warn(`Rate limit exceeded, waiting for ${RETRY_DELAY / 1000} seconds before retrying...`); + await wait(RETRY_DELAY); + continue; + } + + // Check if the error is an Axios error with status 429 + if (error instanceof AxiosError && error.response?.status === 429) { + console.warn(`Rate limit exceeded, waiting for ${RETRY_DELAY / 1000} seconds before retrying...`); + await wait(RETRY_DELAY); + continue; + } + + throw error; + } + } + + throw new Error("Max retries exceeded"); +} diff --git a/silo/src/lib/index.ts b/silo/src/lib/index.ts new file mode 100644 index 0000000000..37e2868919 --- /dev/null +++ b/silo/src/lib/index.ts @@ -0,0 +1,4 @@ +export * from "./controller" +export * from "./decorators" +export * from "./errors" +export * from "./throws" diff --git a/silo/src/lib/throws.ts b/silo/src/lib/throws.ts new file mode 100644 index 0000000000..93c3ecaf04 --- /dev/null +++ b/silo/src/lib/throws.ts @@ -0,0 +1,24 @@ +import "reflect-metadata" + +export function throws(errors: string[]) { + return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value + + descriptor.value = function (...args: any[]) { + const stack = new Error().stack + const callerLine = stack?.split("\n")[2] + // console.log(stack) + + // if (!callerLine?.includes("try")) { + // console.warn( + // `Warning: ${propertyKey} should be called within a try-catch block. Possible errors: ${errors.join(", ")}` + // ) + // } + + return originalMethod.apply(this, args) + } + + return descriptor + } +} + diff --git a/silo/src/logger.ts b/silo/src/logger.ts new file mode 100644 index 0000000000..ab8ddcf642 --- /dev/null +++ b/silo/src/logger.ts @@ -0,0 +1,13 @@ +import { createLogger, format, transports } from "winston"; + +export const logger = createLogger({ + level: "info", + format: format.combine( + format.colorize(), + format.timestamp(), + format.printf( + ({ timestamp, level, message }) => `${timestamp} ${level}: ${message}`, + ), + ), + transports: [new transports.Console()], +}); diff --git a/silo/src/server.ts b/silo/src/server.ts new file mode 100644 index 0000000000..f8b56bf35c --- /dev/null +++ b/silo/src/server.ts @@ -0,0 +1,60 @@ +// sentry +import * as Sentry from "@sentry/node"; +import { nodeProfilingIntegration } from "@sentry/profiling-node"; +import dotenv from "dotenv"; +import express, { Application } from "express"; +import cors from "cors"; +// lib +import { registerControllers } from "./lib/controller"; +// controllers +import { JobConfigController, JobController, CredentialController } from "@/apps/engine/controllers"; + +import JiraController from "@/apps/jira-importer/controllers"; +import LinearController from "@/apps/linear-importer/controllers"; +import { env } from "./env"; +import { logger } from "./logger"; + +const controllers = [JobController, JobConfigController, CredentialController]; +const appControllers = [JiraController, LinearController]; + +export class Server { + app: Application; + port: number; + + constructor() { + this.app = express(); + this.app.use(express.json()); + this.port = Number(env.PORT); + // cors + this.app.use(cors()); + // set up dotenv + dotenv.config(); + // set up controllers + this.setupControllers(); + // sentry setup + this.setupSentry(); + } + + setupControllers() { + // Setup app controllers + controllers.forEach((controller) => registerControllers(this.app, controller)); + // Setup controllers for importers + appControllers.forEach((controller) => registerControllers(this.app, controller)); + } + + setupSentry() { + Sentry.init({ + dsn: process.env.SENTRY_DSN, + integrations: [nodeProfilingIntegration()], + tracesSampleRate: 1.0, + profilesSampleRate: 1.0, + }); + Sentry.setupExpressErrorHandler(this.app); + } + + start() { + this.app.listen(this.port, () => { + logger.info(`Silo started serving on port ${this.port}, 🦊🦊🦊`); + }); + } +} diff --git a/silo/src/start.ts b/silo/src/start.ts new file mode 100644 index 0000000000..751e1c6d7e --- /dev/null +++ b/silo/src/start.ts @@ -0,0 +1,17 @@ +import { logger } from "./logger"; +import { Server } from "./server"; +import taskManager from "@/apps/engine/worker"; +import "source-map-support/register"; + +// Start the worker for taking over the migration jobs +try { + taskManager.start({ + appType: "api", + }); + logger.info("All Good! Booted (source -> plane) worker ⛑︎⛑︎⛑︎"); +} catch (error) { + logger.error("Error starting (source -> plane) worker ~ ", error); +} + +const server = new Server(); +server.start(); diff --git a/silo/src/types/index.ts b/silo/src/types/index.ts new file mode 100644 index 0000000000..8a6b520bba --- /dev/null +++ b/silo/src/types/index.ts @@ -0,0 +1,16 @@ +import { credentials } from "@/db/schema"; +import { z } from "zod"; + +export const taskSchema = z.object({ + route: z.string(), + jobId: z.string(), + type: z.string(), +}); + +export type TaskHeaders = z.infer; + +export abstract class TaskHandler { + abstract handleTask(headers: TaskHeaders, data: any): Promise; +} + +export type Credentials = typeof credentials.$inferInsert; diff --git a/silo/tsconfig.json b/silo/tsconfig.json new file mode 100644 index 0000000000..993f455f21 --- /dev/null +++ b/silo/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@plane/typescript-config/base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "module": "NodeNext", + "target": "ESNext", + "experimentalDecorators": true, + "sourceMap": true, + "moduleResolution": "NodeNext" + }, + "include": ["src/**/*"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..11821605e2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2020", + "jsx": "react", + "strictNullChecks": true, + "strictFunctionTypes": true, + "sourceMap": true + }, + "exclude": ["node_modules", "**/node_modules/*"] +} diff --git a/web/app/[workspaceSlug]/(projects)/settings/imports/(importers)/jira/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/imports/(importers)/jira/page.tsx new file mode 100644 index 0000000000..da36af2ad7 --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/settings/imports/(importers)/jira/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { FC, Fragment, useState } from "react"; +import { E_IMPORTER_KEYS } from "@silo/core"; +// silo contexts +import { ImporterSyncJobContextProvider } from "@/plane-web/silo/contexts"; +// silo hooks +import { useSyncConfig } from "@/plane-web/silo/hooks"; +// silo components +import { UserAuthentication, Dashboard, StepsRoot } from "@/plane-web/silo/jira/components"; +// silo context +import { ImporterContextProvider } from "@/plane-web/silo/jira/contexts"; + +const JiraImporter: FC = () => { + // hooks + const { data, isLoading } = useSyncConfig(E_IMPORTER_KEYS.JIRA); + // states + const [isDashboardView, setIsDashboardView] = useState(true); + + if (isLoading) + return
jira-auth Loading...
; + + if (!data) + return ( +
+ jira-auth Something went wrong +
+ ); + + return ( + + + {!data?.isAuthenticated ? ( + + ) : ( + {isDashboardView ? : } + )} + + + ); +}; + +export default JiraImporter; diff --git a/web/app/[workspaceSlug]/(projects)/settings/imports/(importers)/layout.tsx b/web/app/[workspaceSlug]/(projects)/settings/imports/(importers)/layout.tsx new file mode 100644 index 0000000000..a75d5c6cda --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/settings/imports/(importers)/layout.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { FC, ReactNode } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +// hooks +import { useInstance, useUser, useWorkspace } from "@/hooks/store"; +// silo contexts +import { ImporterBaseContextProvider } from "@/plane-web/silo/contexts/base-importer"; +// silo hooks +import { useApiServiceToken } from "@/plane-web/silo/hooks"; + +type TImporterLayout = { + children: ReactNode; +}; + +const ImporterLayout: FC = observer((props) => { + const { children } = props; + const { workspaceSlug: workspaceSlugParam } = useParams(); + // hooks + const { currentWorkspace } = useWorkspace(); + const { data: currentUser } = useUser(); + const { config } = useInstance(); + // derived values + const siloBaseUrl = config?.silo_base_url; + const workspaceSlug = workspaceSlugParam?.toString() || undefined; + + // check if workspace exists + if (!workspaceSlug || !currentWorkspace || !currentWorkspace?.id || !currentUser?.id || !siloBaseUrl) return null; + + const { data: serviceToken, isLoading: serviceTokenLoading } = useApiServiceToken(workspaceSlug); + + if (serviceTokenLoading) { + return ( +
service-token Loader...
+ ); + } + + if (!serviceToken) { + return ( +
+ service-token Something went wrong +
+ ); + } + + return ( + + {children} + + ); +}); + +export default ImporterLayout; diff --git a/web/app/[workspaceSlug]/(projects)/settings/imports/(importers)/linear/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/imports/(importers)/linear/page.tsx new file mode 100644 index 0000000000..8bafb413ce --- /dev/null +++ b/web/app/[workspaceSlug]/(projects)/settings/imports/(importers)/linear/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { FC, Fragment, useState } from "react"; +import { E_IMPORTER_KEYS } from "@silo/core"; +// silo contexts +import { ImporterSyncJobContextProvider } from "@/plane-web/silo/contexts"; +// silo hooks +import { useSyncConfig } from "@/plane-web/silo/hooks"; +// silo components +import { UserAuthentication, Dashboard, StepsRoot } from "@/plane-web/silo/linear/components"; +// silo context +import { ImporterContextProvider } from "@/plane-web/silo/linear/contexts"; + +const LinearImporter: FC = () => { + // hooks + const { data, isLoading } = useSyncConfig(E_IMPORTER_KEYS.LINEAR); + // states + const [isDashboardView, setIsDashboardView] = useState(true); + + if (isLoading) + return
linear-auth Loading...
; + + if (!data) + return ( +
+ linear-auth Something went wrong +
+ ); + + return ( + + + {!data?.isAuthenticated ? ( + + ) : ( + {isDashboardView ? : } + )} + + + ); +}; + +export default LinearImporter; diff --git a/web/app/[workspaceSlug]/(projects)/settings/imports/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/imports/page.tsx index 09e775919f..36dc2c3aa3 100644 --- a/web/app/[workspaceSlug]/(projects)/settings/imports/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/settings/imports/page.tsx @@ -23,18 +23,15 @@ const ImportsPage = observer(() => { const { workspaceSlug } = useParams(); // store hooks const { data: currentUserProfile } = useUserProfile(); - const { config } = useInstance(); - const { currentWorkspace } = useWorkspace(); const { allowPermissions } = useUserPermissions(); - // derived values const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); const isDarkMode = currentUserProfile?.theme.theme === "dark"; const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Imports` : undefined; - - const isSiloIntegrationEnabled = useFlag(workspaceSlug?.toString(), "SILO_INTEGRATION"); + const isSiloIntegrationEnabled = useFlag(workspaceSlug?.toString(), "SILO_INTEGRATION") || true; + const siloBaseUrl = config?.silo_base_url; if (!isAdmin) return ( @@ -46,7 +43,7 @@ const ImportsPage = observer(() => { ); - if (!isSiloIntegrationEnabled || !config?.silo_base_url) + if (!isSiloIntegrationEnabled || !siloBaseUrl) return ( <> diff --git a/web/app/[workspaceSlug]/(projects)/settings/imports/provider/[providerSlug]/page.tsx b/web/app/[workspaceSlug]/(projects)/settings/imports/provider/[providerSlug]/page.tsx deleted file mode 100644 index 61d152ed2d..0000000000 --- a/web/app/[workspaceSlug]/(projects)/settings/imports/provider/[providerSlug]/page.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; -import { useEffect } from "react"; -import { observer } from "mobx-react"; -import { useParams, useRouter } from "next/navigation"; -import { useInstance, useWorkspace } from "@/hooks/store"; -import SiloIframe from "@/plane-web/components/iframe/silo-iframe"; - -const ImportProviderPage = observer(() => { - const { workspaceSlug, providerSlug } = useParams(); - - const router = useRouter(); - - const { config } = useInstance(); - - const { currentWorkspace } = useWorkspace(); - - useEffect(() => { - const handleMessage = (e: MessageEvent) => { - if (e.data.msg === "created-migration") { - router.push(`/${workspaceSlug}/settings/imports`); - } - }; - - window.addEventListener("message", handleMessage, false); - - return () => { - window.removeEventListener("message", handleMessage, false); - }; - }, [router, workspaceSlug]); - - if (config?.silo_base_url) { - return ( - - ); - } -}); - -export default ImportProviderPage; diff --git a/web/ee/components/integration/guide.tsx b/web/ee/components/integration/guide.tsx index ca360b0905..065add59a2 100644 --- a/web/ee/components/integration/guide.tsx +++ b/web/ee/components/integration/guide.tsx @@ -39,7 +39,7 @@ const IntegrationGuide = observer(() => {
- + diff --git a/web/ee/constants/workspace.ts b/web/ee/constants/workspace.ts index 8b4721c090..c8c78e19f0 100644 --- a/web/ee/constants/workspace.ts +++ b/web/ee/constants/workspace.ts @@ -4,6 +4,7 @@ import { WORKSPACE_SETTINGS as WORKSPACE_SETTINGS_CE } from "@/ce/constants/work import { SettingIcon } from "@/components/icons/attachment"; // logos import JiraLogo from "@/public/services/jira.svg"; +import LinearLogo from "@/public/services/linear.svg"; import { EUserPermissions } from "./user-permissions"; export const WORKSPACE_SETTINGS = { @@ -63,4 +64,11 @@ export const IMPORTERS_LIST = [ description: "Import your Jira data into Plane projects.", logo: JiraLogo, }, + { + provider: "linear", + type: "Import", + title: "Linear", + description: "Import your Linear data into Plane projects.", + logo: LinearLogo, + }, ]; diff --git a/web/ee/silo/constants/index.ts b/web/ee/silo/constants/index.ts new file mode 100644 index 0000000000..be7802b099 --- /dev/null +++ b/web/ee/silo/constants/index.ts @@ -0,0 +1 @@ +export * from "./priority"; diff --git a/web/ee/silo/constants/priority.ts b/web/ee/silo/constants/priority.ts new file mode 100644 index 0000000000..fee14321c9 --- /dev/null +++ b/web/ee/silo/constants/priority.ts @@ -0,0 +1,24 @@ +import { E_PLANE_PRIORITY, TPlanePriorityData } from "@/plane-web/silo/types/common"; + +export const PLANE_PRIORITIES: TPlanePriorityData[] = [ + { + key: E_PLANE_PRIORITY.URGENT, + label: "Urgent", + }, + { + key: E_PLANE_PRIORITY.HIGH, + label: "High", + }, + { + key: E_PLANE_PRIORITY.MEDIUM, + label: "Medium", + }, + { + key: E_PLANE_PRIORITY.LOW, + label: "Low", + }, + { + key: E_PLANE_PRIORITY.NONE, + label: "None", + }, +]; diff --git a/web/ee/silo/contexts/base-importer.tsx b/web/ee/silo/contexts/base-importer.tsx new file mode 100644 index 0000000000..0c15f0129e --- /dev/null +++ b/web/ee/silo/contexts/base-importer.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { createContext, ReactNode } from "react"; + +type TImporterCreateContext = { + // default props + workspaceSlug: string; + workspaceId: string; + userId: string; + userEmail: string; + serviceToken: string; + apiBaseUrl: string; + siloBaseUrl: string; +}; + +export const ImporterBaseContext = createContext({} as TImporterCreateContext); + +type TImporterBaseContextProvider = { + workspaceSlug: string; + workspaceId: string; + userId: string; + userEmail: string; + serviceToken: string; + apiBaseUrl: string; + siloBaseUrl: string; + children: ReactNode; +}; + +export const ImporterBaseContextProvider = (props: TImporterBaseContextProvider) => { + const { workspaceSlug, workspaceId, userId, userEmail, serviceToken, apiBaseUrl, siloBaseUrl, children } = props; + + return ( + + {children} + + ); +}; + +export default ImporterBaseContextProvider; diff --git a/web/ee/silo/contexts/index.ts b/web/ee/silo/contexts/index.ts new file mode 100644 index 0000000000..681071670a --- /dev/null +++ b/web/ee/silo/contexts/index.ts @@ -0,0 +1,2 @@ +export * from "./base-importer"; +export * from "./job-importer"; diff --git a/web/ee/silo/contexts/job-importer.tsx b/web/ee/silo/contexts/job-importer.tsx new file mode 100644 index 0000000000..1e8221be37 --- /dev/null +++ b/web/ee/silo/contexts/job-importer.tsx @@ -0,0 +1,127 @@ +/* eslint-disable no-useless-catch */ +"use client"; + +import { createContext, ReactNode } from "react"; +import useSWR, { KeyedMutator, SWRResponse } from "swr"; +import { SyncJobService, TSyncServices, TSyncJobWithConfig, TSyncJobConfigResponse, TSyncJob } from "@silo/core"; +// silo hooks +import { useApiServiceToken, useBaseImporter } from "@/plane-web/silo/hooks"; + +export type TImporterCreateContext = { + allSyncJobs: TSyncJobWithConfig[] | undefined; + syncJobLoader: boolean; + syncJobError: SWRResponse; + jobMutate: KeyedMutator[]>; + getJobById: (syncJobId: string) => Promise | undefined>; + createJob: (projectId: string, syncJobPayload: Partial) => Promise; + startJob: (syncJobId: string) => Promise; + createJobConfiguration: (configuration: T) => Promise; +}; + +export const ImporterSyncJobContext = createContext>({} as TImporterCreateContext); + +type TImporterSyncJobContextProvider = { + importerType: TSyncServices; + children: ReactNode; +}; + +export const ImporterSyncJobContextProvider = (props: TImporterSyncJobContextProvider) => { + const { importerType, children } = props; + // hooks + const { workspaceSlug, workspaceId } = useBaseImporter(); + const { data: serviceToken } = useApiServiceToken(workspaceSlug); + const { siloBaseUrl } = useBaseImporter(); + // service initialization + const jobService = serviceToken ? new SyncJobService(siloBaseUrl, serviceToken) : undefined; + + // fetching list of jobs + const { + data: allSyncJobs, + isLoading: syncJobLoader, + error: syncJobError, + mutate: jobMutate, + } = useSWR[]>( + siloBaseUrl && jobService ? `IMPORTER_JOBS_${importerType}` : null, + siloBaseUrl && jobService ? async () => await jobService?.getSyncJobs(importerType) : null + ); + + /** + * @description Fetches a job by its ID. + * @param syncJobId - Unique identifier of the job to fetch + * @returns Promise resolving to an array of Job objects + */ + const getJobById = async (syncJobId: string): Promise | undefined> => { + try { + const response = await jobService?.getSyncJobById(syncJobId); + if (response) { + } + return response; + } catch (error) { + throw error; + } + }; + + /** + * @description Creates a new job. + * @param syncJob - Job data, excluding certain properties + * @returns Promise resolving to the created Job object + */ + const createJob = async (projectId: string, syncJobPayload: Partial) => { + try { + const response = await jobService?.createSyncJob(workspaceId, projectId, syncJobPayload); + if (response) { + await startJob(response.insertedId); + jobMutate(); + } + return response; + } catch (error) { + throw error; + } + }; + + /** + * @description Starts a job. + * @param syncJobId - Unique identifier of the job to start + */ + const startJob = async (syncJobId: string) => { + try { + await jobService?.startSyncJob(syncJobId, importerType); + } catch (error) { + console.error(error); + throw error; + } + }; + + /** + * @description Creates a new job configuration. + * @param projectId: string - Unique identifier of the job + * @param configuration: T - Configuration data + */ + const createJobConfiguration = async (configuration: T) => { + try { + const response = await jobService?.createSyncJobConfig(configuration); + return response; + } catch (error) { + throw error; + } + }; + + return ( + + {children} + + ); +}; + +export default ImporterSyncJobContextProvider; diff --git a/web/ee/silo/hooks/context/index.ts b/web/ee/silo/hooks/context/index.ts new file mode 100644 index 0000000000..b75387a6c8 --- /dev/null +++ b/web/ee/silo/hooks/context/index.ts @@ -0,0 +1 @@ +export * from "./use-base-importer"; diff --git a/web/ee/silo/hooks/context/use-base-importer.ts b/web/ee/silo/hooks/context/use-base-importer.ts new file mode 100644 index 0000000000..526ebb9d31 --- /dev/null +++ b/web/ee/silo/hooks/context/use-base-importer.ts @@ -0,0 +1,13 @@ +import { useContext } from "react"; +// silo contexts +import { ImporterBaseContext } from "@/plane-web/silo/contexts"; + +export function useBaseImporter() { + const context = useContext(ImporterBaseContext); + + if (!context) { + throw new Error("useBaseImporter must be used within an ImportBaseContextProvider"); + } + + return context; +} diff --git a/web/ee/silo/hooks/context/use-jira-sync-jobs.ts b/web/ee/silo/hooks/context/use-jira-sync-jobs.ts new file mode 100644 index 0000000000..28b1de1e07 --- /dev/null +++ b/web/ee/silo/hooks/context/use-jira-sync-jobs.ts @@ -0,0 +1,14 @@ +import { useContext } from "react"; +import { JiraConfig } from "@silo/jira"; +// silo contexts +import { ImporterSyncJobContext, TImporterCreateContext } from "@/plane-web/silo/contexts"; + +export function useJiraSyncJobs() { + const context = useContext>(ImporterSyncJobContext); + + if (!context) { + throw new Error("useJiraSyncJobs must be used within an ImporterSyncJobContextProvider"); + } + + return context; +} diff --git a/web/ee/silo/hooks/context/use-linear-sync-jobs.ts b/web/ee/silo/hooks/context/use-linear-sync-jobs.ts new file mode 100644 index 0000000000..f4259b7663 --- /dev/null +++ b/web/ee/silo/hooks/context/use-linear-sync-jobs.ts @@ -0,0 +1,14 @@ +import { useContext } from "react"; +import { LinearConfig } from "@silo/linear"; +// silo contexts +import { ImporterSyncJobContext, TImporterCreateContext } from "@/plane-web/silo/contexts"; + +export function useLinearSyncJobs() { + const context = useContext>(ImporterSyncJobContext); + + if (!context) { + throw new Error("useLinearSyncJobs must be used within an ImporterSyncJobContextProvider"); + } + + return context; +} diff --git a/web/ee/silo/hooks/index.ts b/web/ee/silo/hooks/index.ts new file mode 100644 index 0000000000..b69f6719d9 --- /dev/null +++ b/web/ee/silo/hooks/index.ts @@ -0,0 +1,2 @@ +export * from "./context"; +export * from "./service"; diff --git a/web/ee/silo/hooks/service/index.ts b/web/ee/silo/hooks/service/index.ts new file mode 100644 index 0000000000..cf72d40404 --- /dev/null +++ b/web/ee/silo/hooks/service/index.ts @@ -0,0 +1,6 @@ +export * from "./use-api-service-token"; + +export * from "./use-sync-config"; + +export * from "./use-plane-projects"; +export * from "./use-plane-project-states"; diff --git a/web/ee/silo/hooks/service/use-api-service-token.ts b/web/ee/silo/hooks/service/use-api-service-token.ts new file mode 100644 index 0000000000..f49f10b7ee --- /dev/null +++ b/web/ee/silo/hooks/service/use-api-service-token.ts @@ -0,0 +1,30 @@ +import { useEffect, useState } from "react"; +import useSWR from "swr"; +// silo services +import apiTokenService from "@/plane-web/silo/services/api-service-token.service"; + +export const useApiServiceToken = (workspaceSlug: string) => { + // state + const [token, setToken] = useState(undefined); + + // fetch service token + const { data, isLoading, error, mutate } = useSWR( + workspaceSlug ? `SERVICE_API_TOKEN_${workspaceSlug}` : null, + workspaceSlug ? async () => await apiTokenService.createServiceApiToken(workspaceSlug) : null, + { revalidateOnFocus: true, revalidateOnReconnect: true } + ); + + // update the token + useEffect(() => { + if ((!token && data) || (token && data && token !== data.token)) { + setToken(data.token); + } + }, [data, token]); + + return { + data: token, + isLoading, + error, + mutate, + }; +}; diff --git a/web/ee/silo/hooks/service/use-plane-project-states.ts b/web/ee/silo/hooks/service/use-plane-project-states.ts new file mode 100644 index 0000000000..2baec03198 --- /dev/null +++ b/web/ee/silo/hooks/service/use-plane-project-states.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; +import useSWR from "swr"; +import { IState } from "@plane/types"; +// silo hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; +// services +import { ProjectStateService } from "@/services/project"; + +const projectStateService = new ProjectStateService(); + +export const usePlaneProjectStates = (projectId: string | undefined) => { + // hooks + const { workspaceSlug } = useBaseImporter(); + + // states + const [projectStates, setProjectStates] = useState(undefined); + + // fetch project states + const { data, isLoading, error, mutate } = useSWR( + workspaceSlug && projectId ? `PLANE_PROJECT_STATES_${workspaceSlug}_${projectId}` : null, + workspaceSlug && projectId ? async () => await projectStateService.getStates(workspaceSlug, projectId) : null + ); + + // update the project states + useEffect(() => { + if ((!projectStates && data) || (projectStates && data && !isEqual(projectStates, data))) { + setProjectStates(data); + } + }, [data]); + + // get project state by id + const getById = (stateId: string) => projectStates?.find((state) => state.id === stateId); + + return { + data: projectStates, + isLoading, + error, + mutate, + getById, + }; +}; diff --git a/web/ee/silo/hooks/service/use-plane-projects.ts b/web/ee/silo/hooks/service/use-plane-projects.ts new file mode 100644 index 0000000000..94cef89ae9 --- /dev/null +++ b/web/ee/silo/hooks/service/use-plane-projects.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; +import useSWR from "swr"; +import { IProject } from "@plane/types"; +// silo hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; +// services +import { ProjectService } from "@/services/project"; + +const projectService = new ProjectService(); + +export const usePlaneProjects = () => { + // hooks + const { workspaceSlug } = useBaseImporter(); + + // states + const [projects, setProjects] = useState(undefined); + + // fetch project states + const { data, isLoading, error, mutate } = useSWR( + workspaceSlug ? `PLANE_PROJECT_STATES_${workspaceSlug}` : null, + workspaceSlug ? async () => await projectService.getProjects(workspaceSlug) : null + ); + + // update the project states + useEffect(() => { + if ((!projects && data) || (projects && data && !isEqual(projects, data))) { + setProjects(data); + } + }, [data]); + + // get project by id + const getById = (projectId: string) => projects?.find((project) => project.id === projectId); + + return { + data: projects, + isLoading, + error, + mutate, + getById, + }; +}; diff --git a/web/ee/silo/hooks/service/use-sync-config.ts b/web/ee/silo/hooks/service/use-sync-config.ts new file mode 100644 index 0000000000..b5bbd3995e --- /dev/null +++ b/web/ee/silo/hooks/service/use-sync-config.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; +import useSWR from "swr"; +import { SyncCredService, TSyncServiceConfigured, TSyncServices } from "@silo/core"; +// silo hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; + +export const useSyncConfig = (service: TSyncServices) => { + // hooks + const { workspaceId, userId, siloBaseUrl } = useBaseImporter(); + // service instance + const syncService = new SyncCredService(siloBaseUrl); + // states + const [config, setConfig] = useState(undefined); + + // fetch service config + const { data, isLoading, error, mutate } = useSWR( + siloBaseUrl ? `IMPORTER_CONFIG_${workspaceId}_${userId}_${service}` : null, + siloBaseUrl ? async () => await syncService.isServiceConfigured(workspaceId, userId, service) : null + ); + + // update the config + useEffect(() => { + if ((!config && data) || (config && data && config.isAuthenticated !== data.isAuthenticated)) { + setConfig(data); + } + }, [data]); + + return { + data: config, + isLoading, + error, + mutate, + }; +}; diff --git a/web/ee/silo/jira/components/authentication/index.ts b/web/ee/silo/jira/components/authentication/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/web/ee/silo/jira/components/authentication/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ee/silo/jira/components/authentication/root.tsx b/web/ee/silo/jira/components/authentication/root.tsx new file mode 100644 index 0000000000..9ce8632f84 --- /dev/null +++ b/web/ee/silo/jira/components/authentication/root.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { FC } from "react"; +import { Button } from "@plane/ui"; +import { JiraAuthState } from "@silo/jira"; +// hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; +import { useImporter } from "@/plane-web/silo/jira/hooks"; + +export const UserAuthentication: FC = () => { + // hooks + const { workspaceSlug, workspaceId, userId, serviceToken } = useBaseImporter(); + const { importerAuthService } = useImporter(); + + const handleAuthentication = async () => { + if (!serviceToken) return; + + const payload: JiraAuthState = { + workspaceSlug, + workspaceId, + userId, + apiToken: serviceToken, + }; + + try { + const response = await importerAuthService.jiraAuthentication(payload); + window.open(response); + } catch (error) { + console.error("error", error); + } + }; + + return ( +
+
+
+ Jira to Plane Migration Assistant +
+

+ Seamlessly migrate your Jira projects to Plane with our powerful assistant. +

+
+ {serviceToken && workspaceSlug && workspaceId && userId && ( + + )} +
+ ); +}; diff --git a/web/ee/silo/jira/components/dashboard/empty-state.tsx b/web/ee/silo/jira/components/dashboard/empty-state.tsx new file mode 100644 index 0000000000..5781437104 --- /dev/null +++ b/web/ee/silo/jira/components/dashboard/empty-state.tsx @@ -0,0 +1,5 @@ +export const DashboardEmptyState = () => ( +
+
No Migrations are available
+
+); diff --git a/web/ee/silo/jira/components/dashboard/icon-field-render.tsx b/web/ee/silo/jira/components/dashboard/icon-field-render.tsx new file mode 100644 index 0000000000..c943069828 --- /dev/null +++ b/web/ee/silo/jira/components/dashboard/icon-field-render.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { FC, ReactNode } from "react"; + +type TIconFieldRender = { + icon?: ReactNode; + title?: string; +}; + +export const IconFieldRender: FC = (props) => { + const { icon, title } = props; + + if (!icon && !title) return "-"; + if (!icon && title) return title; + return ( +
+
+ {icon} +
+
{title}
+
+ ); +}; diff --git a/web/ee/silo/jira/components/dashboard/index.ts b/web/ee/silo/jira/components/dashboard/index.ts new file mode 100644 index 0000000000..11cde0f654 --- /dev/null +++ b/web/ee/silo/jira/components/dashboard/index.ts @@ -0,0 +1,7 @@ +export * from "./root"; + +export * from "./loader"; +export * from "./empty-state"; + +export * from "./icon-field-render"; +export * from "./status"; diff --git a/web/ee/silo/jira/components/dashboard/loader.tsx b/web/ee/silo/jira/components/dashboard/loader.tsx new file mode 100644 index 0000000000..338fcdafba --- /dev/null +++ b/web/ee/silo/jira/components/dashboard/loader.tsx @@ -0,0 +1,7 @@ +import { Loader } from "@plane/ui"; + +export const DashboardLoadingState = () => ( + + + +); diff --git a/web/ee/silo/jira/components/dashboard/root.tsx b/web/ee/silo/jira/components/dashboard/root.tsx new file mode 100644 index 0000000000..7333e63d12 --- /dev/null +++ b/web/ee/silo/jira/components/dashboard/root.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { Dispatch, FC, SetStateAction } from "react"; +import Image from "next/image"; +import { Briefcase, RefreshCcw } from "lucide-react"; +import { TLogoProps } from "@plane/types"; +import { Button } from "@plane/ui"; +// components +import { Logo } from "@/components/common"; +// silo context +import { useJiraSyncJobs } from "@/plane-web/silo/hooks/context/use-jira-sync-jobs"; +// silo components +import { IconFieldRender, SyncJobStatus } from "@/plane-web/silo/jira/components"; +// assets +import JiraLogo from "@/public/services/jira.svg"; + +type TDashboard = { + setIsDashboardView: Dispatch>; +}; + +export const Dashboard: FC = (props) => { + // props + const { setIsDashboardView } = props; + // hooks + const { allSyncJobs, startJob } = useJiraSyncJobs(); + + return ( +
+
Imports
+ + {/* header */} +
+
+ {`Jira +
+
+
Jira
+
Import your Jira data into plane projects.
+
+
+ +
+
+ + {/* migrations */} +
+
Migrations
+
+ + + + + + + + + + + + + + + + + {allSyncJobs && + allSyncJobs.length > 0 && + allSyncJobs.map((job, index) => ( + + + + + + + + + + + + + ))} + +
Sr No.Plane ProjectJira WorkspaceJira ProjectStatusTotal IssuesTransformed IssuesCompleted IssuesRe RunStart Time
{index + 1} + + ) : ( + + ) + } + title={job?.config?.meta?.planeProject?.name} + /> + + + } + title={job?.config?.meta?.resource?.name} + /> + + + } + title={job?.config?.meta?.project?.name} + /> + + + {job?.total_batch_count || "-"}{job?.transformed_batch_count || "-"}{job?.completed_batch_count || "-"} + + {job?.start_time?.toString() || "-"}
+
+
+
+ ); +}; diff --git a/web/ee/silo/jira/components/dashboard/status.tsx b/web/ee/silo/jira/components/dashboard/status.tsx new file mode 100644 index 0000000000..9ad4d33ffa --- /dev/null +++ b/web/ee/silo/jira/components/dashboard/status.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import { E_JOB_STATUS, TSyncJobStatus } from "@silo/core"; +// helpers +import { cn } from "@/helpers/common.helper"; + +type TSyncJobStatusProps = { + status: TSyncJobStatus; +}; + +const STATUS_CLASSNAMES: { [key in TSyncJobStatus]: string } = { + [E_JOB_STATUS.INITIATED]: "text-gray-500 border border-gray-500 bg-gray-500/10", + [E_JOB_STATUS.PULLING]: "text-yellow-500 border border-yellow-500 bg-yellow-500/10", + [E_JOB_STATUS.PULLED]: "text-yellow-500 border border-yellow-500 bg-yellow-500/10", + [E_JOB_STATUS.TRANSFORMING]: "text-orange-500 border border-orange-500 bg-orange-500/10", + [E_JOB_STATUS.TRANSFORMED]: "text-orange-500 border border-orange-500 bg-orange-500/10", + [E_JOB_STATUS.PUSHING]: "text-green-500 border border-green-500 bg-green-500/10", + [E_JOB_STATUS.FINISHED]: "text-green-500 border border-green-500 bg-green-500/10", + [E_JOB_STATUS.ERROR]: "text-red-500 border border-red-500 bg-red-500/10", +}; + +export const SyncJobStatus: FC = observer((props) => { + const { status } = props; + + return ( +
+ {status} +
+ ); +}); diff --git a/web/ee/silo/jira/components/index.ts b/web/ee/silo/jira/components/index.ts new file mode 100644 index 0000000000..a03527a759 --- /dev/null +++ b/web/ee/silo/jira/components/index.ts @@ -0,0 +1,4 @@ +export * from "./authentication"; + +export * from "./dashboard"; +export * from "./steps"; diff --git a/web/ee/silo/jira/components/steps/configure-jira/index.ts b/web/ee/silo/jira/components/steps/configure-jira/index.ts new file mode 100644 index 0000000000..3ecf32373f --- /dev/null +++ b/web/ee/silo/jira/components/steps/configure-jira/index.ts @@ -0,0 +1,4 @@ +export * from "./root"; +export * from "./select-resource"; +export * from "./select-project"; +export * from "./select-issue-type"; diff --git a/web/ee/silo/jira/components/steps/configure-jira/root.tsx b/web/ee/silo/jira/components/steps/configure-jira/root.tsx new file mode 100644 index 0000000000..78047ce3ec --- /dev/null +++ b/web/ee/silo/jira/components/steps/configure-jira/root.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { FC, useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; +import { Button } from "@plane/ui"; +// silo components +import { + ConfigureJiraSelectResource, + ConfigureJiraSelectProject, + ConfigureJiraSelectIssueType, +} from "@/plane-web/silo/jira/components"; +// silo hooks +import { useImporter } from "@/plane-web/silo/jira/hooks"; +// silo types +import { E_FORM_RADIO_DATA, E_IMPORTER_STEPS, TFormRadioData, TImporterDataPayload } from "@/plane-web/silo/jira/types"; +// silo ui components +import { StepperNavigation } from "@/plane-web/silo/ui"; + +type TFormData = TImporterDataPayload[E_IMPORTER_STEPS.CONFIGURE_JIRA]; + +const currentStepKey = E_IMPORTER_STEPS.CONFIGURE_JIRA; + +export const ConfigureJiraRoot: FC = () => { + // hooks + const { importerData, handleImporterData, currentStep, handleStepper } = useImporter(); + // states + const [formData, setFormData] = useState({ + resourceId: undefined, + projectId: undefined, + issueType: E_FORM_RADIO_DATA.CREATE_AS_LABEL, + }); + // derived values + const isNextButtonDisabled = !formData?.resourceId || !formData?.projectId || !formData?.issueType; + // handlers + const handleFormData = (key: T, value: TFormData[T]) => { + setFormData((prevData) => ({ ...prevData, [key]: value })); + }; + + const handleOnClickNext = () => { + // update the data in the context + handleImporterData(currentStepKey, formData); + + // moving to the next state + handleStepper("next"); + }; + + useEffect(() => { + const contextData = importerData[currentStepKey]; + if (contextData && !isEqual(contextData, formData)) { + setFormData(contextData); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [importerData]); + + return ( +
+ {/* content */} +
+ {/* section handled jira workspace and projects */} +
+ handleFormData("resourceId", value)} + /> + + {formData.resourceId && ( + handleFormData("projectId", value)} + /> + )} +
+ + {/* Managing issue types as labels or adding them in the issue title */} + {formData.resourceId && formData.projectId && ( + handleFormData("issueType", value)} + /> + )} +
+ + {/* stepper button */} +
+ + + +
+
+ ); +}; diff --git a/web/ee/silo/jira/components/steps/configure-jira/select-issue-type.tsx b/web/ee/silo/jira/components/steps/configure-jira/select-issue-type.tsx new file mode 100644 index 0000000000..24c464cb3b --- /dev/null +++ b/web/ee/silo/jira/components/steps/configure-jira/select-issue-type.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { FC } from "react"; +// helpers +import { cn } from "@/helpers/common.helper"; +// silo hooks +import { useImporter } from "@/plane-web/silo/jira/hooks"; +// silo types +import { E_FORM_RADIO_DATA, TFormRadioData } from "@/plane-web/silo/jira/types"; + +type TConfigureJiraSelectIssueType = { + value: TFormRadioData; + handleFormData: (value: TFormRadioData) => void; +}; + +const radioOptions: { key: TFormRadioData; label: string }[] = [ + { key: E_FORM_RADIO_DATA.CREATE_AS_LABEL, label: "Create as a label" }, + { key: E_FORM_RADIO_DATA.ADD_IN_TITLE, label: "Add [ issue_type ] in the title" }, +]; + +export const ConfigureJiraSelectIssueType: FC = (props) => { + // props + const { value, handleFormData } = props; + // hooks + const { handleSyncJobConfig } = useImporter(); + + const handelData = (value: TFormRadioData) => { + handleFormData(value); + // updating the config data + if (value) { + handleSyncJobConfig("issueType", value); + } + }; + + return ( +
+
How do you want to record issue types in Plane?
+
+ {radioOptions.map((option) => ( +
handelData(option.key)} + > +
+
+
+
{option.label}
+
+ ))} +
+
+ ); +}; diff --git a/web/ee/silo/jira/components/steps/configure-jira/select-project.tsx b/web/ee/silo/jira/components/steps/configure-jira/select-project.tsx new file mode 100644 index 0000000000..85b91f04f5 --- /dev/null +++ b/web/ee/silo/jira/components/steps/configure-jira/select-project.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { FC } from "react"; +// silo hooks +import { useImporter, useJiraProjects } from "@/plane-web/silo/jira/hooks"; +// silo ui components +import { Dropdown } from "@/plane-web/silo/ui"; + +type TConfigureJiraSelectProject = { + resourceId: string; + value: string | undefined; + handleFormData: (value: string | undefined) => void; +}; + +export const ConfigureJiraSelectProject: FC = (props) => { + // props + const { resourceId, value, handleFormData } = props; + // hooks + const { handleSyncJobConfig } = useImporter(); + const { data: jiraProjects, getById: getProjectById } = useJiraProjects(resourceId); + + const handelData = (value: string | undefined) => { + handleFormData(value); + // updating the config data + if (value) { + const projectData = getProjectById(value); + if (projectData) handleSyncJobConfig("project", projectData); + } + }; + + return ( +
+
Select Jira project
+ ({ + key: project.id, + label: project.name, + value: project.id, + data: project, + }))} + value={value} + placeHolder="Select jira project" + onChange={(value: string | undefined) => handelData(value)} + iconExtractor={(option) => ( +
+ {option && option.avatarUrls?.["48x48"] ? ( + {`Jira + ) : ( + <> + )} +
+ )} + queryExtractor={(option) => option.name} + /> +
+ ); +}; diff --git a/web/ee/silo/jira/components/steps/configure-jira/select-resource.tsx b/web/ee/silo/jira/components/steps/configure-jira/select-resource.tsx new file mode 100644 index 0000000000..c21ce48695 --- /dev/null +++ b/web/ee/silo/jira/components/steps/configure-jira/select-resource.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { FC } from "react"; +// silo hooks +import { useImporter, useJiraResources } from "@/plane-web/silo/jira/hooks"; +// silo ui components +import { Dropdown } from "@/plane-web/silo/ui"; + +type TConfigureJiraSelectResource = { + value: string | undefined; + handleFormData: (value: string | undefined) => void; +}; + +export const ConfigureJiraSelectResource: FC = (props) => { + // props + const { value, handleFormData } = props; + // hooks + const { handleSyncJobConfig } = useImporter(); + const { data: jiraResources, getById: getResourceById } = useJiraResources(); + + const handelData = (value: string | undefined) => { + handleFormData(value); + // updating the config data + if (value) { + const resourceData = getResourceById(value); + if (resourceData) handleSyncJobConfig("resource", resourceData); + } + }; + + return ( +
+
Select Jira workspace
+ ({ + key: resource.id, + label: resource.name, + value: resource.id, + data: resource, + }))} + value={value} + placeHolder="Select jira workspace" + onChange={(value: string | undefined) => handelData(value)} + iconExtractor={(option) => ( +
+ {option && option.avatarUrl && ( + {option.name} + )} +
+ )} + queryExtractor={(option) => option.name} + /> +
+ ); +}; diff --git a/web/ee/silo/jira/components/steps/import-users-from-jira/index.ts b/web/ee/silo/jira/components/steps/import-users-from-jira/index.ts new file mode 100644 index 0000000000..ecd4f5dfce --- /dev/null +++ b/web/ee/silo/jira/components/steps/import-users-from-jira/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./upload-csv"; diff --git a/web/ee/silo/jira/components/steps/import-users-from-jira/root.tsx b/web/ee/silo/jira/components/steps/import-users-from-jira/root.tsx new file mode 100644 index 0000000000..8e589280ae --- /dev/null +++ b/web/ee/silo/jira/components/steps/import-users-from-jira/root.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { FC, useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; +import { Button } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +// silo components +import { ImportUsersFromJiraUploader } from "@/plane-web/silo/jira/components"; +// silo hooks +import { useImporter } from "@/plane-web/silo/jira/hooks"; +// silo types +import { E_IMPORTER_STEPS, TImporterDataPayload } from "@/plane-web/silo/jira/types"; +// silo ui components +import { StepperNavigation } from "@/plane-web/silo/ui"; + +type TFormData = TImporterDataPayload[E_IMPORTER_STEPS.IMPORT_USERS_FROM_JIRA]; + +const currentStepKey = E_IMPORTER_STEPS.IMPORT_USERS_FROM_JIRA; + +export const ImportUsersFromJira: FC = () => { + // hooks + const { importerData, handleImporterData, handleSyncJobConfig, currentStep, handleStepper } = useImporter(); + // states + const [formData, setFormData] = useState({ + userSkipToggle: false, + userData: undefined, + }); + // derived values + const isNextButtonDisabled = + formData.userSkipToggle || (formData.userSkipToggle === false && formData?.userData ? false : true); + const jiraResourceId = importerData[E_IMPORTER_STEPS.CONFIGURE_JIRA]?.resourceId; + // handlers + const handleFormData = (key: T, value: TFormData[T]) => { + setFormData((prevData) => ({ ...prevData, [key]: value })); + + if (key === "userSkipToggle") { + handleSyncJobConfig("users", ""); + } + if (key === "userData" && !formData.userSkipToggle && typeof value === "string") { + handleSyncJobConfig("users", value); + } + }; + + const handleOnClickNext = () => { + // update the data in the context + handleImporterData(currentStepKey, formData); + // moving to the next state + handleStepper("next"); + }; + + useEffect(() => { + const contextData = importerData[currentStepKey]; + if (contextData && !isEqual(contextData, formData)) { + setFormData(contextData); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [importerData]); + + return ( +
+ {/* content */} +
+ {/* skipping users checkbox */} +
+
handleFormData("userSkipToggle", !formData.userSkipToggle)} + > +
+
+
+
Skip importing User data
+
+ {/* when skipping we are showing the error below */} + {formData.userSkipToggle && ( +
+ Skipping user import will result in issues, comments, and other data from Jira being created by the user + performing the migration in Plane. You can still manually add users later. +
+ )} +
+ + {/* uploading the users from jira */} + {!formData.userSkipToggle && jiraResourceId && ( +
+
+ Upload a CSV file to import user data  + + from Jira + +
+ handleFormData("userData", value)} + /> +
+ )} +
+ + {/* stepper button */} +
+ + + +
+
+ ); +}; diff --git a/web/ee/silo/jira/components/steps/import-users-from-jira/upload-csv.tsx b/web/ee/silo/jira/components/steps/import-users-from-jira/upload-csv.tsx new file mode 100644 index 0000000000..62dc033708 --- /dev/null +++ b/web/ee/silo/jira/components/steps/import-users-from-jira/upload-csv.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { FC, Fragment, useState } from "react"; +import Papa from "papaparse"; +import Dropzone, { Accept } from "react-dropzone"; +import { TriangleAlert, CircleCheck, X, Loader } from "lucide-react"; +// helpers +import { cn } from "@/helpers/common.helper"; + +type TImportUsersFromJiraUploader = { + handleValue: (value: string) => void; +}; + +const acceptFileTypes: Accept = { "text/csv": [".csv"] }; +const fileErrors = { + processing: { + className: "text-custom-text-200", + icon: , + message: "Processing...", + }, + error: { + className: "text-red-500", + icon: , + message: "Invalid file type", + }, + "missing-fields": { + className: "text-yellow-500", + icon: , + message: "Missing fields!", + }, + success: { + className: "text-green-500", + icon: , + message: "Users CSV added!", + }, +}; + +export const ImportUsersFromJiraUploader: FC = (props) => { + // props + const { handleValue } = props; + // states + const [file, setFile] = useState(); + const [fileErrorType, setFileErrorType] = useState<"processing" | "error" | "missing-fields" | "success" | undefined>( + undefined + ); + + const handleFileChange = (file: File) => { + if (file) { + setFile(file); + setFileErrorType("processing"); + const reader = new FileReader(); + reader.onload = (e) => { + const csvContent = e.target?.result as string; + Papa.parse(csvContent, { + skipEmptyLines: true, + complete: (results) => { + const data = results.data; + if (validateCsv(data)) { + setFileErrorType("success"); + handleValue(csvContent); + } else { + console.error("CSV validation failed"); + setFileErrorType(undefined); + } + }, + error: (error: Error) => { + console.error("Error parsing CSV:", error); + setFileErrorType(undefined); + }, + }); + }; + reader.onerror = (e) => { + console.error("Error reading file:", e); + setFileErrorType(undefined); + }; + reader.readAsText(file); + } + }; + + const validateCsv = (data: Array): boolean => { + if (data.length === 0) { + console.error("CSV is empty."); + return false; + } + const requiredFields = ["User name", "email"]; + const missingFields = new Set(); + requiredFields.forEach((field) => { + if (!data[0].includes(field)) { + missingFields.add(field); + } + }); + if (missingFields.size > 0) { + setFileErrorType("missing-fields"); + return false; + } + return true; + }; + + const handleClearFile = () => { + setFile(undefined); + setFileErrorType(undefined); + }; + + return ( + + {/* upload/dropzone container */} +
+ { + handleFileChange(files[0]); + }} + > + {({ getRootProps, getInputProps }) => ( +
+ +
Click here to Upload file{"'"}s
+
or Drag and Drop
+
+ )} +
+ {file && ( +
+ +
+ )} +
+ + {/* upload successful */} + {fileErrorType && ( +
+ {fileErrors[fileErrorType]?.icon} + {fileErrors[fileErrorType]?.message} +
+ )} +
+ ); +}; diff --git a/web/ee/silo/jira/components/steps/index.ts b/web/ee/silo/jira/components/steps/index.ts new file mode 100644 index 0000000000..4c9fadf1e6 --- /dev/null +++ b/web/ee/silo/jira/components/steps/index.ts @@ -0,0 +1,8 @@ +export * from "./root"; + +export * from "./select-plane-project"; +export * from "./configure-jira"; +export * from "./import-users-from-jira"; +export * from "./map-states"; +export * from "./map-priority"; +export * from "./summary"; diff --git a/web/ee/silo/jira/components/steps/map-priority/index.ts b/web/ee/silo/jira/components/steps/map-priority/index.ts new file mode 100644 index 0000000000..395ac705b7 --- /dev/null +++ b/web/ee/silo/jira/components/steps/map-priority/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./priority-selection"; diff --git a/web/ee/silo/jira/components/steps/map-priority/priority-selection.tsx b/web/ee/silo/jira/components/steps/map-priority/priority-selection.tsx new file mode 100644 index 0000000000..81120eb959 --- /dev/null +++ b/web/ee/silo/jira/components/steps/map-priority/priority-selection.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { FC } from "react"; +import { PriorityIcon } from "@plane/ui"; +import { JiraPriority } from "@silo/jira"; +// silo types +import { TPlanePriorityData } from "@/plane-web/silo/types/common"; +// silo ui components +import { Dropdown } from "@/plane-web/silo/ui"; + +type TMapPrioritiesSelection = { + value: string | undefined; + handleValue: (value: string | undefined) => void; + jiraPriority: JiraPriority; + planePriorities: TPlanePriorityData[]; +}; + +export const MapPrioritiesSelection: FC = (props) => { + const { value, handleValue, jiraPriority, planePriorities } = props; + + return ( +
+
{jiraPriority?.name}
+
+ ({ + key: state.key, + label: state.label, + value: state.key, + data: state, + }))} + value={value} + placeHolder="Select Priority" + onChange={(value: string | undefined) => handleValue(value)} + iconExtractor={(option) => ( +
+ +
+ )} + queryExtractor={(option) => option.label} + /> +
+
+ ); +}; diff --git a/web/ee/silo/jira/components/steps/map-priority/root.tsx b/web/ee/silo/jira/components/steps/map-priority/root.tsx new file mode 100644 index 0000000000..d32d6122bd --- /dev/null +++ b/web/ee/silo/jira/components/steps/map-priority/root.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { FC, useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; +import { Button } from "@plane/ui"; +import { IPriorityConfig, JiraPriority } from "@silo/jira"; +// silo constants +import { PLANE_PRIORITIES } from "@/plane-web/silo/constants/priority"; +// silo components +import { MapPrioritiesSelection } from "@/plane-web/silo/jira/components"; +// silo hooks +import { useImporter, useJiraProjectPriorities } from "@/plane-web/silo/jira/hooks"; +// silo types +import { E_IMPORTER_STEPS, TImporterDataPayload } from "@/plane-web/silo/jira/types"; +// silo ui components +import { StepperNavigation } from "@/plane-web/silo/ui"; + +type TFormData = TImporterDataPayload[E_IMPORTER_STEPS.MAP_PRIORITY]; + +const currentStepKey = E_IMPORTER_STEPS.MAP_PRIORITY; + +export const MapPriorityRoot: FC = () => { + // hooks + const { importerData, handleImporterData, handleSyncJobConfig, currentStep, handleStepper } = useImporter(); + const jiraResourceId = importerData[E_IMPORTER_STEPS.CONFIGURE_JIRA]?.resourceId; + const jiraProjectId = importerData[E_IMPORTER_STEPS.CONFIGURE_JIRA]?.projectId; + const { data: jiraProjectPriorities, getById: getJiraPriorityById } = useJiraProjectPriorities( + jiraResourceId, + jiraProjectId + ); + // states + const [formData, setFormData] = useState({}); + // derived values + const isNextButtonDisabled = jiraProjectPriorities?.length === Object.keys(formData).length ? false : true; + // handlers + const handleFormData = (key: T, value: TFormData[T]) => { + setFormData((prevData) => ({ ...prevData, [key]: value })); + }; + + const constructJiraPrioritySyncJobConfig = () => { + const priorityConfig: IPriorityConfig[] = []; + Object.entries(formData).forEach(([jiraPriorityId, planePriority]) => { + if (jiraPriorityId && planePriority) { + const jiraState = getJiraPriorityById(jiraPriorityId); + if (jiraState && planePriority) { + const syncJobConfig = { + source_priority: jiraState, + target_priority: planePriority, + }; + priorityConfig.push(syncJobConfig); + } + } + }); + return priorityConfig; + }; + + const handleOnClickNext = () => { + // validate the sync job config + if (jiraProjectPriorities?.length === Object.keys(formData).length) { + // update the data in the context + handleImporterData(currentStepKey, formData); + // update the sync job config + const priorityConfig = constructJiraPrioritySyncJobConfig(); + handleSyncJobConfig("priority", priorityConfig); + // moving to the next state + handleStepper("next"); + } + }; + + useEffect(() => { + const contextData = importerData[currentStepKey]; + if (contextData && !isEqual(contextData, formData)) { + setFormData(contextData); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [importerData]); + + return ( +
+ {/* content */} +
+
+
Jira Priorities
+
Plane Priorities
+
+
+ {jiraProjectPriorities && + PLANE_PRIORITIES && + jiraProjectPriorities.map( + (jiraPriority: JiraPriority) => + jiraPriority.id && ( + + jiraPriority.id && handleFormData(jiraPriority.id, value) + } + jiraPriority={jiraPriority} + planePriorities={PLANE_PRIORITIES} + /> + ) + )} +
+
+ + {/* stepper button */} +
+ + + +
+
+ ); +}; diff --git a/web/ee/silo/jira/components/steps/map-states/index.ts b/web/ee/silo/jira/components/steps/map-states/index.ts new file mode 100644 index 0000000000..943d6bbb91 --- /dev/null +++ b/web/ee/silo/jira/components/steps/map-states/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./state-selection"; diff --git a/web/ee/silo/jira/components/steps/map-states/root.tsx b/web/ee/silo/jira/components/steps/map-states/root.tsx new file mode 100644 index 0000000000..1815c5d9ca --- /dev/null +++ b/web/ee/silo/jira/components/steps/map-states/root.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { FC, useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; +import { ExState } from "@plane/sdk"; +import { Button } from "@plane/ui"; +import { IStateConfig, JiraStatus } from "@silo/jira"; +// silo hooks +import { usePlaneProjectStates } from "@/plane-web/silo/hooks"; +// silo components +import { MapStatesSelection } from "@/plane-web/silo/jira/components"; +// silo hooks +import { useImporter, useJiraProjectStates } from "@/plane-web/silo/jira/hooks"; +// silo types +import { E_IMPORTER_STEPS, TImporterDataPayload } from "@/plane-web/silo/jira/types"; +// silo ui components +import { StepperNavigation } from "@/plane-web/silo/ui"; + +type TFormData = TImporterDataPayload[E_IMPORTER_STEPS.MAP_STATES]; + +const currentStepKey = E_IMPORTER_STEPS.MAP_STATES; + +export const MapStatesRoot: FC = () => { + // hooks + const { importerData, handleImporterData, handleSyncJobConfig, currentStep, handleStepper } = useImporter(); + const planeProjectId = importerData[E_IMPORTER_STEPS.SELECT_PLANE_PROJECT]?.projectId; + const jiraResourceId = importerData[E_IMPORTER_STEPS.CONFIGURE_JIRA]?.resourceId; + const jiraProjectId = importerData[E_IMPORTER_STEPS.CONFIGURE_JIRA]?.projectId; + const { data: jiraProjectStates, getById: getJiraStateById } = useJiraProjectStates(jiraResourceId, jiraProjectId); + const { data: planeProjectStates, getById: getPlaneStateById } = usePlaneProjectStates(planeProjectId); + // states + const [formData, setFormData] = useState({}); + // derived values + const isNextButtonDisabled = jiraProjectStates?.length === Object.keys(formData).length ? false : true; + // handlers + const handleFormData = (key: T, value: TFormData[T]) => { + setFormData((prevData) => ({ ...prevData, [key]: value })); + }; + + const constructJiraStateSyncJobConfig = () => { + const stateConfig: IStateConfig[] = []; + Object.entries(formData).forEach(([jiraStateId, planeStateId]) => { + if (jiraStateId && planeStateId) { + const jiraState = getJiraStateById(jiraStateId); + const planeState = getPlaneStateById(planeStateId); + if (jiraState && planeState) { + const syncJobConfig = { + source_state: jiraState, + target_state: planeState as unknown as ExState, + }; + stateConfig.push(syncJobConfig); + } + } + }); + return stateConfig; + }; + + const handleOnClickNext = () => { + // validate the sync job config + if (jiraProjectStates?.length === Object.keys(formData).length) { + // update the data in the context + handleImporterData(currentStepKey, formData); + // update the sync job config + const stateConfig = constructJiraStateSyncJobConfig(); + handleSyncJobConfig("state", stateConfig); + // moving to the next state + handleStepper("next"); + } + }; + + useEffect(() => { + const contextData = importerData[currentStepKey]; + if (contextData && !isEqual(contextData, formData)) { + setFormData(contextData); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [importerData]); + + return ( +
+ {/* content */} +
+
+
Jira States
+
Plane States
+
+
+ {jiraProjectStates && + planeProjectStates && + jiraProjectStates.map( + (jiraState: JiraStatus) => + jiraState.id && ( + jiraState.id && handleFormData(jiraState.id, value)} + jiraState={jiraState} + planeStates={planeProjectStates} + /> + ) + )} +
+
+ + {/* stepper button */} +
+ + + +
+
+ ); +}; diff --git a/web/ee/silo/jira/components/steps/map-states/state-selection.tsx b/web/ee/silo/jira/components/steps/map-states/state-selection.tsx new file mode 100644 index 0000000000..251b4dbd54 --- /dev/null +++ b/web/ee/silo/jira/components/steps/map-states/state-selection.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { FC } from "react"; +import { IState } from "@plane/types"; +import { StateGroupIcon } from "@plane/ui"; +import { JiraStatus } from "@silo/jira"; +// silo ui components +import { Dropdown } from "@/plane-web/silo/ui"; + +type TMapStatesSelection = { + value: string | undefined; + handleValue: (value: string | undefined) => void; + jiraState: JiraStatus; + planeStates: IState[]; +}; + +export const MapStatesSelection: FC = (props) => { + const { value, handleValue, jiraState, planeStates } = props; + + return ( +
+
{jiraState.name}
+
+ ({ + key: state.id, + label: state.name, + value: state.id, + data: state, + }))} + value={value} + placeHolder="Select state" + onChange={(value: string | undefined) => handleValue(value)} + iconExtractor={(option) => ( +
+ +
+ )} + queryExtractor={(option) => option.name} + /> +
+
+ ); +}; diff --git a/web/ee/silo/jira/components/steps/root.tsx b/web/ee/silo/jira/components/steps/root.tsx new file mode 100644 index 0000000000..b3952a09e4 --- /dev/null +++ b/web/ee/silo/jira/components/steps/root.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { FC } from "react"; +// constants +import { IMPORTER_STEPS } from "@/plane-web/silo/jira/constants/steps"; +// hooks +import { useImporter } from "@/plane-web/silo/jira/hooks"; +// components +import { Stepper } from "@/plane-web/silo/ui"; +// assets +import JiraLogo from "@/public/services/jira.svg"; + +export const StepsRoot: FC = () => { + // hooks + const { currentStepIndex } = useImporter(); + + return ( +
+ +
+ ); +}; diff --git a/web/ee/silo/jira/components/steps/select-plane-project/index.ts b/web/ee/silo/jira/components/steps/select-plane-project/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/web/ee/silo/jira/components/steps/select-plane-project/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ee/silo/jira/components/steps/select-plane-project/root.tsx b/web/ee/silo/jira/components/steps/select-plane-project/root.tsx new file mode 100644 index 0000000000..e4eb0fd16e --- /dev/null +++ b/web/ee/silo/jira/components/steps/select-plane-project/root.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { FC, useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; +import { Briefcase } from "lucide-react"; +import { ExProject } from "@plane/sdk"; +import { Button } from "@plane/ui"; +// components +import { Logo } from "@/components/common"; +// silo hooks +import { usePlaneProjects } from "@/plane-web/silo/hooks"; +import { useImporter } from "@/plane-web/silo/jira/hooks"; +// silo types +import { E_IMPORTER_STEPS, TImporterDataPayload } from "@/plane-web/silo/jira/types"; +// silo ui components +import { StepperNavigation, Dropdown } from "@/plane-web/silo/ui"; + +type TFormData = TImporterDataPayload[E_IMPORTER_STEPS.SELECT_PLANE_PROJECT]; + +const currentStepKey = E_IMPORTER_STEPS.SELECT_PLANE_PROJECT; + +export const SelectPlaneProjectRoot: FC = () => { + // hooks + const { importerData, handleImporterData, handleSyncJobConfig, currentStep, handleStepper } = useImporter(); + const { data: projects, getById: getProjectById } = usePlaneProjects(); + // states + const [formData, setFormData] = useState({ projectId: undefined }); + // derived values + const isNextButtonDisabled = !formData.projectId; + // handlers + const handleFormData = (value: string | undefined) => { + setFormData({ projectId: value }); + // updating the config data + if (value) { + const currentProject = getProjectById(value); + if (currentProject) handleSyncJobConfig("planeProject", currentProject as ExProject); + } + }; + + const handleOnClickNext = () => { + // update the data in the context + handleImporterData(currentStepKey, formData); + // moving to the next state + handleStepper("next"); + }; + + useEffect(() => { + const contextData = importerData[currentStepKey]; + if (contextData && !isEqual(contextData, formData)) { + setFormData(contextData); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [importerData]); + + return ( +
+ {/* content */} +
+
Select Plane project
+ ({ + key: project.id || "", + label: project.name || "", + value: project.id || "", + data: project, + }))} + value={formData.projectId} + placeHolder="Select plane project" + onChange={(value: string | undefined) => handleFormData(value)} + iconExtractor={(option) => ( +
+ {option && option?.logo_props ? ( + + ) : ( + + )} +
+ )} + queryExtractor={(option) => option.name || ""} + /> +
+ + {/* stepper button */} +
+ + + +
+
+ ); +}; diff --git a/web/ee/silo/jira/components/steps/summary/index.ts b/web/ee/silo/jira/components/steps/summary/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/web/ee/silo/jira/components/steps/summary/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ee/silo/jira/components/steps/summary/root.tsx b/web/ee/silo/jira/components/steps/summary/root.tsx new file mode 100644 index 0000000000..28dca82e60 --- /dev/null +++ b/web/ee/silo/jira/components/steps/summary/root.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { FC, useEffect } from "react"; +import { Button } from "@plane/ui"; +import { TSyncJob, TSyncJobStatus } from "@silo/core"; +import { JiraConfig } from "@silo/jira"; +// silo hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; +import { useJiraSyncJobs } from "@/plane-web/silo/hooks/context/use-jira-sync-jobs"; +import { + useImporter, + useJiraProjectIssuesCount, + useJiraProjectLabels, + useJiraProjectPriorities, + useJiraProjectStates, +} from "@/plane-web/silo/jira/hooks"; +// silo types +import { E_IMPORTER_STEPS } from "@/plane-web/silo/jira/types"; +// silo ui components +import { StepperNavigation } from "@/plane-web/silo/ui"; + +export const SummaryRoot: FC = () => { + // hooks + const { workspaceSlug, workspaceId, userId, userEmail, apiBaseUrl } = useBaseImporter(); + const { + importerData, + currentStep, + syncJobConfig, + handleSyncJobConfig, + handleStepper, + resetImporterData, + setDashboardView, + } = useImporter(); + const planeProjectId = importerData[E_IMPORTER_STEPS.SELECT_PLANE_PROJECT]?.projectId; + const jiraResourceId = importerData[E_IMPORTER_STEPS.CONFIGURE_JIRA]?.resourceId; + const jiraProjectId = importerData[E_IMPORTER_STEPS.CONFIGURE_JIRA]?.projectId; + const { data: jiraProjectStates } = useJiraProjectStates(jiraResourceId, jiraProjectId); + const { data: jiraProjectPriorities } = useJiraProjectPriorities(jiraResourceId, jiraProjectId); + const { data: jiraProjectLabels } = useJiraProjectLabels(jiraResourceId, jiraProjectId); + const { data: jiraProjectIssueCount } = useJiraProjectIssuesCount(jiraResourceId, jiraProjectId); + const { createJobConfiguration, createJob, startJob } = useJiraSyncJobs(); + + useEffect(() => { + if (jiraProjectLabels && jiraProjectLabels.length > 0) { + handleSyncJobConfig("label", jiraProjectLabels); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [jiraProjectLabels]); + + const handleOnClickNext = async () => { + if (planeProjectId) { + // create a new config and the sync job + try { + const importerConfig = await createJobConfiguration(syncJobConfig as JiraConfig); + if (importerConfig && importerConfig?.insertedId) { + const syncJobPayload: Partial = { + workspace_slug: workspaceSlug, + workspace_id: workspaceId, + project_id: planeProjectId, + initiator_id: userId, + initiator_email: userEmail, + config: importerConfig?.insertedId, + migration_type: "JIRA", + target_hostname: apiBaseUrl, + status: "" as TSyncJobStatus, + }; + const importerCreateJob = await createJob(planeProjectId, syncJobPayload); + if (importerCreateJob && importerCreateJob?.insertedId) { + await startJob(importerCreateJob?.insertedId); + setDashboardView(true); + // clearing the existing data in the context + resetImporterData(); + // moving to the next state + handleStepper("next"); + } + } + } catch (error) { + console.error("error", error); + } + } + }; + + return ( +
+ {/* content */} +
+
+
Jira Entities
+
Migrating
+
+
+
+
Issues
+
{jiraProjectIssueCount || 0} issues
+
+
+
Labels
+
{jiraProjectLabels?.length || 0} labels
+
+
+
States
+
{jiraProjectStates?.length || 0} states
+
+
+
Priorities
+
{jiraProjectPriorities?.length || 0} priorities
+
+
+
+ + {/* stepper button */} +
+ + + +
+
+ ); +}; diff --git a/web/ee/silo/jira/constants/steps.tsx b/web/ee/silo/jira/constants/steps.tsx new file mode 100644 index 0000000000..1f86fd56b8 --- /dev/null +++ b/web/ee/silo/jira/constants/steps.tsx @@ -0,0 +1,74 @@ +"use client"; +import { Layers2, Layers3, UsersRound, SignalHigh, ReceiptText } from "lucide-react"; +// components +import { + SelectPlaneProjectRoot, + ConfigureJiraRoot, + ImportUsersFromJira, + MapStatesRoot, + MapPriorityRoot, + SummaryRoot, +} from "@/plane-web/silo/jira/components"; +// types +import { E_IMPORTER_STEPS, TImporterStep } from "@/plane-web/silo/jira/types"; + +export const IMPORTER_STEPS: TImporterStep[] = [ + { + key: E_IMPORTER_STEPS.SELECT_PLANE_PROJECT, + icon: () => , + title: "Configure Plane", + description: + "Please first create the project in Plane where you intend to migrate your Jira data. Once the project is created, select it here.", + component: () => , + prevStep: undefined, + nextStep: E_IMPORTER_STEPS.CONFIGURE_JIRA, + }, + { + key: E_IMPORTER_STEPS.CONFIGURE_JIRA, + icon: () => , + title: "Configure Jira", + description: "Please select the Jira workspace and project from which you want to migrate your data.", + component: () => , + prevStep: E_IMPORTER_STEPS.SELECT_PLANE_PROJECT, + nextStep: E_IMPORTER_STEPS.IMPORT_USERS_FROM_JIRA, + }, + { + key: E_IMPORTER_STEPS.IMPORT_USERS_FROM_JIRA, + icon: () => , + title: "Import users", + description: + "Please add the users you wish to migrate from Jira to Plane. Alternatively, you can skip this step and manually add users later", + component: () => , + prevStep: E_IMPORTER_STEPS.CONFIGURE_JIRA, + nextStep: E_IMPORTER_STEPS.MAP_STATES, + }, + { + key: E_IMPORTER_STEPS.MAP_STATES, + icon: () => , + title: "Map states", + description: + "We have automatically matched the Jira statuses to Plane states to the best of our ability. Please map any remaining states before proceeding, you can also create states and map them manually.", + component: () => , + prevStep: E_IMPORTER_STEPS.IMPORT_USERS_FROM_JIRA, + nextStep: E_IMPORTER_STEPS.MAP_PRIORITY, + }, + { + key: E_IMPORTER_STEPS.MAP_PRIORITY, + icon: () => , + title: "Map priorities", + description: + "We have automatically matched the priorities to the best of our ability. Please map any remaining priorities before proceeding.", + component: () => , + prevStep: E_IMPORTER_STEPS.MAP_STATES, + nextStep: E_IMPORTER_STEPS.SUMMARY, + }, + { + key: E_IMPORTER_STEPS.SUMMARY, + icon: () => , + title: "Summary", + description: "Here is a summary of the data that will be migrated from Jira to Plane.", + component: () => , + prevStep: E_IMPORTER_STEPS.MAP_PRIORITY, + nextStep: E_IMPORTER_STEPS.SELECT_PLANE_PROJECT, + }, +]; diff --git a/web/ee/silo/jira/contexts/importer.tsx b/web/ee/silo/jira/contexts/importer.tsx new file mode 100644 index 0000000000..38d78677da --- /dev/null +++ b/web/ee/silo/jira/contexts/importer.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { createContext, Dispatch, ReactNode, SetStateAction, useState } from "react"; +import { JiraConfig } from "@silo/jira"; +// silo hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; +// silo constants +import { IMPORTER_STEPS } from "@/plane-web/silo/jira/constants/steps"; +// silo services +import { ImporterAuthService } from "@/plane-web/silo/jira/services/auth.service"; +import { JiraService } from "@/plane-web/silo/jira/services/jira.service"; +// silo types +import { + E_IMPORTER_STEPS, + TImporterStepKeys, + TImporterStep, + TImporterDataPayload, + E_FORM_RADIO_DATA, +} from "@/plane-web/silo/jira/types"; + +type TImporterCreateContext = { + // services + importerAuthService: ImporterAuthService; + jiraService: JiraService; + // stepper state management + currentStep: TImporterStep; + currentStepIndex: number; + handleStepper: (direction: "previous" | "next") => void; + // state + importerData: TImporterDataPayload; + handleImporterData: (key: T, value: TImporterDataPayload[T]) => void; + syncJobConfig: Partial; + handleSyncJobConfig: (key: T, config: JiraConfig[T]) => void; + resetImporterData: () => void; + setDashboardView: Dispatch>; +}; + +export const ImporterContext = createContext({} as TImporterCreateContext); + +const defaultImporterData: TImporterDataPayload = { + [E_IMPORTER_STEPS.SELECT_PLANE_PROJECT]: { + projectId: undefined, + }, + [E_IMPORTER_STEPS.CONFIGURE_JIRA]: { + resourceId: undefined, + projectId: undefined, + issueType: E_FORM_RADIO_DATA.CREATE_AS_LABEL, + }, + [E_IMPORTER_STEPS.IMPORT_USERS_FROM_JIRA]: { + userSkipToggle: false, + userData: undefined, + }, + [E_IMPORTER_STEPS.MAP_STATES]: {}, + [E_IMPORTER_STEPS.MAP_PRIORITY]: {}, +}; + +type TImporterContext = { + setDashboardView: Dispatch>; + children: ReactNode; +}; + +export const ImporterContextProvider = (props: TImporterContext) => { + // props + const { setDashboardView, children } = props; + // hooks + const { siloBaseUrl } = useBaseImporter(); + // initiating services + const importerAuthService = new ImporterAuthService(siloBaseUrl); + const jiraService = new JiraService(siloBaseUrl); + // states + const [stepper, setStepper] = useState(E_IMPORTER_STEPS.SELECT_PLANE_PROJECT); + const [importerData, setImporterData] = useState(defaultImporterData); + const [syncJobConfig, setSyncJobConfig] = useState>({ + issueType: E_FORM_RADIO_DATA.CREATE_AS_LABEL, + }); + + // derived values + const currentStepIndex = IMPORTER_STEPS.findIndex((step) => step.key === stepper); + const currentStep = IMPORTER_STEPS[currentStepIndex]; + + // handlers + const handleStepper = (direction: "previous" | "next") => { + if (direction === "previous") { + if (currentStep.prevStep) setStepper(currentStep.prevStep); + } else { + if (currentStep.nextStep) setStepper(currentStep.nextStep); + } + }; + + const handleImporterData: (key: T, value: TImporterDataPayload[T]) => void = ( + key, + value + ) => { + setImporterData((prevData) => ({ ...prevData, [key]: value })); + }; + + const handleSyncJobConfig = (key: T, config: JiraConfig[T]) => { + setSyncJobConfig((prevConfig) => ({ ...prevConfig, [key]: config })); + }; + + const resetImporterData = () => { + setImporterData(defaultImporterData); + setSyncJobConfig({}); + }; + + return ( + + {children} + + ); +}; + +export default ImporterContext; diff --git a/web/ee/silo/jira/contexts/index.ts b/web/ee/silo/jira/contexts/index.ts new file mode 100644 index 0000000000..b21106af6a --- /dev/null +++ b/web/ee/silo/jira/contexts/index.ts @@ -0,0 +1 @@ +export * from "./importer"; diff --git a/web/ee/silo/jira/hooks/index.ts b/web/ee/silo/jira/hooks/index.ts new file mode 100644 index 0000000000..01c156074c --- /dev/null +++ b/web/ee/silo/jira/hooks/index.ts @@ -0,0 +1,2 @@ +export * from "./use-importer"; +export * from "./jira"; diff --git a/web/ee/silo/jira/hooks/jira/index.ts b/web/ee/silo/jira/hooks/jira/index.ts new file mode 100644 index 0000000000..855978b34b --- /dev/null +++ b/web/ee/silo/jira/hooks/jira/index.ts @@ -0,0 +1,6 @@ +export * from "./use-resources"; +export * from "./use-projects"; +export * from "./use-project-states"; +export * from "./use-project-priorities"; +export * from "./use-project-labels"; +export * from "./use-project-issues-count"; diff --git a/web/ee/silo/jira/hooks/jira/use-project-issues-count.ts b/web/ee/silo/jira/hooks/jira/use-project-issues-count.ts new file mode 100644 index 0000000000..648073b15c --- /dev/null +++ b/web/ee/silo/jira/hooks/jira/use-project-issues-count.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; +import useSWR from "swr"; +// hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; +import { useImporter } from "@/plane-web/silo/jira/hooks"; + +export const useJiraProjectIssuesCount = (resourceId: string | undefined, projectId: string | undefined) => { + // hooks + const { workspaceId, userId } = useBaseImporter(); + const { jiraService } = useImporter(); + + // states + const [jiraProjectIssueCount, setJiraProjectIssueCount] = useState(undefined); + + const { data, isLoading, error, mutate } = useSWR( + workspaceId && userId && resourceId && projectId + ? `JIRA_PROJECT_ISSUE_COUNT_${workspaceId}_${userId}_${resourceId}_${projectId}` + : null, + workspaceId && userId && resourceId && projectId + ? async () => await jiraService.getProjectIssuesCount(workspaceId, userId, resourceId, projectId) + : null + ); + + // update the project states + useEffect(() => { + if ((!jiraProjectIssueCount && data) || (jiraProjectIssueCount && data && jiraProjectIssueCount !== data)) { + setJiraProjectIssueCount(data); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + return { + data: jiraProjectIssueCount, + isLoading, + error, + mutate, + }; +}; diff --git a/web/ee/silo/jira/hooks/jira/use-project-labels.ts b/web/ee/silo/jira/hooks/jira/use-project-labels.ts new file mode 100644 index 0000000000..27cb1ba8fc --- /dev/null +++ b/web/ee/silo/jira/hooks/jira/use-project-labels.ts @@ -0,0 +1,40 @@ +import { useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; +import useSWR from "swr"; +import { ILabelConfig } from "@silo/jira"; +// hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; +import { useImporter } from "@/plane-web/silo/jira/hooks"; + +export const useJiraProjectLabels = (resourceId: string | undefined, projectId: string | undefined) => { + // hooks + const { workspaceId, userId } = useBaseImporter(); + const { jiraService } = useImporter(); + + // states + const [jiraProjectLabels, setJiraProjectLabels] = useState(undefined); + + const { data, isLoading, error, mutate } = useSWR( + workspaceId && userId && resourceId && projectId + ? `JIRA_PROJECT_LABELS_${workspaceId}_${userId}_${resourceId}_${projectId}` + : null, + workspaceId && userId && resourceId && projectId + ? async () => await jiraService.getProjectLabels(workspaceId, userId, resourceId, projectId) + : null + ); + + // update the project states + useEffect(() => { + if ((!jiraProjectLabels && data) || (jiraProjectLabels && data && !isEqual(jiraProjectLabels, data))) { + setJiraProjectLabels(data); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + return { + data: jiraProjectLabels, + isLoading, + error, + mutate, + }; +}; diff --git a/web/ee/silo/jira/hooks/jira/use-project-priorities.ts b/web/ee/silo/jira/hooks/jira/use-project-priorities.ts new file mode 100644 index 0000000000..a04fb79283 --- /dev/null +++ b/web/ee/silo/jira/hooks/jira/use-project-priorities.ts @@ -0,0 +1,44 @@ +import { useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; +import useSWR from "swr"; +import { JiraPriority } from "@silo/jira"; +// hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; +import { useImporter } from "@/plane-web/silo/jira/hooks"; + +export const useJiraProjectPriorities = (resourceId: string | undefined, projectId: string | undefined) => { + // hooks + const { workspaceId, userId } = useBaseImporter(); + const { jiraService } = useImporter(); + + // states + const [jiraProjectPriorities, setJiraProjectPriorities] = useState(undefined); + + const { data, isLoading, error, mutate } = useSWR( + workspaceId && userId && resourceId && projectId + ? `JIRA_PROJECT_PRIORITIES_${workspaceId}_${userId}_${resourceId}_${projectId}` + : null, + workspaceId && userId && resourceId && projectId + ? async () => await jiraService.getProjectPriorities(workspaceId, userId, resourceId, projectId) + : null + ); + + // update the project states + useEffect(() => { + if ((!jiraProjectPriorities && data) || (jiraProjectPriorities && data && !isEqual(jiraProjectPriorities, data))) { + setJiraProjectPriorities(data); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + // get project priority by id + const getById = (id: string) => jiraProjectPriorities?.find((priority) => priority.id === id); + + return { + data: jiraProjectPriorities, + isLoading, + error, + mutate, + getById, + }; +}; diff --git a/web/ee/silo/jira/hooks/jira/use-project-states.ts b/web/ee/silo/jira/hooks/jira/use-project-states.ts new file mode 100644 index 0000000000..64c34796a8 --- /dev/null +++ b/web/ee/silo/jira/hooks/jira/use-project-states.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; +import useSWR from "swr"; +import { JiraStatus } from "@silo/jira"; +// hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; +import { useImporter } from "@/plane-web/silo/jira/hooks"; + +export const useJiraProjectStates = (resourceId: string | undefined, projectId: string | undefined) => { + // hooks + const { workspaceId, userId } = useBaseImporter(); + const { jiraService } = useImporter(); + + // states + const [jiraProjectStates, setJiraProjectStates] = useState(undefined); + + // fetch jira project states + const { data, isLoading, error, mutate } = useSWR( + workspaceId && userId && resourceId && projectId + ? `JIRA_PROJECT_STATES_${workspaceId}_${userId}_${resourceId}_${projectId}` + : null, + workspaceId && userId && resourceId && projectId + ? async () => await jiraService.getProjectStates(workspaceId, userId, resourceId, projectId) + : null + ); + + // update the project states + useEffect(() => { + if ((!jiraProjectStates && data) || (jiraProjectStates && data && !isEqual(jiraProjectStates, data))) { + const jiraProjectStates = data; + if (jiraProjectStates.length > 0) { + const allStatuses = jiraProjectStates.flatMap((item) => item.statuses); + const uniqueStatuses = Array.from(new Map(allStatuses.map((status) => [status.id, status])).values()); + setJiraProjectStates(uniqueStatuses); + } else { + setJiraProjectStates([]); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + // get project state by id + const getById = (id: string) => jiraProjectStates?.find((state) => state.id === id); + + return { + data: jiraProjectStates, + isLoading, + error, + mutate, + getById, + }; +}; diff --git a/web/ee/silo/jira/hooks/jira/use-projects.ts b/web/ee/silo/jira/hooks/jira/use-projects.ts new file mode 100644 index 0000000000..05390404ca --- /dev/null +++ b/web/ee/silo/jira/hooks/jira/use-projects.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; +import useSWR from "swr"; +import { JiraProject } from "@silo/jira"; +// hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; +import { useImporter } from "@/plane-web/silo/jira/hooks"; + +export const useJiraProjects = (resourceId: string | undefined) => { + // hooks + const { workspaceId, userId } = useBaseImporter(); + const { jiraService } = useImporter(); + + // states + const [jiraProjects, setJiraProjects] = useState(undefined); + + // fetch jira projects + const { data, isLoading, error, mutate } = useSWR( + workspaceId && userId && resourceId ? `JIRA_PROJECTS_${workspaceId}_${userId}_${resourceId}` : null, + workspaceId && userId && resourceId + ? async () => await jiraService.getProjects(workspaceId, userId, resourceId) + : null + ); + + useEffect(() => { + if ((!jiraProjects && data) || (jiraProjects && data && !isEqual(jiraProjects, data))) { + setJiraProjects(data); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + // get project by id + const getById = (id: string) => jiraProjects?.find((project) => project.id === id); + + return { + data: jiraProjects, + isLoading, + error, + mutate, + getById, + }; +}; diff --git a/web/ee/silo/jira/hooks/jira/use-resources.ts b/web/ee/silo/jira/hooks/jira/use-resources.ts new file mode 100644 index 0000000000..607f350a76 --- /dev/null +++ b/web/ee/silo/jira/hooks/jira/use-resources.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; +import useSWR from "swr"; +import { JiraResource } from "@silo/jira"; +// hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; +import { useImporter } from "@/plane-web/silo/jira/hooks"; + +export const useJiraResources = () => { + // hooks + const { workspaceId, userId } = useBaseImporter(); + const { jiraService } = useImporter(); + + // states + const [jiraResources, setJiraResources] = useState(undefined); + + // fetch resources + const { data, isLoading, error, mutate } = useSWR( + `JIRA_RESOURCES_${workspaceId}_${userId}`, + async () => await jiraService.getResources(workspaceId, userId) + ); + + // update the resources + useEffect(() => { + if ((!jiraResources && data) || (jiraResources && data && !isEqual(jiraResources, data))) { + setJiraResources(data); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + // get resource by id + const getById = (id: string) => jiraResources?.find((resource) => resource.id === id); + + return { + data: jiraResources, + isLoading, + error, + mutate, + getById, + }; +}; diff --git a/web/ee/silo/jira/hooks/use-importer.ts b/web/ee/silo/jira/hooks/use-importer.ts new file mode 100644 index 0000000000..905d693d74 --- /dev/null +++ b/web/ee/silo/jira/hooks/use-importer.ts @@ -0,0 +1,13 @@ +import { useContext } from "react"; +// contexts +import { ImporterContext } from "@/plane-web/silo/jira/contexts"; + +export function useImporter() { + const context = useContext(ImporterContext); + + if (!context) { + throw new Error("useImporter must be used within an ImportContextProvider"); + } + + return context; +} diff --git a/web/ee/silo/jira/services/auth.service.ts b/web/ee/silo/jira/services/auth.service.ts new file mode 100644 index 0000000000..3d3a40dbef --- /dev/null +++ b/web/ee/silo/jira/services/auth.service.ts @@ -0,0 +1,26 @@ +import axios, { AxiosInstance } from "axios"; +import { JiraAuthState } from "@silo/jira"; + +export class ImporterAuthService { + protected baseURL: string; + private axiosInstance: AxiosInstance; + + constructor(baseURL: string) { + this.baseURL = baseURL; + this.axiosInstance = axios.create({ baseURL }); + } + + /** + * @description authenticate the service + * @property payload: JiraAuthState + * @redirects to the Jira authentication URL + */ + async jiraAuthentication(payload: JiraAuthState) { + return this.axiosInstance + .post(`/silo/api/jira/auth/url`, payload) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/web/ee/silo/jira/services/jira.service.ts b/web/ee/silo/jira/services/jira.service.ts new file mode 100644 index 0000000000..b684a8cafe --- /dev/null +++ b/web/ee/silo/jira/services/jira.service.ts @@ -0,0 +1,131 @@ +import axios, { AxiosInstance } from "axios"; +import { JiraResource, JiraProject, JiraStates, JiraPriority, ILabelConfig } from "@silo/jira/"; + +export class JiraService { + protected baseURL: string; + private axiosInstance: AxiosInstance; + + constructor(baseURL: string) { + this.baseURL = baseURL; + this.axiosInstance = axios.create({ baseURL }); + } + + /** + * @description get workspaces + * @property workspaceId: string + * @property userId: string + * @returns workspaces | undefined + */ + async getResources(workspaceId: string, userId: string): Promise { + return this.axiosInstance + .post(`/silo/api/jira/resources`, { workspaceId, userId }) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description get projects + * @property workspaceId: string + * @property userId: string + * @property resourceId: string + * @returns projects | undefined + */ + async getProjects(workspaceId: string, userId: string, resourceId: string): Promise { + return this.axiosInstance + .post(`/silo/api/jira/projects/`, { workspaceId, userId, cloudId: resourceId }) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description get project states + * @property workspaceId: string + * @property userId: string + * @property resourceId: string + * @property projectId: string + * @returns states | undefined + */ + async getProjectStates( + workspaceId: string, + userId: string, + resourceId: string, + projectId: string + ): Promise { + return this.axiosInstance + .post(`/silo/api/jira/states/`, { workspaceId, userId, cloudId: resourceId, projectId }) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description get project priorities + * @property workspaceId: string + * @property userId: string + * @property resourceId: string + * @property projectId: string + * @returns priorities | undefined + */ + async getProjectPriorities( + workspaceId: string, + userId: string, + resourceId: string, + projectId: string + ): Promise { + return this.axiosInstance + .post(`/silo/api/jira/priorities/`, { workspaceId, userId, cloudId: resourceId, projectId }) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description get project labels + * @property workspaceId: string + * @property userId: string + * @property resourceId: string + * @property projectId: string + * @returns project | undefined + */ + async getProjectLabels( + workspaceId: string, + userId: string, + resourceId: string, + projectId: string + ): Promise { + return this.axiosInstance + .post(`/silo/api/jira/labels/`, { workspaceId, userId, cloudId: resourceId, projectId }) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description get project issues count + * @property workspaceId: string + * @property userId: string + * @property resourceId: string + * @property projectId: string + * @returns project | undefined + */ + async getProjectIssuesCount( + workspaceId: string, + userId: string, + resourceId: string, + projectId: string + ): Promise { + return this.axiosInstance + .post(`/silo/api/jira/issue-count`, { workspaceId, userId, cloudId: resourceId, projectId }) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/web/ee/silo/jira/types/importer.ts b/web/ee/silo/jira/types/importer.ts new file mode 100644 index 0000000000..fd40102655 --- /dev/null +++ b/web/ee/silo/jira/types/importer.ts @@ -0,0 +1,28 @@ +import { E_IMPORTER_STEPS } from "@/plane-web/silo/jira/types"; + +export enum E_FORM_RADIO_DATA { + CREATE_AS_LABEL = "create_as_label", + ADD_IN_TITLE = "add_in_title", +} +export type TFormRadioData = E_FORM_RADIO_DATA.CREATE_AS_LABEL | E_FORM_RADIO_DATA.ADD_IN_TITLE; + +export type TImporterDataPayload = { + [E_IMPORTER_STEPS.SELECT_PLANE_PROJECT]: { + projectId: string | undefined; + }; + [E_IMPORTER_STEPS.CONFIGURE_JIRA]: { + resourceId: string | undefined; + projectId: string | undefined; + issueType: TFormRadioData; + }; + [E_IMPORTER_STEPS.IMPORT_USERS_FROM_JIRA]: { + userSkipToggle: boolean; + userData: string | undefined; + }; + [E_IMPORTER_STEPS.MAP_STATES]: { + [key: string]: string | undefined; + }; + [E_IMPORTER_STEPS.MAP_PRIORITY]: { + [key: string]: string | undefined; + }; +}; diff --git a/web/ee/silo/jira/types/index.ts b/web/ee/silo/jira/types/index.ts new file mode 100644 index 0000000000..9887c04072 --- /dev/null +++ b/web/ee/silo/jira/types/index.ts @@ -0,0 +1,2 @@ +export * from "./steps"; +export * from "./importer"; diff --git a/web/ee/silo/jira/types/steps.ts b/web/ee/silo/jira/types/steps.ts new file mode 100644 index 0000000000..5de856fb97 --- /dev/null +++ b/web/ee/silo/jira/types/steps.ts @@ -0,0 +1,21 @@ +// types +import { TStepperBlock } from "@/plane-web/silo/types/ui"; + +export enum E_IMPORTER_STEPS { + SELECT_PLANE_PROJECT = "select-plane-project", + CONFIGURE_JIRA = "configure-jira", + IMPORT_USERS_FROM_JIRA = "import-users-from-jira", + MAP_STATES = "map-states", + MAP_PRIORITY = "map-priority", + SUMMARY = "summary", +} + +export type TImporterStepKeys = + | E_IMPORTER_STEPS.SELECT_PLANE_PROJECT + | E_IMPORTER_STEPS.CONFIGURE_JIRA + | E_IMPORTER_STEPS.IMPORT_USERS_FROM_JIRA + | E_IMPORTER_STEPS.MAP_STATES + | E_IMPORTER_STEPS.MAP_PRIORITY + | E_IMPORTER_STEPS.SUMMARY; + +export type TImporterStep = TStepperBlock; diff --git a/web/ee/silo/linear/components/authentication/index.ts b/web/ee/silo/linear/components/authentication/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/web/ee/silo/linear/components/authentication/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ee/silo/linear/components/authentication/root.tsx b/web/ee/silo/linear/components/authentication/root.tsx new file mode 100644 index 0000000000..d3b5d9be96 --- /dev/null +++ b/web/ee/silo/linear/components/authentication/root.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { FC } from "react"; +import { Button } from "@plane/ui"; +import { LinearAuthState } from "@silo/linear"; +// hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; +import { useImporter } from "@/plane-web/silo/linear/hooks"; + +export const UserAuthentication: FC = () => { + // hooks + const { workspaceSlug, workspaceId, userId, serviceToken } = useBaseImporter(); + const { importerAuthService } = useImporter(); + + const handleAuthentication = async () => { + if (!serviceToken) return; + + const payload: LinearAuthState = { + workspaceSlug, + workspaceId, + userId, + apiToken: serviceToken, + }; + + try { + const response = await importerAuthService.linearAuthentication(payload); + window.open(response); + } catch (error) { + console.error("error", error); + } + }; + + return ( +
+
+
+ Linear to Plane Migration Assistant +
+

+ Seamlessly migrate your linear projects to Plane with our powerful assistant. +

+
+ {serviceToken && workspaceSlug && workspaceId && userId && ( + + )} +
+ ); +}; diff --git a/web/ee/silo/linear/components/dashboard/empty-state.tsx b/web/ee/silo/linear/components/dashboard/empty-state.tsx new file mode 100644 index 0000000000..5781437104 --- /dev/null +++ b/web/ee/silo/linear/components/dashboard/empty-state.tsx @@ -0,0 +1,5 @@ +export const DashboardEmptyState = () => ( +
+
No Migrations are available
+
+); diff --git a/web/ee/silo/linear/components/dashboard/icon-field-render.tsx b/web/ee/silo/linear/components/dashboard/icon-field-render.tsx new file mode 100644 index 0000000000..c943069828 --- /dev/null +++ b/web/ee/silo/linear/components/dashboard/icon-field-render.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { FC, ReactNode } from "react"; + +type TIconFieldRender = { + icon?: ReactNode; + title?: string; +}; + +export const IconFieldRender: FC = (props) => { + const { icon, title } = props; + + if (!icon && !title) return "-"; + if (!icon && title) return title; + return ( +
+
+ {icon} +
+
{title}
+
+ ); +}; diff --git a/web/ee/silo/linear/components/dashboard/index.ts b/web/ee/silo/linear/components/dashboard/index.ts new file mode 100644 index 0000000000..11cde0f654 --- /dev/null +++ b/web/ee/silo/linear/components/dashboard/index.ts @@ -0,0 +1,7 @@ +export * from "./root"; + +export * from "./loader"; +export * from "./empty-state"; + +export * from "./icon-field-render"; +export * from "./status"; diff --git a/web/ee/silo/linear/components/dashboard/loader.tsx b/web/ee/silo/linear/components/dashboard/loader.tsx new file mode 100644 index 0000000000..338fcdafba --- /dev/null +++ b/web/ee/silo/linear/components/dashboard/loader.tsx @@ -0,0 +1,7 @@ +import { Loader } from "@plane/ui"; + +export const DashboardLoadingState = () => ( + + + +); diff --git a/web/ee/silo/linear/components/dashboard/root.tsx b/web/ee/silo/linear/components/dashboard/root.tsx new file mode 100644 index 0000000000..8f401029a7 --- /dev/null +++ b/web/ee/silo/linear/components/dashboard/root.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { Dispatch, FC, SetStateAction } from "react"; +import Image from "next/image"; +import { Briefcase, RefreshCcw } from "lucide-react"; +import { TLogoProps } from "@plane/types"; +import { Button } from "@plane/ui"; +// components +import { Logo } from "@/components/common"; +// silo context +import { useLinearSyncJobs } from "@/plane-web/silo/hooks/context/use-linear-sync-jobs"; +// silo components +import { IconFieldRender, SyncJobStatus } from "@/plane-web/silo/linear/components"; +// assets +import LinearLogo from "@/public/services/linear.svg"; + +type TDashboard = { + setIsDashboardView: Dispatch>; +}; + +export const Dashboard: FC = (props) => { + // props + const { setIsDashboardView } = props; + // hooks + const { allSyncJobs, startJob } = useLinearSyncJobs(); + + return ( +
+
Imports
+ + {/* header */} +
+
+ {`Linear +
+
+
Linear
+
Import your Linear data into plane projects.
+
+
+ +
+
+ + {/* migrations */} +
+
Migrations
+
+ + + + + + + + + + + + + + + {allSyncJobs && + allSyncJobs.length > 0 && + allSyncJobs.map((job, index) => ( + + + + + + + + + + + ))} + +
Sr No.Migration IdPlane ProjectLinear WorkspaceLinear ProjectStatusRe RunStart Time
{index + 1}{job.id} + {/* + ) : ( + + ) + } + title={job?.config?.meta?.planeProject?.name} + /> */} + + {/* + } + title={job?.config?.meta?.resource?.name} + /> */} + + {/* + } + title={job?.config?.meta?.project?.name} + /> */} + + + + + {job?.start_time?.toString() || "-"}
+
+
+
+ ); +}; diff --git a/web/ee/silo/linear/components/dashboard/status.tsx b/web/ee/silo/linear/components/dashboard/status.tsx new file mode 100644 index 0000000000..9ad4d33ffa --- /dev/null +++ b/web/ee/silo/linear/components/dashboard/status.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { FC } from "react"; +import { observer } from "mobx-react"; +import { E_JOB_STATUS, TSyncJobStatus } from "@silo/core"; +// helpers +import { cn } from "@/helpers/common.helper"; + +type TSyncJobStatusProps = { + status: TSyncJobStatus; +}; + +const STATUS_CLASSNAMES: { [key in TSyncJobStatus]: string } = { + [E_JOB_STATUS.INITIATED]: "text-gray-500 border border-gray-500 bg-gray-500/10", + [E_JOB_STATUS.PULLING]: "text-yellow-500 border border-yellow-500 bg-yellow-500/10", + [E_JOB_STATUS.PULLED]: "text-yellow-500 border border-yellow-500 bg-yellow-500/10", + [E_JOB_STATUS.TRANSFORMING]: "text-orange-500 border border-orange-500 bg-orange-500/10", + [E_JOB_STATUS.TRANSFORMED]: "text-orange-500 border border-orange-500 bg-orange-500/10", + [E_JOB_STATUS.PUSHING]: "text-green-500 border border-green-500 bg-green-500/10", + [E_JOB_STATUS.FINISHED]: "text-green-500 border border-green-500 bg-green-500/10", + [E_JOB_STATUS.ERROR]: "text-red-500 border border-red-500 bg-red-500/10", +}; + +export const SyncJobStatus: FC = observer((props) => { + const { status } = props; + + return ( +
+ {status} +
+ ); +}); diff --git a/web/ee/silo/linear/components/index.ts b/web/ee/silo/linear/components/index.ts new file mode 100644 index 0000000000..a03527a759 --- /dev/null +++ b/web/ee/silo/linear/components/index.ts @@ -0,0 +1,4 @@ +export * from "./authentication"; + +export * from "./dashboard"; +export * from "./steps"; diff --git a/web/ee/silo/linear/components/steps/configure-linear/index.ts b/web/ee/silo/linear/components/steps/configure-linear/index.ts new file mode 100644 index 0000000000..b07df174f7 --- /dev/null +++ b/web/ee/silo/linear/components/steps/configure-linear/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./select-team"; diff --git a/web/ee/silo/linear/components/steps/configure-linear/root.tsx b/web/ee/silo/linear/components/steps/configure-linear/root.tsx new file mode 100644 index 0000000000..27e87ffcf7 --- /dev/null +++ b/web/ee/silo/linear/components/steps/configure-linear/root.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { FC, useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; +import { Button } from "@plane/ui"; +// silo components +import { ConfigureLinearSelectTeam } from "@/plane-web/silo/linear/components"; +// silo hooks +import { useImporter } from "@/plane-web/silo/linear/hooks"; +// silo types +import { E_IMPORTER_STEPS, TImporterDataPayload } from "@/plane-web/silo/linear/types"; +// silo ui components +import { StepperNavigation } from "@/plane-web/silo/ui"; + +type TFormData = TImporterDataPayload[E_IMPORTER_STEPS.CONFIGURE_LINEAR]; + +const currentStepKey = E_IMPORTER_STEPS.CONFIGURE_LINEAR; + +export const ConfigureLinearRoot: FC = () => { + // hooks + const { importerData, handleImporterData, currentStep, handleStepper } = useImporter(); + // states + const [formData, setFormData] = useState({ + teamId: undefined, + }); + // derived values + const isNextButtonDisabled = !formData?.teamId; + // handlers + const handleFormData = (key: T, value: TFormData[T]) => { + setFormData((prevData) => ({ ...prevData, [key]: value })); + }; + + const handleOnClickNext = () => { + // update the data in the context + handleImporterData(currentStepKey, formData); + // moving to the next state + handleStepper("next"); + }; + + useEffect(() => { + const contextData = importerData[currentStepKey]; + if (contextData && !isEqual(contextData, formData)) { + setFormData(contextData); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [importerData]); + + return ( +
+ {/* content */} +
+ handleFormData("teamId", value)} + /> +
+ + {/* stepper button */} +
+ + + +
+
+ ); +}; diff --git a/web/ee/silo/linear/components/steps/configure-linear/select-team.tsx b/web/ee/silo/linear/components/steps/configure-linear/select-team.tsx new file mode 100644 index 0000000000..58aaa3df54 --- /dev/null +++ b/web/ee/silo/linear/components/steps/configure-linear/select-team.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { FC } from "react"; +// silo hooks +import { useImporter, useLinearTeams } from "@/plane-web/silo/linear/hooks"; +// silo ui components +import { Dropdown } from "@/plane-web/silo/ui"; + +type TConfigureLinearSelectTeam = { + value: string | undefined; + handleFormData: (value: string | undefined) => void; +}; + +export const ConfigureLinearSelectTeam: FC = (props) => { + // props + const { value, handleFormData } = props; + // hooks + const { handleSyncJobConfig } = useImporter(); + const { data: linearTeams, getById: getTeamById } = useLinearTeams(); + + const handelData = (value: string | undefined) => { + handleFormData(value); + // updating the config data + if (value) { + const teamData = getTeamById(value); + if (teamData && teamData.id) handleSyncJobConfig("teamId", teamData.id); + // if (teamData) handleSyncJobConfig("teamUrl", teamData.); + } + }; + + return ( +
+
Select linear team
+ ({ + key: resource.id, + label: resource.name, + value: resource.id, + data: resource, + }))} + value={value} + placeHolder="Select linear team" + onChange={(value: string | undefined) => handelData(value)} + // iconExtractor={(option) => ( + //
+ // {option && option.avatarUrl && ( + // {option.name} + // )} + //
+ // )} + queryExtractor={(option) => option.name} + /> +
+ ); +}; diff --git a/web/ee/silo/linear/components/steps/index.ts b/web/ee/silo/linear/components/steps/index.ts new file mode 100644 index 0000000000..45f96000e1 --- /dev/null +++ b/web/ee/silo/linear/components/steps/index.ts @@ -0,0 +1,6 @@ +export * from "./root"; + +export * from "./select-plane-project"; +export * from "./configure-linear"; +export * from "./map-states"; +export * from "./summary"; diff --git a/web/ee/silo/linear/components/steps/map-states/index.ts b/web/ee/silo/linear/components/steps/map-states/index.ts new file mode 100644 index 0000000000..943d6bbb91 --- /dev/null +++ b/web/ee/silo/linear/components/steps/map-states/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./state-selection"; diff --git a/web/ee/silo/linear/components/steps/map-states/root.tsx b/web/ee/silo/linear/components/steps/map-states/root.tsx new file mode 100644 index 0000000000..5ef6a5fc65 --- /dev/null +++ b/web/ee/silo/linear/components/steps/map-states/root.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { FC, useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; +import { ExState } from "@plane/sdk"; +import { Button } from "@plane/ui"; +import { IStateConfig, LinearState } from "@silo/linear"; +// silo hooks +import { usePlaneProjectStates } from "@/plane-web/silo/hooks"; +// silo components +import { MapStatesSelection } from "@/plane-web/silo/linear/components"; +// silo hooks +import { useImporter, useLinearTeamStates } from "@/plane-web/silo/linear/hooks"; +// silo types +import { E_IMPORTER_STEPS, TImporterDataPayload } from "@/plane-web/silo/linear/types"; +// silo ui components +import { StepperNavigation } from "@/plane-web/silo/ui"; + +type TFormData = TImporterDataPayload[E_IMPORTER_STEPS.MAP_STATES]; + +const currentStepKey = E_IMPORTER_STEPS.MAP_STATES; + +export const MapStatesRoot: FC = () => { + // hooks + const { importerData, handleImporterData, handleSyncJobConfig, currentStep, handleStepper } = useImporter(); + const planeProjectId = importerData[E_IMPORTER_STEPS.SELECT_PLANE_PROJECT]?.projectId; + const linearTeamId = importerData[E_IMPORTER_STEPS.CONFIGURE_LINEAR]?.teamId; + const { data: linearTeamStates, getById: getLinearStateById } = useLinearTeamStates(linearTeamId); + const { data: planeProjectStates, getById: getPlaneStateById } = usePlaneProjectStates(planeProjectId); + // states + const [formData, setFormData] = useState({}); + // derived values + const isNextButtonDisabled = linearTeamStates?.length === Object.keys(formData).length ? false : true; + // handlers + const handleFormData = (key: T, value: TFormData[T]) => { + setFormData((prevData) => ({ ...prevData, [key]: value })); + }; + + const constructLinearStateSyncJobConfig = () => { + const stateConfig: IStateConfig[] = []; + Object.entries(formData).forEach(([linearStateId, planeStateId]) => { + if (linearStateId && planeStateId) { + const linearState = getLinearStateById(linearStateId); + const planeState = getPlaneStateById(planeStateId); + if (linearState && planeState) { + const syncJobConfig = { + source_state: { id: linearState.id, name: linearState.name }, + target_state: planeState as unknown as ExState, + }; + stateConfig.push(syncJobConfig); + } + } + }); + return stateConfig; + }; + + const handleOnClickNext = () => { + // validate the sync job config + if (linearTeamStates?.length === Object.keys(formData).length) { + // update the data in the context + handleImporterData(currentStepKey, formData); + // update the sync job config + const stateConfig = constructLinearStateSyncJobConfig(); + handleSyncJobConfig("state", stateConfig); + // moving to the next state + handleStepper("next"); + } + }; + + useEffect(() => { + const contextData = importerData[currentStepKey]; + if (contextData && !isEqual(contextData, formData)) { + setFormData(contextData); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [importerData]); + + console.log("linearTeamStates", linearTeamStates); + + return ( +
+ {/* content */} +
+
+
Linear States
+
Plane States
+
+
+ {linearTeamStates && + planeProjectStates && + linearTeamStates.map((linearState: LinearState) => ( + handleFormData(linearState.id, value)} + linearState={linearState} + planeStates={planeProjectStates} + /> + ))} +
+
+ + {/* stepper button */} +
+ + + +
+
+ ); +}; diff --git a/web/ee/silo/linear/components/steps/map-states/state-selection.tsx b/web/ee/silo/linear/components/steps/map-states/state-selection.tsx new file mode 100644 index 0000000000..74e24ebd6c --- /dev/null +++ b/web/ee/silo/linear/components/steps/map-states/state-selection.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { FC } from "react"; +import { IState } from "@plane/types"; +import { StateGroupIcon } from "@plane/ui"; +import { LinearState } from "@silo/linear"; +// silo ui components +import { Dropdown } from "@/plane-web/silo/ui"; + +type TMapStatesSelection = { + value: string | undefined; + handleValue: (value: string | undefined) => void; + linearState: LinearState; + planeStates: IState[]; +}; + +export const MapStatesSelection: FC = (props) => { + const { value, handleValue, linearState, planeStates } = props; + + return ( +
+
{linearState.name}
+
+ ({ + key: state.id, + label: state.name, + value: state.id, + data: state, + }))} + value={value} + placeHolder="Select state" + onChange={(value: string | undefined) => handleValue(value)} + iconExtractor={(option) => ( +
+ +
+ )} + queryExtractor={(option) => option.name} + /> +
+
+ ); +}; diff --git a/web/ee/silo/linear/components/steps/root.tsx b/web/ee/silo/linear/components/steps/root.tsx new file mode 100644 index 0000000000..9718ac09d0 --- /dev/null +++ b/web/ee/silo/linear/components/steps/root.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { FC } from "react"; +// constants +import { IMPORTER_STEPS } from "@/plane-web/silo/linear/constants/steps"; +// hooks +import { useImporter } from "@/plane-web/silo/linear/hooks"; +// components +import { Stepper } from "@/plane-web/silo/ui"; +// assets +import LinearLogo from "@/public/services/linear.svg"; + +export const StepsRoot: FC = () => { + // hooks + const { currentStepIndex } = useImporter(); + + return ( +
+ +
+ ); +}; diff --git a/web/ee/silo/linear/components/steps/select-plane-project/index.ts b/web/ee/silo/linear/components/steps/select-plane-project/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/web/ee/silo/linear/components/steps/select-plane-project/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ee/silo/linear/components/steps/select-plane-project/root.tsx b/web/ee/silo/linear/components/steps/select-plane-project/root.tsx new file mode 100644 index 0000000000..7a091d41e2 --- /dev/null +++ b/web/ee/silo/linear/components/steps/select-plane-project/root.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { FC, useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; +import { Briefcase } from "lucide-react"; +import { ExProject } from "@plane/sdk"; +import { Button } from "@plane/ui"; +// components +import { Logo } from "@/components/common"; +// silo hooks +import { usePlaneProjects } from "@/plane-web/silo/hooks"; +import { useImporter } from "@/plane-web/silo/linear/hooks"; +// silo types +import { E_IMPORTER_STEPS, TImporterDataPayload } from "@/plane-web/silo/linear/types"; +// silo ui components +import { StepperNavigation, Dropdown } from "@/plane-web/silo/ui"; + +type TFormData = TImporterDataPayload[E_IMPORTER_STEPS.SELECT_PLANE_PROJECT]; + +const currentStepKey = E_IMPORTER_STEPS.SELECT_PLANE_PROJECT; + +export const SelectPlaneProjectRoot: FC = () => { + // hooks + const { importerData, handleImporterData, handleSyncJobConfig, currentStep, handleStepper } = useImporter(); + const { data: projects, getById: getProjectById } = usePlaneProjects(); + // states + const [formData, setFormData] = useState({ projectId: undefined }); + // derived values + const isNextButtonDisabled = !formData.projectId; + // handlers + const handleFormData = (value: string | undefined) => { + setFormData({ projectId: value }); + // updating the config data + if (value) { + const currentProject = getProjectById(value); + console.log("currentProject", currentProject); + // if (currentProject) handleSyncJobConfig("planeProject", currentProject as ExProject); + } + }; + + const handleOnClickNext = () => { + // update the data in the context + handleImporterData(currentStepKey, formData); + // moving to the next state + handleStepper("next"); + }; + + useEffect(() => { + const contextData = importerData[currentStepKey]; + if (contextData && !isEqual(contextData, formData)) { + setFormData(contextData); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [importerData]); + + return ( +
+ {/* content */} +
+
Select Plane project
+ ({ + key: project.id || "", + label: project.name || "", + value: project.id || "", + data: project, + }))} + value={formData.projectId} + placeHolder="Select plane project" + onChange={(value: string | undefined) => handleFormData(value)} + iconExtractor={(option) => ( +
+ {option && option?.logo_props ? ( + + ) : ( + + )} +
+ )} + queryExtractor={(option) => option.name || ""} + /> +
+ + {/* stepper button */} +
+ + + +
+
+ ); +}; diff --git a/web/ee/silo/linear/components/steps/summary/index.ts b/web/ee/silo/linear/components/steps/summary/index.ts new file mode 100644 index 0000000000..1efe34c51e --- /dev/null +++ b/web/ee/silo/linear/components/steps/summary/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ee/silo/linear/components/steps/summary/root.tsx b/web/ee/silo/linear/components/steps/summary/root.tsx new file mode 100644 index 0000000000..ca435b6099 --- /dev/null +++ b/web/ee/silo/linear/components/steps/summary/root.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { FC } from "react"; +import { Button } from "@plane/ui"; +import { TSyncJob } from "@silo/core"; +import { LinearConfig } from "@silo/linear"; +// silo hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; +import { useLinearSyncJobs } from "@/plane-web/silo/hooks/context/use-linear-sync-jobs"; +import { useImporter, useLinearTeamIssueCount, useLinearTeamStates } from "@/plane-web/silo/linear/hooks"; +// silo types +import { E_IMPORTER_STEPS } from "@/plane-web/silo/linear/types"; +// silo ui components +import { StepperNavigation } from "@/plane-web/silo/ui"; + +export const SummaryRoot: FC = () => { + // hooks + const { workspaceSlug, workspaceId, userId, userEmail, apiBaseUrl } = useBaseImporter(); + const { importerData, currentStep, syncJobConfig, handleStepper, resetImporterData, setDashboardView } = + useImporter(); + const planeProjectId = importerData[E_IMPORTER_STEPS.SELECT_PLANE_PROJECT]?.projectId; + const linearTeamId = importerData[E_IMPORTER_STEPS.CONFIGURE_LINEAR]?.teamId; + const { data: linearTeamStates } = useLinearTeamStates(linearTeamId); + const { data: linearTeamIssueCount } = useLinearTeamIssueCount(linearTeamId); + const { createJobConfiguration, createJob, startJob } = useLinearSyncJobs(); + + const handleOnClickNext = async () => { + if (planeProjectId) { + // create a new config and the sync job + try { + const importerConfig = await createJobConfiguration(syncJobConfig as LinearConfig); + if (importerConfig && importerConfig?.insertedId) { + const syncJobPayload: Partial = { + workspace_slug: workspaceSlug, + workspace_id: workspaceId, + project_id: planeProjectId, + initiator_id: userId, + initiator_email: userEmail, + config: importerConfig?.insertedId, + migration_type: "LINEAR", + target_hostname: apiBaseUrl, + }; + const importerCreateJob = await createJob(planeProjectId, syncJobPayload); + if (importerCreateJob && importerCreateJob?.insertedId) { + await startJob(importerCreateJob?.insertedId); + setDashboardView(true); + // clearing the existing data in the context + resetImporterData(); + // moving to the next state + handleStepper("next"); + } + } + } catch (error) { + console.error("error", error); + } + } + }; + + return ( +
+ {/* content */} +
+
+
Linear Entities
+
Migrating
+
+
+
+
Issues
+
{linearTeamIssueCount || 0} issues
+
+
+
States
+
{linearTeamStates?.length || 0} states
+
+
+
+ + {/* stepper button */} +
+ + + +
+
+ ); +}; diff --git a/web/ee/silo/linear/constants/steps.tsx b/web/ee/silo/linear/constants/steps.tsx new file mode 100644 index 0000000000..4028cf87a3 --- /dev/null +++ b/web/ee/silo/linear/constants/steps.tsx @@ -0,0 +1,52 @@ +"use client"; +import { Layers2, Layers3, ReceiptText } from "lucide-react"; +// components +import { + SelectPlaneProjectRoot, + ConfigureLinearRoot, + MapStatesRoot, + SummaryRoot, +} from "@/plane-web/silo/linear/components"; +// types +import { E_IMPORTER_STEPS, TImporterStep } from "@/plane-web/silo/linear/types"; + +export const IMPORTER_STEPS: TImporterStep[] = [ + { + key: E_IMPORTER_STEPS.SELECT_PLANE_PROJECT, + icon: () => , + title: "Configure Plane", + description: + "Please first create the project in Plane where you intend to migrate your Linear data. Once the project is created, select it here.", + component: () => , + prevStep: undefined, + nextStep: E_IMPORTER_STEPS.CONFIGURE_LINEAR, + }, + { + key: E_IMPORTER_STEPS.CONFIGURE_LINEAR, + icon: () => , + title: "Configure Linear", + description: "Please select the Linear team from which you want to migrate your data.", + component: () => , + prevStep: E_IMPORTER_STEPS.SELECT_PLANE_PROJECT, + nextStep: E_IMPORTER_STEPS.MAP_STATES, + }, + { + key: E_IMPORTER_STEPS.MAP_STATES, + icon: () => , + title: "Map states", + description: + "We have automatically matched the Linear statuses to Plane states to the best of our ability. Please map any remaining states before proceeding, you can also create states and map them manually.", + component: () => , + prevStep: E_IMPORTER_STEPS.CONFIGURE_LINEAR, + nextStep: E_IMPORTER_STEPS.SUMMARY, + }, + { + key: E_IMPORTER_STEPS.SUMMARY, + icon: () => , + title: "Summary", + description: "Here is a summary of the data that will be migrated from Linear to Plane.", + component: () => , + prevStep: E_IMPORTER_STEPS.MAP_STATES, + nextStep: E_IMPORTER_STEPS.SELECT_PLANE_PROJECT, + }, +]; diff --git a/web/ee/silo/linear/contexts/importer.tsx b/web/ee/silo/linear/contexts/importer.tsx new file mode 100644 index 0000000000..e5bd7dcd4f --- /dev/null +++ b/web/ee/silo/linear/contexts/importer.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { createContext, Dispatch, ReactNode, SetStateAction, useState } from "react"; +import { LinearConfig } from "@silo/linear"; +// silo hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; +// silo constants +import { IMPORTER_STEPS } from "@/plane-web/silo/linear/constants/steps"; +// silo services +import { ImporterAuthService } from "@/plane-web/silo/linear/services/auth.service"; +import { LinearService } from "@/plane-web/silo/linear/services/linear.service"; +// silo types +import { + E_IMPORTER_STEPS, + TImporterStepKeys, + TImporterStep, + TImporterDataPayload, +} from "@/plane-web/silo/linear/types"; + +type TImporterCreateContext = { + // services + importerAuthService: ImporterAuthService; + linearService: LinearService; + // stepper state management + currentStep: TImporterStep; + currentStepIndex: number; + handleStepper: (direction: "previous" | "next") => void; + // state + importerData: TImporterDataPayload; + handleImporterData: (key: T, value: TImporterDataPayload[T]) => void; + syncJobConfig: Partial; + handleSyncJobConfig: (key: T, config: LinearConfig[T]) => void; + resetImporterData: () => void; + setDashboardView: Dispatch>; +}; + +export const ImporterContext = createContext({} as TImporterCreateContext); + +const defaultImporterData: TImporterDataPayload = { + [E_IMPORTER_STEPS.SELECT_PLANE_PROJECT]: { + projectId: undefined, + }, + [E_IMPORTER_STEPS.CONFIGURE_LINEAR]: { + teamId: undefined, + }, + [E_IMPORTER_STEPS.MAP_STATES]: {}, +}; + +type TImporterContext = { + setDashboardView: Dispatch>; + children: ReactNode; +}; + +export const ImporterContextProvider = (props: TImporterContext) => { + // props + const { setDashboardView, children } = props; + // hooks + const { siloBaseUrl } = useBaseImporter(); + // initiating services + const importerAuthService = new ImporterAuthService(siloBaseUrl); + const linearService = new LinearService(siloBaseUrl); + // states + const [stepper, setStepper] = useState(E_IMPORTER_STEPS.SELECT_PLANE_PROJECT); + const [importerData, setImporterData] = useState(defaultImporterData); + const [syncJobConfig, setSyncJobConfig] = useState>({}); + + // derived values + const currentStepIndex = IMPORTER_STEPS.findIndex((step) => step.key === stepper); + const currentStep = IMPORTER_STEPS[currentStepIndex]; + + // handlers + const handleStepper = (direction: "previous" | "next") => { + if (direction === "previous") { + if (currentStep.prevStep) setStepper(currentStep.prevStep); + } else { + if (currentStep.nextStep) setStepper(currentStep.nextStep); + } + }; + + const handleImporterData: (key: T, value: TImporterDataPayload[T]) => void = ( + key, + value + ) => { + setImporterData((prevData) => ({ ...prevData, [key]: value })); + }; + + const handleSyncJobConfig = (key: T, config: LinearConfig[T]) => { + setSyncJobConfig((prevConfig) => ({ ...prevConfig, [key]: config })); + }; + + const resetImporterData = () => { + setImporterData(defaultImporterData); + setSyncJobConfig({}); + }; + + return ( + + {children} + + ); +}; + +export default ImporterContext; diff --git a/web/ee/silo/linear/contexts/index.ts b/web/ee/silo/linear/contexts/index.ts new file mode 100644 index 0000000000..b21106af6a --- /dev/null +++ b/web/ee/silo/linear/contexts/index.ts @@ -0,0 +1 @@ +export * from "./importer"; diff --git a/web/ee/silo/linear/hooks/index.ts b/web/ee/silo/linear/hooks/index.ts new file mode 100644 index 0000000000..e74c966457 --- /dev/null +++ b/web/ee/silo/linear/hooks/index.ts @@ -0,0 +1,2 @@ +export * from "./use-importer"; +export * from "./linear"; diff --git a/web/ee/silo/linear/hooks/linear/index.ts b/web/ee/silo/linear/hooks/linear/index.ts new file mode 100644 index 0000000000..7c169f78d5 --- /dev/null +++ b/web/ee/silo/linear/hooks/linear/index.ts @@ -0,0 +1,3 @@ +export * from "./use-teams"; +export * from "./use-team-states"; +export * from "./use-project-issues-count"; diff --git a/web/ee/silo/linear/hooks/linear/use-project-issues-count.ts b/web/ee/silo/linear/hooks/linear/use-project-issues-count.ts new file mode 100644 index 0000000000..f38c329cf6 --- /dev/null +++ b/web/ee/silo/linear/hooks/linear/use-project-issues-count.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; +import useSWR from "swr"; +// hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; +import { useImporter } from "@/plane-web/silo/linear/hooks"; + +export const useLinearTeamIssueCount = (teamId: string | undefined) => { + // hooks + const { workspaceId, userId } = useBaseImporter(); + const { linearService } = useImporter(); + + // states + const [linearTeamIssueCount, setLinearTeamIssueCount] = useState(undefined); + + const { data, isLoading, error, mutate } = useSWR( + workspaceId && userId && teamId ? `LINEAR_TEAM_ISSUE_COUNT_${workspaceId}_${userId}_${teamId}` : null, + workspaceId && userId && teamId + ? async () => await linearService.getTeamIssueCount(workspaceId, userId, teamId) + : null + ); + + // update the project states + useEffect(() => { + if ((!linearTeamIssueCount && data) || (linearTeamIssueCount && data && linearTeamIssueCount !== data)) { + setLinearTeamIssueCount(data); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + return { + data: linearTeamIssueCount, + isLoading, + error, + mutate, + }; +}; diff --git a/web/ee/silo/linear/hooks/linear/use-team-states.ts b/web/ee/silo/linear/hooks/linear/use-team-states.ts new file mode 100644 index 0000000000..ada621026f --- /dev/null +++ b/web/ee/silo/linear/hooks/linear/use-team-states.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; +import useSWR from "swr"; +import { LinearState } from "@silo/linear"; +// hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; +import { useImporter } from "@/plane-web/silo/linear/hooks"; + +export const useLinearTeamStates = (teamId: string | undefined) => { + // hooks + const { workspaceId, userId } = useBaseImporter(); + const { linearService } = useImporter(); + + // states + const [linearTeamStates, setLinearTeamStates] = useState(undefined); + + // fetch linear project states + const { data, isLoading, error, mutate } = useSWR( + workspaceId && userId && teamId ? `LINEAR_TEAM_STATES_${workspaceId}_${userId}_${teamId}` : null, + workspaceId && userId && teamId ? async () => await linearService.getTeamStates(workspaceId, userId, teamId) : null + ); + + // update the project states + useEffect(() => { + if ((!linearTeamStates && data) || (linearTeamStates && data && !isEqual(linearTeamStates, data))) { + setLinearTeamStates(data); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + // get project state by id + const getById = (id: string) => linearTeamStates?.find((state) => state.id === id); + + return { + data: linearTeamStates, + isLoading, + error, + mutate, + getById, + }; +}; diff --git a/web/ee/silo/linear/hooks/linear/use-teams.ts b/web/ee/silo/linear/hooks/linear/use-teams.ts new file mode 100644 index 0000000000..fe9d675e71 --- /dev/null +++ b/web/ee/silo/linear/hooks/linear/use-teams.ts @@ -0,0 +1,41 @@ +import { useEffect, useState } from "react"; +import isEqual from "lodash/isEqual"; +import useSWR from "swr"; +import { LinearTeam } from "@silo/linear"; +// hooks +import { useBaseImporter } from "@/plane-web/silo/hooks"; +import { useImporter } from "@/plane-web/silo/linear/hooks"; + +export const useLinearTeams = () => { + // hooks + const { workspaceId, userId } = useBaseImporter(); + const { linearService } = useImporter(); + + // states + const [linearTeam, setLinearTeams] = useState(undefined); + + // fetch resources + const { data, isLoading, error, mutate } = useSWR( + `LINEAR_TEAMS_${workspaceId}_${userId}`, + async () => await linearService.getTeams(workspaceId, userId) + ); + + // update the resources + useEffect(() => { + if ((!linearTeam && data) || (linearTeam && data && !isEqual(linearTeam, data))) { + setLinearTeams(data); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data]); + + // get resource by id + const getById = (id: string) => linearTeam?.find((resource) => resource.id === id); + + return { + data: linearTeam, + isLoading, + error, + mutate, + getById, + }; +}; diff --git a/web/ee/silo/linear/hooks/use-importer.ts b/web/ee/silo/linear/hooks/use-importer.ts new file mode 100644 index 0000000000..7d0a00a1a9 --- /dev/null +++ b/web/ee/silo/linear/hooks/use-importer.ts @@ -0,0 +1,13 @@ +import { useContext } from "react"; +// contexts +import { ImporterContext } from "@/plane-web/silo/linear/contexts"; + +export function useImporter() { + const context = useContext(ImporterContext); + + if (!context) { + throw new Error("useImporter must be used within an ImportContextProvider"); + } + + return context; +} diff --git a/web/ee/silo/linear/services/auth.service.ts b/web/ee/silo/linear/services/auth.service.ts new file mode 100644 index 0000000000..f4ce054c9e --- /dev/null +++ b/web/ee/silo/linear/services/auth.service.ts @@ -0,0 +1,26 @@ +import axios, { AxiosInstance } from "axios"; +import { LinearAuthState } from "@silo/linear"; + +export class ImporterAuthService { + protected baseURL: string; + private axiosInstance: AxiosInstance; + + constructor(baseURL: string) { + this.baseURL = baseURL; + this.axiosInstance = axios.create({ baseURL }); + } + + /** + * @description authenticate the service + * @property payload: LinearAuthState + * @redirects to the linear authentication URL + */ + async linearAuthentication(payload: LinearAuthState) { + return this.axiosInstance + .post(`/silo/api/linear/auth/url`, payload) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/web/ee/silo/linear/services/linear.service.ts b/web/ee/silo/linear/services/linear.service.ts new file mode 100644 index 0000000000..f21b245f12 --- /dev/null +++ b/web/ee/silo/linear/services/linear.service.ts @@ -0,0 +1,59 @@ +import axios, { AxiosInstance } from "axios"; +import { LinearTeam, LinearState } from "@silo/linear"; + +export class LinearService { + protected baseURL: string; + private axiosInstance: AxiosInstance; + + constructor(baseURL: string) { + this.baseURL = baseURL; + this.axiosInstance = axios.create({ baseURL }); + } + + /** + * @description get teams + * @property workspaceId: string + * @property userId: string + * @returns teams | undefined + */ + async getTeams(workspaceId: string, userId: string): Promise { + return this.axiosInstance + .post(`/silo/api/linear/teams`, { workspaceId, userId }) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description get project states + * @property workspaceId: string + * @property userId: string + * @property teamId: string + * @returns states | undefined + */ + async getTeamStates(workspaceId: string, userId: string, teamId: string): Promise { + return this.axiosInstance + .post(`/silo/api/linear/team-states`, { workspaceId, userId, teamId }) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + /** + * @description get project issues count + * @property workspaceId: string + * @property userId: string + * @property teamId: string + * @returns number | undefined + */ + async getTeamIssueCount(workspaceId: string, userId: string, teamId: string): Promise { + return this.axiosInstance + .post(`/silo/api/linear/team-issue-count`, { workspaceId, userId, teamId }) + .then((res) => res.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/web/ee/silo/linear/types/importer.ts b/web/ee/silo/linear/types/importer.ts new file mode 100644 index 0000000000..f4390f9880 --- /dev/null +++ b/web/ee/silo/linear/types/importer.ts @@ -0,0 +1,19 @@ +import { E_IMPORTER_STEPS } from "@/plane-web/silo/linear/types"; + +export enum E_FORM_RADIO_DATA { + CREATE_AS_LABEL = "create_as_label", + ADD_IN_TITLE = "add_in_title", +} +export type TFormRadioData = E_FORM_RADIO_DATA.CREATE_AS_LABEL | E_FORM_RADIO_DATA.ADD_IN_TITLE; + +export type TImporterDataPayload = { + [E_IMPORTER_STEPS.SELECT_PLANE_PROJECT]: { + projectId: string | undefined; + }; + [E_IMPORTER_STEPS.CONFIGURE_LINEAR]: { + teamId: string | undefined; + }; + [E_IMPORTER_STEPS.MAP_STATES]: { + [key: string]: string | undefined; + }; +}; diff --git a/web/ee/silo/linear/types/index.ts b/web/ee/silo/linear/types/index.ts new file mode 100644 index 0000000000..9887c04072 --- /dev/null +++ b/web/ee/silo/linear/types/index.ts @@ -0,0 +1,2 @@ +export * from "./steps"; +export * from "./importer"; diff --git a/web/ee/silo/linear/types/steps.ts b/web/ee/silo/linear/types/steps.ts new file mode 100644 index 0000000000..1c607da7f7 --- /dev/null +++ b/web/ee/silo/linear/types/steps.ts @@ -0,0 +1,17 @@ +// types +import { TStepperBlock } from "@/plane-web/silo/types/ui"; + +export enum E_IMPORTER_STEPS { + SELECT_PLANE_PROJECT = "select-plane-project", + CONFIGURE_LINEAR = "configure-linear", + MAP_STATES = "map-states", + SUMMARY = "summary", +} + +export type TImporterStepKeys = + | E_IMPORTER_STEPS.SELECT_PLANE_PROJECT + | E_IMPORTER_STEPS.CONFIGURE_LINEAR + | E_IMPORTER_STEPS.MAP_STATES + | E_IMPORTER_STEPS.SUMMARY; + +export type TImporterStep = TStepperBlock; diff --git a/web/ee/silo/services/api-service-token.service.ts b/web/ee/silo/services/api-service-token.service.ts new file mode 100644 index 0000000000..c5fc0a67cc --- /dev/null +++ b/web/ee/silo/services/api-service-token.service.ts @@ -0,0 +1,28 @@ +import { IApiToken } from "@plane/types"; +// helpers +import { API_BASE_URL } from "@/helpers/common.helper"; +// services +import { APIService } from "@/services/api.service"; + +export class ApiTokenService extends APIService { + constructor() { + super(API_BASE_URL); + } + + /** + * @description create service api token for access the plane api endpoints to create the data in + * @param workspaceSlug: string + * @returns IApiToken + */ + async createServiceApiToken(workspaceSlug: string): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/service-api-tokens/`, {} as Partial) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} + +const apiTokenService = new ApiTokenService(); + +export default apiTokenService; diff --git a/web/ee/silo/types/common.ts b/web/ee/silo/types/common.ts new file mode 100644 index 0000000000..c210e75ad3 --- /dev/null +++ b/web/ee/silo/types/common.ts @@ -0,0 +1,20 @@ +// plane priority +export enum E_PLANE_PRIORITY { + URGENT = "urgent", + HIGH = "high", + MEDIUM = "medium", + LOW = "low", + NONE = "none", +} + +export type TPlanePriority = + | E_PLANE_PRIORITY.URGENT + | E_PLANE_PRIORITY.HIGH + | E_PLANE_PRIORITY.MEDIUM + | E_PLANE_PRIORITY.LOW + | E_PLANE_PRIORITY.NONE; + +export type TPlanePriorityData = { + key: TPlanePriority; + label: string; +}; diff --git a/web/ee/silo/types/ui/dropdown.ts b/web/ee/silo/types/ui/dropdown.ts new file mode 100644 index 0000000000..68c3e71ee5 --- /dev/null +++ b/web/ee/silo/types/ui/dropdown.ts @@ -0,0 +1,16 @@ +export type TDropdownOptions = { + key: string; + label: string; + value: string; + data?: T; +}; + +export type TDropdown = { + dropdownOptions: TDropdownOptions[]; + onChange: (value: string | undefined) => void; + value: string | undefined; + placeHolder?: string; + disabled?: boolean; + iconExtractor?: (option: T) => JSX.Element; + queryExtractor?: (option: T) => string; +}; diff --git a/web/ee/silo/types/ui/index.ts b/web/ee/silo/types/ui/index.ts new file mode 100644 index 0000000000..a394b6a516 --- /dev/null +++ b/web/ee/silo/types/ui/index.ts @@ -0,0 +1,2 @@ +export * from "./stepper"; +export * from "./dropdown"; diff --git a/web/ee/silo/types/ui/stepper.ts b/web/ee/silo/types/ui/stepper.ts new file mode 100644 index 0000000000..7029f78be9 --- /dev/null +++ b/web/ee/silo/types/ui/stepper.ts @@ -0,0 +1,21 @@ +export type TStepperBlock = { + key: T; + icon?: () => JSX.Element; + title: string; + description: string; + component: () => JSX.Element; + prevStep: T | undefined; + nextStep: T | undefined; +}; + +export type TStepper = { + logo?: string; + steps: TStepperBlock[]; + currentStepIndex: number; +}; + +export type TStepperNavigation = { + currentStep: TStepperBlock; + handleStep: (direction: "previous" | "next") => void; + children?: React.ReactNode; +}; diff --git a/web/ee/silo/ui/dropdown.tsx b/web/ee/silo/ui/dropdown.tsx new file mode 100644 index 0000000000..2e1e887637 --- /dev/null +++ b/web/ee/silo/ui/dropdown.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { CustomSearchSelect, Tooltip } from "@plane/ui"; +// silo types +import { TDropdown } from "@/plane-web/silo/types/ui"; + +export const Dropdown = (props: TDropdown) => { + const { dropdownOptions, onChange, value, placeHolder, disabled = false, iconExtractor, queryExtractor } = props; + + // derived values + const className = ""; + const buttonClassName = "w-full min-h-8 h-full"; + const optionsClassName = ""; + + const selectedState = dropdownOptions.find((option) => option.key === value); + const dropdownLabel = selectedState ? ( + +
+ {iconExtractor && selectedState && iconExtractor(selectedState.data as T)} +
{selectedState?.label}
+
+
+ ) : placeHolder ? ( + placeHolder + ) : ( + "Select" + ); + const dropdownOptionsRender = (dropdownOptions ? Object.values(dropdownOptions).flat() : []).map((dropdownItem) => ({ + value: dropdownItem?.value, + query: queryExtractor ? queryExtractor(dropdownItem.data as T) : `${dropdownItem?.label}`, + content: ( +
+ {iconExtractor && iconExtractor(dropdownItem.data as T)} +
{dropdownItem?.label}
+
+ ), + })); + + return ( + + ); +}; diff --git a/web/ee/silo/ui/index.ts b/web/ee/silo/ui/index.ts new file mode 100644 index 0000000000..a394b6a516 --- /dev/null +++ b/web/ee/silo/ui/index.ts @@ -0,0 +1,2 @@ +export * from "./stepper"; +export * from "./dropdown"; diff --git a/web/ee/silo/ui/stepper.tsx b/web/ee/silo/ui/stepper.tsx new file mode 100644 index 0000000000..7101080330 --- /dev/null +++ b/web/ee/silo/ui/stepper.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { Fragment } from "react"; +import Image from "next/image"; +import { Button } from "@plane/ui"; +// helpers +import { cn } from "@/helpers/common.helper"; +// silo types +import { TStepper, TStepperNavigation } from "@/plane-web/silo/types/ui"; + +export const Stepper = (props: TStepper) => { + // props + const { logo, steps, currentStepIndex } = props; + // derived value + const currentStepDetails = steps[currentStepIndex]; + + return ( +
+
+ {/* stepper header */} +
+ {logo && ( +
+ {`Importer +
+ )} + +
+ {steps.map((step, index) => ( + + {/* left bar */} + {step?.prevStep && ( +
+ )} + + {/* content */} +
+ {step?.icon ? step?.icon() : index + 1} +
+ + {/* right bar */} + {step?.nextStep && index < steps.length - 1 && ( +
+ )} + + ))} +
+
+ + {/* title and description */} +
+
{currentStepDetails?.title}
+
{currentStepDetails?.description}
+
+
+ + {/* component */} + {currentStepDetails?.component &&
{currentStepDetails.component()}
} +
+ ); +}; + +export const StepperNavigation = (props: TStepperNavigation) => { + const { currentStep, handleStep, children } = props; + + return ( +
+ + {children ? ( + children + ) : ( + + )} +
+ ); +}; diff --git a/web/package.json b/web/package.json index 1babcbf6ef..3f78968124 100644 --- a/web/package.json +++ b/web/package.json @@ -32,6 +32,10 @@ "@plane/types": "*", "@plane/ui": "*", "@popperjs/core": "^2.11.8", + "@plane/sdk": "*", + "@silo/core": "*", + "@silo/jira": "*", + "@silo/linear": "*", "@react-pdf/renderer": "^3.4.5", "@sentry/nextjs": "^8.32.0", "@sqlite.org/sqlite-wasm": "^3.46.0-build2", @@ -51,6 +55,7 @@ "next": "^14.2.12", "next-themes": "^0.2.1", "nprogress": "^0.2.0", + "papaparse": "^5.4.1", "posthog-js": "^1.131.3", "react": "^18.3.1", "react-color": "^2.19.3", @@ -79,6 +84,7 @@ "@types/lodash": "^4.14.202", "@types/node": "18.16.1", "@types/nprogress": "^0.2.0", + "@types/papaparse": "^5.3.14", "@types/react": "^18.2.48", "@types/react-color": "^3.0.6", "@types/react-dom": "^18.2.18", diff --git a/web/public/services/linear.svg b/web/public/services/linear.svg new file mode 100644 index 0000000000..18475f7682 --- /dev/null +++ b/web/public/services/linear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 19a34e439d..ac6738cb36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,15 @@ # yarn lockfile v1 +"@acuminous/bitsyntax@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@acuminous/bitsyntax/-/bitsyntax-0.1.2.tgz#e0b31b9ee7ad1e4dd840c34864327c33d9f1f653" + integrity sha512-29lUK80d1muEQqiUsSo+3A0yP6CdspgC95EnKBMi22Xlwt79i/En4Vr67+cXhU+cZjbti3TgGGC5wy1stIywVQ== + dependencies: + buffer-more-ints "~1.0.0" + debug "^4.3.4" + safe-buffer "~5.1.2" + "@adobe/css-tools@^4.4.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.4.0.tgz#728c484f4e10df03d5a3acd0d8adcbbebff8ad63" @@ -1090,6 +1099,11 @@ react-confetti "^6.1.0" strip-ansi "^7.1.0" +"@colors/colors@1.6.0", "@colors/colors@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.6.0.tgz#ec6cd237440700bc23ca23087f513c75508958b0" + integrity sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA== + "@cspotcode/source-map-support@^0.8.0": version "0.8.1" resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" @@ -1097,6 +1111,15 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" +"@dabh/diagnostics@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.3.tgz#7f7e97ee9a725dffc7808d93668cc984e1dc477a" + integrity sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA== + dependencies: + colorspace "1.1.x" + enabled "2.0.x" + kuler "^2.0.0" + "@daybrush/utils@^1.1.1", "@daybrush/utils@^1.13.0", "@daybrush/utils@^1.4.0", "@daybrush/utils@^1.6.0", "@daybrush/utils@^1.7.1": version "1.13.0" resolved "https://registry.yarnpkg.com/@daybrush/utils/-/utils-1.13.0.tgz#ea70a60864130da476406fdd1d465e3068aea0ff" @@ -1238,6 +1261,22 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== +"@esbuild-kit/core-utils@^3.3.2": + version "3.3.2" + resolved "https://registry.yarnpkg.com/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz#186b6598a5066f0413471d7c4d45828e399ba96c" + integrity sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ== + dependencies: + esbuild "~0.18.20" + source-map-support "^0.5.21" + +"@esbuild-kit/esm-loader@^2.5.5": + version "2.6.5" + resolved "https://registry.yarnpkg.com/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz#6eedee46095d7d13b1efc381e2211ed1c60e64ea" + integrity sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA== + dependencies: + "@esbuild-kit/core-utils" "^3.3.2" + get-tsconfig "^4.7.0" + "@esbuild/aix-ppc64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz#d1bc06aedb6936b3b6d313bf809a5a40387d2b7f" @@ -1248,6 +1287,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz#51299374de171dbd80bb7d838e1cfce9af36f353" integrity sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ== +"@esbuild/android-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz#984b4f9c8d0377443cc2dfcef266d02244593622" + integrity sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ== + "@esbuild/android-arm64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz#7ad65a36cfdb7e0d429c353e00f680d737c2aed4" @@ -1258,6 +1302,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz#58565291a1fe548638adb9c584237449e5e14018" integrity sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw== +"@esbuild/android-arm@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.18.20.tgz#fedb265bc3a589c84cc11f810804f234947c3682" + integrity sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw== + "@esbuild/android-arm@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.12.tgz#b0c26536f37776162ca8bde25e42040c203f2824" @@ -1268,6 +1317,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.23.1.tgz#5eb8c652d4c82a2421e3395b808e6d9c42c862ee" integrity sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ== +"@esbuild/android-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.18.20.tgz#35cf419c4cfc8babe8893d296cd990e9e9f756f2" + integrity sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg== + "@esbuild/android-x64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.12.tgz#cb13e2211282012194d89bf3bfe7721273473b3d" @@ -1278,6 +1332,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.23.1.tgz#ae19d665d2f06f0f48a6ac9a224b3f672e65d517" integrity sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg== +"@esbuild/darwin-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz#08172cbeccf95fbc383399a7f39cfbddaeb0d7c1" + integrity sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA== + "@esbuild/darwin-arm64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz#cbee41e988020d4b516e9d9e44dd29200996275e" @@ -1288,6 +1347,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz#05b17f91a87e557b468a9c75e9d85ab10c121b16" integrity sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q== +"@esbuild/darwin-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz#d70d5790d8bf475556b67d0f8b7c5bdff053d85d" + integrity sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ== + "@esbuild/darwin-x64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz#e37d9633246d52aecf491ee916ece709f9d5f4cd" @@ -1298,6 +1362,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz#c58353b982f4e04f0d022284b8ba2733f5ff0931" integrity sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw== +"@esbuild/freebsd-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz#98755cd12707f93f210e2494d6a4b51b96977f54" + integrity sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw== + "@esbuild/freebsd-arm64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz#1ee4d8b682ed363b08af74d1ea2b2b4dbba76487" @@ -1308,6 +1377,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz#f9220dc65f80f03635e1ef96cfad5da1f446f3bc" integrity sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA== +"@esbuild/freebsd-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz#c1eb2bff03915f87c29cece4c1a7fa1f423b066e" + integrity sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ== + "@esbuild/freebsd-x64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz#37a693553d42ff77cd7126764b535fb6cc28a11c" @@ -1318,6 +1392,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz#69bd8511fa013b59f0226d1609ac43f7ce489730" integrity sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g== +"@esbuild/linux-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz#bad4238bd8f4fc25b5a021280c770ab5fc3a02a0" + integrity sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA== + "@esbuild/linux-arm64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz#be9b145985ec6c57470e0e051d887b09dddb2d4b" @@ -1328,6 +1407,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz#8050af6d51ddb388c75653ef9871f5ccd8f12383" integrity sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g== +"@esbuild/linux-arm@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz#3e617c61f33508a27150ee417543c8ab5acc73b0" + integrity sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg== + "@esbuild/linux-arm@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz#207ecd982a8db95f7b5279207d0ff2331acf5eef" @@ -1338,6 +1422,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz#ecaabd1c23b701070484990db9a82f382f99e771" integrity sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ== +"@esbuild/linux-ia32@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz#699391cccba9aee6019b7f9892eb99219f1570a7" + integrity sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA== + "@esbuild/linux-ia32@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz#d0d86b5ca1562523dc284a6723293a52d5860601" @@ -1348,6 +1437,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz#3ed2273214178109741c09bd0687098a0243b333" integrity sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ== +"@esbuild/linux-loong64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz#e6fccb7aac178dd2ffb9860465ac89d7f23b977d" + integrity sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg== + "@esbuild/linux-loong64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz#9a37f87fec4b8408e682b528391fa22afd952299" @@ -1358,6 +1452,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz#a0fdf440b5485c81b0fbb316b08933d217f5d3ac" integrity sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw== +"@esbuild/linux-mips64el@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz#eeff3a937de9c2310de30622a957ad1bd9183231" + integrity sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ== + "@esbuild/linux-mips64el@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz#4ddebd4e6eeba20b509d8e74c8e30d8ace0b89ec" @@ -1368,6 +1467,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz#e11a2806346db8375b18f5e104c5a9d4e81807f6" integrity sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q== +"@esbuild/linux-ppc64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz#2f7156bde20b01527993e6881435ad79ba9599fb" + integrity sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA== + "@esbuild/linux-ppc64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz#adb67dadb73656849f63cd522f5ecb351dd8dee8" @@ -1378,6 +1482,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz#06a2744c5eaf562b1a90937855b4d6cf7c75ec96" integrity sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw== +"@esbuild/linux-riscv64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz#6628389f210123d8b4743045af8caa7d4ddfc7a6" + integrity sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A== + "@esbuild/linux-riscv64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz#11bc0698bf0a2abf8727f1c7ace2112612c15adf" @@ -1388,6 +1497,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz#65b46a2892fc0d1af4ba342af3fe0fa4a8fe08e7" integrity sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA== +"@esbuild/linux-s390x@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz#255e81fb289b101026131858ab99fba63dcf0071" + integrity sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ== + "@esbuild/linux-s390x@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz#e86fb8ffba7c5c92ba91fc3b27ed5a70196c3cc8" @@ -1398,6 +1512,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz#e71ea18c70c3f604e241d16e4e5ab193a9785d6f" integrity sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw== +"@esbuild/linux-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz#c7690b3417af318a9b6f96df3031a8865176d338" + integrity sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w== + "@esbuild/linux-x64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz#5f37cfdc705aea687dfe5dfbec086a05acfe9c78" @@ -1408,6 +1527,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz#d47f97391e80690d4dfe811a2e7d6927ad9eed24" integrity sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ== +"@esbuild/netbsd-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz#30e8cd8a3dded63975e2df2438ca109601ebe0d1" + integrity sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A== + "@esbuild/netbsd-x64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz#29da566a75324e0d0dd7e47519ba2f7ef168657b" @@ -1423,6 +1547,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz#05c5a1faf67b9881834758c69f3e51b7dee015d7" integrity sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q== +"@esbuild/openbsd-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz#7812af31b205055874c8082ea9cf9ab0da6217ae" + integrity sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg== + "@esbuild/openbsd-x64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz#306c0acbdb5a99c95be98bdd1d47c916e7dc3ff0" @@ -1433,6 +1562,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz#2e58ae511bacf67d19f9f2dcd9e8c5a93f00c273" integrity sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA== +"@esbuild/sunos-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz#d5c275c3b4e73c9b0ecd38d1ca62c020f887ab9d" + integrity sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ== + "@esbuild/sunos-x64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz#0933eaab9af8b9b2c930236f62aae3fc593faf30" @@ -1443,6 +1577,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz#adb022b959d18d3389ac70769cef5a03d3abd403" integrity sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA== +"@esbuild/win32-arm64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz#73bc7f5a9f8a77805f357fab97f290d0e4820ac9" + integrity sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg== + "@esbuild/win32-arm64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz#773bdbaa1971b36db2f6560088639ccd1e6773ae" @@ -1453,6 +1592,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz#84906f50c212b72ec360f48461d43202f4c8b9a2" integrity sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A== +"@esbuild/win32-ia32@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz#ec93cbf0ef1085cc12e71e0d661d20569ff42102" + integrity sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g== + "@esbuild/win32-ia32@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz#000516cad06354cc84a73f0943a4aa690ef6fd67" @@ -1463,6 +1607,11 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz#5e3eacc515820ff729e90d0cb463183128e82fac" integrity sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ== +"@esbuild/win32-x64@0.18.20": + version "0.18.20" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz#786c5f41f043b07afb1af37683d7c33668858f6d" + integrity sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ== + "@esbuild/win32-x64@0.19.12": version "0.19.12" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz#c57c8afbb4054a3ab8317591a0b7320360b444ae" @@ -1480,11 +1629,25 @@ dependencies: eslint-visitor-keys "^3.3.0" -"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.4.0", "@eslint-community/regexpp@^4.6.1": +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.11.0", "@eslint-community/regexpp@^4.4.0", "@eslint-community/regexpp@^4.6.1": version "4.11.1" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.11.1.tgz#a547badfc719eb3e5f4b556325e542fbe9d7a18f" integrity sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q== +"@eslint/config-array@^0.18.0": + version "0.18.0" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.18.0.tgz#37d8fe656e0d5e3dbaea7758ea56540867fd074d" + integrity sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw== + dependencies: + "@eslint/object-schema" "^2.1.4" + debug "^4.3.1" + minimatch "^3.1.2" + +"@eslint/core@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.6.0.tgz#9930b5ba24c406d67a1760e94cdbac616a6eb674" + integrity sha512-8I2Q8ykA4J0x0o7cg67FPVnehcqWTBehu/lmY+bolPFHGjh49YzGBMXTvpqVgEbBdvNCSxj6iFgiIyHzf03lzg== + "@eslint/eslintrc@^2.1.4": version "2.1.4" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" @@ -1500,11 +1663,43 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@eslint/eslintrc@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.1.0.tgz#dbd3482bfd91efa663cbe7aa1f506839868207b6" + integrity sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^10.0.1" + globals "^14.0.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.0" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + "@eslint/js@8.57.1": version "8.57.1" resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2" integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== +"@eslint/js@9.12.0", "@eslint/js@^9.9.1": + version "9.12.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.12.0.tgz#69ca3ca9fab9a808ec6d67b8f6edb156cbac91e1" + integrity sha512-eohesHH8WFRUprDNyEREgqP6beG6htMeUYeCpkEgBCieCMme5r9zFWjzAJp//9S+Kub4rqE+jXe9Cp1a7IYIIA== + +"@eslint/object-schema@^2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.4.tgz#9e69f8bb4031e11df79e03db09f9dbbae1740843" + integrity sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ== + +"@eslint/plugin-kit@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz#8712dccae365d24e9eeecb7b346f85e750ba343d" + integrity sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig== + dependencies: + levn "^0.4.1" + "@floating-ui/core@^1.6.0": version "1.6.8" resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.8.tgz#aa43561be075815879305965020f492cdb43da12" @@ -1541,6 +1736,11 @@ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62" integrity sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig== +"@graphql-typed-document-node/core@^3.1.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861" + integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== + "@headlessui/react@^1.7.13", "@headlessui/react@^1.7.19", "@headlessui/react@^1.7.3": version "1.7.19" resolved "https://registry.yarnpkg.com/@headlessui/react/-/react-1.7.19.tgz#91c78cf5fcb254f4a0ebe96936d48421caf75f40" @@ -1604,6 +1804,19 @@ uuid "^10.0.0" ws "^8.5.0" +"@humanfs/core@^0.19.0": + version "0.19.0" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.0.tgz#08db7a8c73bb07673d9ebd925f2dad746411fcec" + integrity sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw== + +"@humanfs/node@^0.16.5": + version "0.16.5" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.5.tgz#a9febb7e7ad2aff65890fdc630938f8d20aa84ba" + integrity sha512-KSPA4umqSG4LHYRodq31VDwKAvaTF4xmVlzM8Aeh4PlU1JQ3IG0wiA8C25d3RQ9nJyM3mBHyI53K06VVL/oFFg== + dependencies: + "@humanfs/core" "^0.19.0" + "@humanwhocodes/retry" "^0.3.0" + "@humanwhocodes/config-array@^0.13.0": version "0.13.0" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748" @@ -1623,6 +1836,11 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== +"@humanwhocodes/retry@^0.3.0", "@humanwhocodes/retry@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.3.1.tgz#c72a5c76a9fbaf3488e231b13dc52c0da7bab42a" + integrity sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA== + "@hypnosphi/create-react-context@^0.3.1": version "0.3.1" resolved "https://registry.yarnpkg.com/@hypnosphi/create-react-context/-/create-react-context-0.3.1.tgz#f8bfebdc7665f5d426cba3753e0e9c7d3154d7c6" @@ -1829,6 +2047,15 @@ resolved "https://registry.yarnpkg.com/@lifeomic/attempt/-/attempt-3.1.0.tgz#7fc703559177b81a008b9d263e3d9a001d11d08a" integrity sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw== +"@linear/sdk@^30.0.0": + version "30.0.0" + resolved "https://registry.yarnpkg.com/@linear/sdk/-/sdk-30.0.0.tgz#1c4f4f4cbc133d1e22e5f70eb1a5d23dbd45ff6b" + integrity sha512-H7FaZxt0qn1AojQd0UBAA+VyUjGnEH3jNhu5oh+O26hfbWiN32dVujLwrapUAeVd7Oyt5hg+5k8+6QVrjBnWOQ== + dependencies: + "@graphql-typed-document-node/core" "^3.1.0" + graphql "^15.4.0" + isomorphic-unfetch "^3.1.0" + "@mdx-js/react@^3.0.0": version "3.0.1" resolved "https://registry.yarnpkg.com/@mdx-js/react/-/react-3.0.1.tgz#997a19b3a5b783d936c75ae7c47cfe62f967f746" @@ -2159,6 +2386,184 @@ resolved "https://registry.yarnpkg.com/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz#3dc35ba0f1e66b403c00b39344f870298ebb1c8e" integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA== +"@oclif/core@>=3.26.0": + version "4.0.27" + resolved "https://registry.yarnpkg.com/@oclif/core/-/core-4.0.27.tgz#686079df278e681e3097cea301d5558fdb490e88" + integrity sha512-9j92jHr6k2tjQ6/mIwNi46Gqw+qbPFQ02mxT5T8/nxO2fgsPL3qL0kb9SR1il5AVfqpgLIG3uLUcw87rgaioUg== + dependencies: + ansi-escapes "^4.3.2" + ansis "^3.3.2" + clean-stack "^3.0.1" + cli-spinners "^2.9.2" + debug "^4.3.7" + ejs "^3.1.10" + get-package-type "^0.1.0" + globby "^11.1.0" + indent-string "^4.0.0" + is-wsl "^3" + lilconfig "^3.1.2" + minimatch "^9.0.5" + semver "^7.6.3" + string-width "^4.2.3" + supports-color "^8" + widest-line "^3.1.0" + wordwrap "^1.0.0" + wrap-ansi "^7.0.0" + +"@octokit/auth-app@^7.1.0": + version "7.1.1" + resolved "https://registry.yarnpkg.com/@octokit/auth-app/-/auth-app-7.1.1.tgz#d8916ad01e6ffb0a0a50507aa613e91fe7a49b93" + integrity sha512-kRAd6yelV9OgvlEJE88H0VLlQdZcag9UlLr7dV0YYP37X8PPDvhgiTy66QVhDXdyoT0AleFN2w/qXkPdrSzINg== + dependencies: + "@octokit/auth-oauth-app" "^8.1.0" + "@octokit/auth-oauth-user" "^5.1.0" + "@octokit/request" "^9.1.1" + "@octokit/request-error" "^6.1.1" + "@octokit/types" "^13.4.1" + lru-cache "^10.0.0" + universal-github-app-jwt "^2.2.0" + universal-user-agent "^7.0.0" + +"@octokit/auth-oauth-app@^8.1.0": + version "8.1.1" + resolved "https://registry.yarnpkg.com/@octokit/auth-oauth-app/-/auth-oauth-app-8.1.1.tgz#6204affa6e86f535016799cadf2af9befe5e893c" + integrity sha512-5UtmxXAvU2wfcHIPPDWzVSAWXVJzG3NWsxb7zCFplCWEmMCArSZV0UQu5jw5goLQXbFyOr5onzEH37UJB3zQQg== + dependencies: + "@octokit/auth-oauth-device" "^7.0.0" + "@octokit/auth-oauth-user" "^5.0.1" + "@octokit/request" "^9.0.0" + "@octokit/types" "^13.0.0" + universal-user-agent "^7.0.0" + +"@octokit/auth-oauth-device@^7.0.0", "@octokit/auth-oauth-device@^7.0.1": + version "7.1.1" + resolved "https://registry.yarnpkg.com/@octokit/auth-oauth-device/-/auth-oauth-device-7.1.1.tgz#7b4f8f97cbcadbe9894d48cde4406dbdef39875a" + integrity sha512-HWl8lYueHonuyjrKKIup/1tiy0xcmQCdq5ikvMO1YwkNNkxb6DXfrPjrMYItNLyCP/o2H87WuijuE+SlBTT8eg== + dependencies: + "@octokit/oauth-methods" "^5.0.0" + "@octokit/request" "^9.0.0" + "@octokit/types" "^13.0.0" + universal-user-agent "^7.0.0" + +"@octokit/auth-oauth-user@^5.0.1", "@octokit/auth-oauth-user@^5.1.0": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@octokit/auth-oauth-user/-/auth-oauth-user-5.1.1.tgz#4f1570c6ee15bb9ddc3dcca83308dcaa159e3848" + integrity sha512-rRkMz0ErOppdvEfnemHJXgZ9vTPhBuC6yASeFaB7I2yLMd7QpjfrL1mnvRPlyKo+M6eeLxrKanXJ9Qte29SRsw== + dependencies: + "@octokit/auth-oauth-device" "^7.0.1" + "@octokit/oauth-methods" "^5.0.0" + "@octokit/request" "^9.0.1" + "@octokit/types" "^13.0.0" + universal-user-agent "^7.0.0" + +"@octokit/auth-token@^5.0.0": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-5.1.1.tgz#3bbfe905111332a17f72d80bd0b51a3e2fa2cf07" + integrity sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA== + +"@octokit/core@^6.1.2": + version "6.1.2" + resolved "https://registry.yarnpkg.com/@octokit/core/-/core-6.1.2.tgz#20442d0a97c411612da206411e356014d1d1bd17" + integrity sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg== + dependencies: + "@octokit/auth-token" "^5.0.0" + "@octokit/graphql" "^8.0.0" + "@octokit/request" "^9.0.0" + "@octokit/request-error" "^6.0.1" + "@octokit/types" "^13.0.0" + before-after-hook "^3.0.2" + universal-user-agent "^7.0.0" + +"@octokit/endpoint@^10.0.0": + version "10.1.1" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-10.1.1.tgz#1a9694e7aef6aa9d854dc78dd062945945869bcc" + integrity sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q== + dependencies: + "@octokit/types" "^13.0.0" + universal-user-agent "^7.0.2" + +"@octokit/graphql@^8.0.0": + version "8.1.1" + resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-8.1.1.tgz#3cacab5f2e55d91c733e3bf481d3a3f8a5f639c4" + integrity sha512-ukiRmuHTi6ebQx/HFRCXKbDlOh/7xEV6QUXaE7MJEKGNAncGI/STSbOkl12qVXZrfZdpXctx5O9X1AIaebiDBg== + dependencies: + "@octokit/request" "^9.0.0" + "@octokit/types" "^13.0.0" + universal-user-agent "^7.0.0" + +"@octokit/oauth-authorization-url@^7.0.0": + version "7.1.1" + resolved "https://registry.yarnpkg.com/@octokit/oauth-authorization-url/-/oauth-authorization-url-7.1.1.tgz#0e17c2225eb66b58ec902d02b6f1315ffe9ff04b" + integrity sha512-ooXV8GBSabSWyhLUowlMIVd9l1s2nsOGQdlP2SQ4LnkEsGXzeCvbSbCPdZThXhEFzleGPwbapT0Sb+YhXRyjCA== + +"@octokit/oauth-methods@^5.0.0": + version "5.1.2" + resolved "https://registry.yarnpkg.com/@octokit/oauth-methods/-/oauth-methods-5.1.2.tgz#fd31d2a69f4c91d1abc1ed1814dda5252c697e02" + integrity sha512-C5lglRD+sBlbrhCUTxgJAFjWgJlmTx5bQ7Ch0+2uqRjYv7Cfb5xpX4WuSC9UgQna3sqRGBL9EImX9PvTpMaQ7g== + dependencies: + "@octokit/oauth-authorization-url" "^7.0.0" + "@octokit/request" "^9.1.0" + "@octokit/request-error" "^6.1.0" + "@octokit/types" "^13.0.0" + +"@octokit/openapi-types@^22.2.0": + version "22.2.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-22.2.0.tgz#75aa7dcd440821d99def6a60b5f014207ae4968e" + integrity sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg== + +"@octokit/plugin-paginate-rest@^11.0.0": + version "11.3.5" + resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.3.5.tgz#a1929b3ba3dc7b63bc73bb6d3c7a3faf2a9c7649" + integrity sha512-cgwIRtKrpwhLoBi0CUNuY83DPGRMaWVjqVI/bGKsLJ4PzyWZNaEmhHroI2xlrVXkk6nFv0IsZpOp+ZWSWUS2AQ== + dependencies: + "@octokit/types" "^13.6.0" + +"@octokit/plugin-request-log@^5.3.1": + version "5.3.1" + resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-5.3.1.tgz#ccb75d9705de769b2aa82bcd105cc96eb0c00f69" + integrity sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw== + +"@octokit/plugin-rest-endpoint-methods@^13.0.0": + version "13.2.6" + resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.2.6.tgz#b9d343dbe88a6cb70cc7fa16faa98f0a29ffe654" + integrity sha512-wMsdyHMjSfKjGINkdGKki06VEkgdEldIGstIEyGX0wbYHGByOwN/KiM+hAAlUwAtPkP3gvXtVQA9L3ITdV2tVw== + dependencies: + "@octokit/types" "^13.6.1" + +"@octokit/request-error@^6.0.1", "@octokit/request-error@^6.1.0", "@octokit/request-error@^6.1.1": + version "6.1.5" + resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-6.1.5.tgz#907099e341c4e6179db623a0328d678024f54653" + integrity sha512-IlBTfGX8Yn/oFPMwSfvugfncK2EwRLjzbrpifNaMY8o/HTEAFqCA1FZxjD9cWvSKBHgrIhc4CSBIzMxiLsbzFQ== + dependencies: + "@octokit/types" "^13.0.0" + +"@octokit/request@^9.0.0", "@octokit/request@^9.0.1", "@octokit/request@^9.1.0", "@octokit/request@^9.1.1": + version "9.1.3" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-9.1.3.tgz#42b693bc06238f43af3c037ebfd35621c6457838" + integrity sha512-V+TFhu5fdF3K58rs1pGUJIDH5RZLbZm5BI+MNF+6o/ssFNT4vWlCh/tVpF3NxGtP15HUxTTMUbsG5llAuU2CZA== + dependencies: + "@octokit/endpoint" "^10.0.0" + "@octokit/request-error" "^6.0.1" + "@octokit/types" "^13.1.0" + universal-user-agent "^7.0.2" + +"@octokit/rest@^21.0.1": + version "21.0.2" + resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-21.0.2.tgz#9b767dbc1098daea8310fd8b76bf7a97215d5972" + integrity sha512-+CiLisCoyWmYicH25y1cDfCrv41kRSvTq6pPWtRroRJzhsCZWZyCqGyI8foJT5LmScADSwRAnr/xo+eewL04wQ== + dependencies: + "@octokit/core" "^6.1.2" + "@octokit/plugin-paginate-rest" "^11.0.0" + "@octokit/plugin-request-log" "^5.3.1" + "@octokit/plugin-rest-endpoint-methods" "^13.0.0" + +"@octokit/types@^13.0.0", "@octokit/types@^13.1.0", "@octokit/types@^13.4.1", "@octokit/types@^13.5.0", "@octokit/types@^13.6.0", "@octokit/types@^13.6.1": + version "13.6.1" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-13.6.1.tgz#432fc6c0aaae54318e5b2d3e15c22ac97fc9b15f" + integrity sha512-PHZE9Z+kWXb23Ndik8MKPirBPziOc0D2/3KH1P+6jK5nGWe96kadZuE4jev2/Jq7FvIfTlT2Ltg8Fv2x1v0a5g== + dependencies: + "@octokit/openapi-types" "^22.2.0" + "@opentelemetry/api-logs@0.52.1": version "0.52.1" resolved "https://registry.yarnpkg.com/@opentelemetry/api-logs/-/api-logs-0.52.1.tgz#52906375da4d64c206b0c4cb8ffa209214654ecc" @@ -2787,6 +3192,40 @@ "@react-spring/shared" "~9.4.5" "@react-spring/types" "~9.4.5" +"@redis/bloom@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@redis/bloom/-/bloom-1.2.0.tgz#d3fd6d3c0af3ef92f26767b56414a370c7b63b71" + integrity sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg== + +"@redis/client@1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@redis/client/-/client-1.6.0.tgz#dcf4ae1319763db6fdddd6de7f0af68a352c30ea" + integrity sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg== + dependencies: + cluster-key-slot "1.1.2" + generic-pool "3.9.0" + yallist "4.0.0" + +"@redis/graph@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@redis/graph/-/graph-1.1.1.tgz#8c10df2df7f7d02741866751764031a957a170ea" + integrity sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw== + +"@redis/json@1.0.7": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@redis/json/-/json-1.0.7.tgz#016257fcd933c4cbcb9c49cde8a0961375c6893b" + integrity sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ== + +"@redis/search@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@redis/search/-/search-1.2.0.tgz#50976fd3f31168f585666f7922dde111c74567b8" + integrity sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw== + +"@redis/time-series@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@redis/time-series/-/time-series-1.1.0.tgz#cba454c05ec201bd5547aaf55286d44682ac8eb5" + integrity sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g== + "@remirror/core-constants@3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@remirror/core-constants/-/core-constants-3.0.0.tgz#96fdb89d25c62e7b6a5d08caf0ce5114370e3b8f" @@ -3079,7 +3518,7 @@ rollup "3.29.5" stacktrace-parser "^0.1.10" -"@sentry/node@8.33.1", "@sentry/node@^8.28.0": +"@sentry/node@8.33.1", "@sentry/node@^8.27.0", "@sentry/node@^8.28.0": version "8.33.1" resolved "https://registry.yarnpkg.com/@sentry/node/-/node-8.33.1.tgz#1f45d13e59b86e9572e3f7764a8c9e5fe4ece0a7" integrity sha512-0Xmlrl5nU5Bx6YybaIfztyOIiIXW5X64vcK0u94Sg4uHcDO7YvEbhflKjp669ds2I6ZQ/czqxnaAY8gM6P2SCA== @@ -3129,7 +3568,7 @@ "@sentry/types" "8.33.1" "@sentry/utils" "8.33.1" -"@sentry/profiling-node@^8.28.0": +"@sentry/profiling-node@^8.27.0", "@sentry/profiling-node@^8.28.0": version "8.33.1" resolved "https://registry.yarnpkg.com/@sentry/profiling-node/-/profiling-node-8.33.1.tgz#13143360b7adf3d35d2e701191378cde0a6cb419" integrity sha512-mpgcqT/yUyWiRKjHFJ6UMjNmMgAYK4aeaMQDL7P61y5mrVxfK9vRQbKuXdP9XiyiZe06j1PSskiBEA2S1cDVJQ== @@ -3655,6 +4094,11 @@ dependencies: "@swc/counter" "^0.1.3" +"@t3-oss/env-core@^0.11.1": + version "0.11.1" + resolved "https://registry.yarnpkg.com/@t3-oss/env-core/-/env-core-0.11.1.tgz#adc4022c62040ed6de5299e81cf65bb2aab90673" + integrity sha512-MaxOwEoG1ntCFoKJsS7nqwgcxLW1SJw238AJwfJeaz3P/8GtkxXZsPPolsz1AdYvUTbe3XvqZ/VCdfjt+3zmKw== + "@tailwindcss/typography@^0.5.9": version "0.5.15" resolved "https://registry.yarnpkg.com/@tailwindcss/typography/-/typography-0.5.15.tgz#007ab9870c86082a1c76e5b3feda9392c7c8d648" @@ -4014,6 +4458,20 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== +"@types/adm-zip@^0.5.5": + version "0.5.5" + resolved "https://registry.yarnpkg.com/@types/adm-zip/-/adm-zip-0.5.5.tgz#4588042726aa5f351d7ea88232e4a952f60e7c1a" + integrity sha512-YCGstVMjc4LTY5uK9/obvxBya93axZOVOyf2GSUulADzmLhYE45u2nAssCs/fWBs1Ifq5Vat75JTPwd5XZoPJw== + dependencies: + "@types/node" "*" + +"@types/amqplib@^0.10.5": + version "0.10.5" + resolved "https://registry.yarnpkg.com/@types/amqplib/-/amqplib-0.10.5.tgz#fd883eddfbd669702a727fa10007b27c4c1e6ec7" + integrity sha512-/cSykxROY7BWwDoi4Y4/jLAuZTshZxd8Ey1QYa/VaXriMotBDoou7V/twJiOSHzU6t1Kp1AHAUXGCgqq+6DNeg== + dependencies: + "@types/node" "*" + "@types/aria-query@^5.0.1": version "5.0.4" resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" @@ -4175,7 +4633,22 @@ resolved "https://registry.yarnpkg.com/@types/escodegen/-/escodegen-0.0.6.tgz#5230a9ce796e042cda6f086dbf19f22ea330659c" integrity sha512-AjwI4MvWx3HAOaZqYsjKWyEObT9lcVV0Y0V8nXo6cXzN8ZiMxVhf6F3d/UNvXVGKrEzL/Dluc5p+y9GkzlTWig== -"@types/estree@*", "@types/estree@1.0.6", "@types/estree@^1.0.0", "@types/estree@^1.0.5": +"@types/eslint@*": + version "9.6.1" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-9.6.1.tgz#d5795ad732ce81715f27f75da913004a56751584" + integrity sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag== + dependencies: + "@types/estree" "*" + "@types/json-schema" "*" + +"@types/eslint__js@^8.42.3": + version "8.42.3" + resolved "https://registry.yarnpkg.com/@types/eslint__js/-/eslint__js-8.42.3.tgz#d1fa13e5c1be63a10b4e3afe992779f81c1179a0" + integrity sha512-alfG737uhmPdnvkrLdZLcEKJ/B8s9Y4hrZ+YAdzUeoArBlSUERA2E87ROfOaS4jd/C45fzOoZzidLc1IPwLqOw== + dependencies: + "@types/eslint" "*" + +"@types/estree@*", "@types/estree@1.0.6", "@types/estree@^1.0.0", "@types/estree@^1.0.5", "@types/estree@^1.0.6": version "1.0.6" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== @@ -4263,7 +4736,7 @@ resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-3.0.6.tgz#a04ca19e877687bd449f5ad37d33b104b71fdf95" integrity sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ== -"@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": +"@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -4336,6 +4809,13 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== +"@types/multer@^1.4.12": + version "1.4.12" + resolved "https://registry.yarnpkg.com/@types/multer/-/multer-1.4.12.tgz#da67bd0c809f3a63fe097c458c0d4af1fea50ab7" + integrity sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg== + dependencies: + "@types/express" "*" + "@types/mysql@2.15.26": version "2.15.26" resolved "https://registry.yarnpkg.com/@types/mysql/-/mysql-2.15.26.tgz#f0de1484b9e2354d587e7d2bd17a873cc8300836" @@ -4384,6 +4864,13 @@ resolved "https://registry.yarnpkg.com/@types/nprogress/-/nprogress-0.2.3.tgz#b2150b054a13622fabcba12cf6f0b54c48b14287" integrity sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA== +"@types/papaparse@^5.3.14": + version "5.3.14" + resolved "https://registry.yarnpkg.com/@types/papaparse/-/papaparse-5.3.14.tgz#345cc2a675a90106ff1dc33b95500dfb30748031" + integrity sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g== + dependencies: + "@types/node" "*" + "@types/parse-json@^4.0.0": version "4.0.2" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239" @@ -4467,6 +4954,13 @@ dependencies: "@types/react" "*" +"@types/redis@^4.0.11": + version "4.0.11" + resolved "https://registry.yarnpkg.com/@types/redis/-/redis-4.0.11.tgz#0bb4c11ac9900a21ad40d2a6768ec6aaf651c0e1" + integrity sha512-bI+gth8La8Wg/QCR1+V1fhrL9+LZUSWfcqpOj2Kc80ZQ4ffbdL173vQd5wovmoV9i071FU9oP2g6etLuEwb6Rg== + dependencies: + redis "*" + "@types/resolve@^1.20.2": version "1.20.6" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.20.6.tgz#e6e60dad29c2c8c206c026e6dd8d6d1bdda850b8" @@ -4504,6 +4998,11 @@ resolved "https://registry.yarnpkg.com/@types/shimmer/-/shimmer-1.2.0.tgz#9b706af96fa06416828842397a70dfbbf1c14ded" integrity sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg== +"@types/triple-beam@^1.3.2": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c" + integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw== + "@types/trusted-types@*": version "2.0.7" resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" @@ -4546,7 +5045,7 @@ resolved "https://registry.yarnpkg.com/@types/zxcvbn/-/zxcvbn-4.4.5.tgz#8ce8623ed7a36e3a76d1c0b539708dfb2e859bc0" integrity sha512-FZJgC5Bxuqg7Rhsm/bx6gAruHHhDQ55r+s0JhDh8CQ16fD7NsJJ+p8YMMQDhSQoIrSmjpqqYWA96oQVMNkjRyA== -"@typescript-eslint/eslint-plugin@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/eslint-plugin@^8.6.0": +"@typescript-eslint/eslint-plugin@8.8.1", "@typescript-eslint/eslint-plugin@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/eslint-plugin@^8.6.0": version "8.8.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.1.tgz#9364b756d4d78bcbdf6fd3e9345e6924c68ad371" integrity sha512-xfvdgA8AP/vxHgtgU310+WBnLB4uJQ9XdyP17RebG26rLtDrQJV3ZYrcopX91GrHmMoH8bdSwMRh2a//TiJ1jQ== @@ -4577,22 +5076,7 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/eslint-plugin@^7.1.1": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz#b16d3cf3ee76bf572fdf511e79c248bdec619ea3" - integrity sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw== - dependencies: - "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "7.18.0" - "@typescript-eslint/type-utils" "7.18.0" - "@typescript-eslint/utils" "7.18.0" - "@typescript-eslint/visitor-keys" "7.18.0" - graphemer "^1.4.0" - ignore "^5.3.1" - natural-compare "^1.4.0" - ts-api-utils "^1.3.0" - -"@typescript-eslint/parser@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser@^8.6.0": +"@typescript-eslint/parser@8.8.1", "@typescript-eslint/parser@^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser@^8.6.0": version "8.8.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.8.1.tgz#5952ba2a83bd52024b872f3fdc8ed2d3636073b8" integrity sha512-hQUVn2Lij2NAxVFEdvIGxT9gP1tq2yM83m+by3whWFsWC+1y8pxxxHUFE1UqDu2VsGi2i6RLcv4QvouM84U+ow== @@ -4603,17 +5087,6 @@ "@typescript-eslint/visitor-keys" "8.8.1" debug "^4.3.4" -"@typescript-eslint/parser@^7.1.1": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.18.0.tgz#83928d0f1b7f4afa974098c64b5ce6f9051f96a0" - integrity sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg== - dependencies: - "@typescript-eslint/scope-manager" "7.18.0" - "@typescript-eslint/types" "7.18.0" - "@typescript-eslint/typescript-estree" "7.18.0" - "@typescript-eslint/visitor-keys" "7.18.0" - debug "^4.3.4" - "@typescript-eslint/scope-manager@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz#d9457ccc6a0b8d6b37d0eb252a23022478c5460c" @@ -4622,14 +5095,6 @@ "@typescript-eslint/types" "5.62.0" "@typescript-eslint/visitor-keys" "5.62.0" -"@typescript-eslint/scope-manager@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz#c928e7a9fc2c0b3ed92ab3112c614d6bd9951c83" - integrity sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA== - dependencies: - "@typescript-eslint/types" "7.18.0" - "@typescript-eslint/visitor-keys" "7.18.0" - "@typescript-eslint/scope-manager@8.8.1": version "8.8.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.8.1.tgz#b4bea1c0785aaebfe3c4ab059edaea1c4977e7ff" @@ -4648,16 +5113,6 @@ debug "^4.3.4" tsutils "^3.21.0" -"@typescript-eslint/type-utils@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz#2165ffaee00b1fbbdd2d40aa85232dab6998f53b" - integrity sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA== - dependencies: - "@typescript-eslint/typescript-estree" "7.18.0" - "@typescript-eslint/utils" "7.18.0" - debug "^4.3.4" - ts-api-utils "^1.3.0" - "@typescript-eslint/type-utils@8.8.1": version "8.8.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.8.1.tgz#31f59ec46e93a02b409fb4d406a368a59fad306e" @@ -4673,11 +5128,6 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f" integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ== -"@typescript-eslint/types@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.18.0.tgz#b90a57ccdea71797ffffa0321e744f379ec838c9" - integrity sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ== - "@typescript-eslint/types@8.8.1": version "8.8.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.8.1.tgz#ebe85e0fa4a8e32a24a56adadf060103bef13bd1" @@ -4696,20 +5146,6 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/typescript-estree@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz#b5868d486c51ce8f312309ba79bdb9f331b37931" - integrity sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA== - dependencies: - "@typescript-eslint/types" "7.18.0" - "@typescript-eslint/visitor-keys" "7.18.0" - debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - minimatch "^9.0.4" - semver "^7.6.0" - ts-api-utils "^1.3.0" - "@typescript-eslint/typescript-estree@8.8.1": version "8.8.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.8.1.tgz#34649f4e28d32ee49152193bc7dedc0e78e5d1ec" @@ -4738,16 +5174,6 @@ eslint-scope "^5.1.1" semver "^7.3.7" -"@typescript-eslint/utils@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.18.0.tgz#bca01cde77f95fc6a8d5b0dbcbfb3d6ca4be451f" - integrity sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw== - dependencies: - "@eslint-community/eslint-utils" "^4.4.0" - "@typescript-eslint/scope-manager" "7.18.0" - "@typescript-eslint/types" "7.18.0" - "@typescript-eslint/typescript-estree" "7.18.0" - "@typescript-eslint/utils@8.8.1": version "8.8.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.8.1.tgz#9e29480fbfa264c26946253daa72181f9f053c9d" @@ -4766,14 +5192,6 @@ "@typescript-eslint/types" "5.62.0" eslint-visitor-keys "^3.3.0" -"@typescript-eslint/visitor-keys@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz#0564629b6124d67607378d0f0332a0495b25e7d7" - integrity sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg== - dependencies: - "@typescript-eslint/types" "7.18.0" - eslint-visitor-keys "^3.4.3" - "@typescript-eslint/visitor-keys@8.8.1": version "8.8.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.8.1.tgz#0fb1280f381149fc345dfde29f7542ff4e587fc5" @@ -4782,6 +5200,13 @@ "@typescript-eslint/types" "8.8.1" eslint-visitor-keys "^3.4.3" +"@typescript/vfs@^1.5.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@typescript/vfs/-/vfs-1.6.0.tgz#9c90d8c43f7ac53cc77d5959e5c4c9b639f0959e" + integrity sha512-hvJUjNVeBMp77qPINuUvYXj4FyWeeMMKZkxEATEU3hqBAQ7qdTBCUFT7Sp0Zu0faeEtFf+ldXxMEDr/bk73ISg== + dependencies: + debug "^4.1.1" + "@ungap/structured-clone@^1.0.0", "@ungap/structured-clone@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" @@ -5015,11 +5440,16 @@ acorn@^7.4.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.11.0, acorn@^8.12.1, acorn@^8.4.1, acorn@^8.7.1, acorn@^8.8.1, acorn@^8.8.2, acorn@^8.9.0: +acorn@^8.11.0, acorn@^8.12.0, acorn@^8.12.1, acorn@^8.4.1, acorn@^8.7.1, acorn@^8.8.1, acorn@^8.8.2, acorn@^8.9.0: version "8.12.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248" integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== +adm-zip@^0.5.16: + version "0.5.16" + resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.16.tgz#0b5e4c779f07dedea5805cdccb1147071d94a909" + integrity sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ== + agent-base@6: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -5073,6 +5503,23 @@ ajv@^8.0.0, ajv@^8.9.0: json-schema-traverse "^1.0.0" require-from-string "^2.0.2" +amqplib@^0.10.4: + version "0.10.4" + resolved "https://registry.yarnpkg.com/amqplib/-/amqplib-0.10.4.tgz#4058c775830c908267dc198969015e0e8d280e70" + integrity sha512-DMZ4eCEjAVdX1II2TfIUpJhfKAuoCeDIo/YyETbfAqehHTXxxs7WOOd+N1Xxr4cKhx12y23zk8/os98FxlZHrw== + dependencies: + "@acuminous/bitsyntax" "^0.1.2" + buffer-more-ints "~1.0.0" + readable-stream "1.x >=1.1.9" + url-parse "~1.5.10" + +ansi-escapes@^4.2.1, ansi-escapes@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + dependencies: + type-fest "^0.21.3" + ansi-html-community@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/ansi-html-community/-/ansi-html-community-0.0.8.tgz#69fbc4d6ccbe383f9736934ae34c3f8290f1bf41" @@ -5112,6 +5559,11 @@ ansi-styles@^6.1.0: resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== +ansis@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.3.2.tgz#15adc36fea112da95c74d309706e593618accac3" + integrity sha512-cFthbBlt+Oi0i9Pv/j6YdVWJh54CtjGACaMPCIrEV4Ha7HWsIjXDwseYV79TIL0B4+KfSwD5S70PeQDkPUd1rA== + any-promise@^1.0.0: version "1.3.0" resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" @@ -5125,6 +5577,11 @@ anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" +append-field@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" + integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw== + arg@^4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" @@ -5287,6 +5744,11 @@ async-lock@^1.3.1: resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.4.1.tgz#56b8718915a9b68b10fce2f2a9a3dddf765ef53f" integrity sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ== +async@^3.2.3: + version "3.2.6" + resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" + integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -5338,7 +5800,7 @@ axe-core@^4.10.0: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.0.tgz#d9e56ab0147278272739a000880196cdfe113b59" integrity sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g== -axios@^1.7.2, axios@^1.7.4: +axios@^1.7.2, axios@^1.7.4, axios@^1.7.7: version "1.7.7" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.7.tgz#2f554296f9892a72ac8d8e4c5b79c14a91d0a47f" integrity sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q== @@ -5418,6 +5880,11 @@ basic-auth@~2.0.1: dependencies: safe-buffer "5.1.2" +before-after-hook@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-3.0.2.tgz#d5665a5fa8b62294a5aa0a499f933f4a1016195d" + integrity sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A== + better-opn@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/better-opn/-/better-opn-3.0.2.tgz#f96f35deaaf8f34144a4102651babcf00d1d8817" @@ -5442,6 +5909,15 @@ bind-event-listener@^3.0.0: resolved "https://registry.yarnpkg.com/bind-event-listener/-/bind-event-listener-3.0.0.tgz#c90f9a7fcb65cac21045f810c20ef7e647a74921" integrity sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q== +bl@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + bluebird@^3.7.2: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -5526,6 +6002,19 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +buffer-more-ints@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-more-ints/-/buffer-more-ints-1.0.0.tgz#ef4f8e2dddbad429ed3828a9c55d44f05c611422" + integrity sha512-EMetuGFz5SLsT0QTnXzINh4Ksr+oo4i+UGTXEshiGCQWnsgSs7ZhJ8fzlwQ+OzEMs0MpDAMr1hxnblp5a4vcHg== + +buffer@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + buffer@^6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" @@ -5541,7 +6030,14 @@ bundle-require@^4.0.0: dependencies: load-tsconfig "^0.2.3" -busboy@1.6.0: +bundle-require@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/bundle-require/-/bundle-require-5.0.0.tgz#071521bdea6534495cf23e92a83f889f91729e93" + integrity sha512-GuziW3fSSmopcx4KRymQEJVbZUfqlCqcq7dvs6TYwKRZiegK/2buMxQTPs6MGlNv50wms1699qYO54R8XfRX4w== + dependencies: + load-tsconfig "^0.2.3" + +busboy@1.6.0, busboy@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== @@ -5558,7 +6054,7 @@ bytes@3.1.2: resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== -cac@^6.7.12: +cac@^6.7.12, cac@^6.7.14: version "6.7.14" resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== @@ -5574,7 +6070,7 @@ call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: get-intrinsic "^1.2.4" set-function-length "^1.2.1" -callsites@^3.0.0: +callsites@^3.0.0, callsites@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== @@ -5611,6 +6107,11 @@ case-sensitive-paths-webpack-plugin@^2.4.0: resolved "https://registry.yarnpkg.com/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz#db64066c6422eed2e08cc14b986ca43796dbc6d4" integrity sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw== +case@^1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/case/-/case-1.6.3.tgz#0a4386e3e9825351ca2e6216c60467ff5f1ea1c9" + integrity sha512-mzDSXIPaFwVDvZAHqZ9VlbyF4yyXRuX6IvB06WvPYkqJVO24kX1PPhv9bfpKNFZyxYFmmgo03HUiD8iklmJYRQ== + chai@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/chai/-/chai-5.1.1.tgz#f035d9792a22b481ead1c65908d14bb62ec1c82c" @@ -5639,7 +6140,7 @@ chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: +chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -5670,6 +6171,11 @@ character-entities@^2.0.0: resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22" integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ== +chardet@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" + integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== + check-error@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" @@ -5724,6 +6230,30 @@ clean-css@^5.2.2: dependencies: source-map "~0.6.0" +clean-stack@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-3.0.1.tgz#155bf0b2221bf5f4fba89528d24c5953f17fe3a8" + integrity sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg== + dependencies: + escape-string-regexp "4.0.0" + +cli-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" + integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + dependencies: + restore-cursor "^3.1.0" + +cli-spinners@^2.5.0, cli-spinners@^2.9.2: + version "2.9.2" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" + integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== + +cli-width@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" + integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw== + client-only@0.0.1, client-only@^0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" @@ -5738,6 +6268,11 @@ cliui@^8.0.1: strip-ansi "^6.0.1" wrap-ansi "^7.0.0" +clone@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" + integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== + clone@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" @@ -5758,7 +6293,7 @@ clsx@^2.0.0, clsx@^2.1.0, clsx@^2.1.1: resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== -cluster-key-slot@^1.1.0: +cluster-key-slot@1.1.2, cluster-key-slot@^1.1.0: version "1.1.2" resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac" integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA== @@ -5771,7 +6306,7 @@ cmdk@^1.0.0: "@radix-ui/react-dialog" "1.0.5" "@radix-ui/react-primitive" "1.0.3" -color-convert@^1.9.0: +color-convert@^1.9.0, color-convert@^1.9.3: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== @@ -5795,7 +6330,7 @@ color-name@^1.0.0, color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-string@^1.9.0, color-string@^1.9.1: +color-string@^1.6.0, color-string@^1.9.0, color-string@^1.9.1: version "1.9.1" resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== @@ -5803,6 +6338,14 @@ color-string@^1.9.0, color-string@^1.9.1: color-name "^1.0.0" simple-swizzle "^0.2.2" +color@^3.1.3: + version "3.2.1" + resolved "https://registry.yarnpkg.com/color/-/color-3.2.1.tgz#3544dc198caf4490c3ecc9a790b54fe9ff45e164" + integrity sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA== + dependencies: + color-convert "^1.9.3" + color-string "^1.6.0" + color@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" @@ -5816,6 +6359,14 @@ colorette@^2.0.10, colorette@^2.0.7: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== +colorspace@1.1.x: + version "1.1.4" + resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.4.tgz#8d442d1186152f60453bf8070cd66eb364e59243" + integrity sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w== + dependencies: + color "^3.1.3" + text-hex "1.0.x" + combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -5848,6 +6399,11 @@ commander@^8.3.0: resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== +commander@^9.0.0: + version "9.5.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-9.5.0.tgz#bc08d1eb5cedf7ccb797a96199d41c7bc3e60d30" + integrity sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ== + commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" @@ -5883,6 +6439,16 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +concat-stream@^1.5.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + concurrently@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-9.0.1.tgz#01e171bf6c7af0c022eb85daef95bff04d8185aa" @@ -5896,6 +6462,11 @@ concurrently@^9.0.1: tree-kill "^1.2.2" yargs "^17.7.2" +consola@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/consola/-/consola-3.2.3.tgz#0741857aa88cfa0d6fd53f1cff0375136e98502f" + integrity sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ== + constant-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/constant-case/-/constant-case-3.0.4.tgz#3b84a9aeaf4cf31ec45e6bf5de91bdfb0589faf1" @@ -5949,6 +6520,11 @@ core-js-compat@^3.38.0, core-js-compat@^3.38.1: dependencies: browserslist "^4.23.3" +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + cors@^2.8.5: version "2.8.5" resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" @@ -6085,6 +6661,11 @@ csstype@^3.0.2, csstype@^3.1.3: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== +csv-string@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/csv-string/-/csv-string-4.1.1.tgz#3ab81c702e15adb3396a9f98c3a703b77a0391cc" + integrity sha512-KGvaJEZEdh2O/EVvczwbPLqJZtSQaWQ4cEJbiOJEG4ALq+dBBqNmBkRXTF4NV79V25+XYtiqbco1IWrmHLm5FQ== + d3-array@2, d3-array@^2.3.0: version "2.12.1" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-2.12.1.tgz#e20b41aafcdffdf5d50928004ececf815a465e81" @@ -6306,7 +6887,7 @@ debug@2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5: +debug@4, debug@^4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.7: version "4.3.7" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== @@ -6393,6 +6974,13 @@ deepmerge@^4.2.2: resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== +defaults@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.4.tgz#b0b02062c1e2aa62ff5d9528f0f98baa90978d7a" + integrity sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A== + dependencies: + clone "^1.0.2" + define-data-property@^1.0.1, define-data-property@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" @@ -6627,6 +7215,20 @@ dotenv@16.0.3: resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.3.tgz#115aec42bac5053db3c456db30cc243a5a836a07" integrity sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ== +drizzle-kit@^0.22.4: + version "0.22.8" + resolved "https://registry.yarnpkg.com/drizzle-kit/-/drizzle-kit-0.22.8.tgz#0f85d84cd5a1dbad045228067105ff636fc597bf" + integrity sha512-VjI4wsJjk3hSqHSa3TwBf+uvH6M6pRHyxyoVbt935GUzP9tUR/BRZ+MhEJNgryqbzN2Za1KP0eJMTgKEPsalYQ== + dependencies: + "@esbuild-kit/esm-loader" "^2.5.5" + esbuild "^0.19.7" + esbuild-register "^3.5.0" + +drizzle-orm@^0.33.0: + version "0.33.0" + resolved "https://registry.yarnpkg.com/drizzle-orm/-/drizzle-orm-0.33.0.tgz#ece81e3e85f7559b5f7c01fc09e654e9a2f087fe" + integrity sha512-SHy72R2Rdkz0LEq0PSG/IdvnT3nGiWuRk+2tXZQ90GVq/XQhpCzu/EFT3V2rox+w8MlkBQxifF8pCStNYnERfA== + eastasianwidth@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" @@ -6637,6 +7239,13 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== +ejs@^3.1.10: + version "3.1.10" + resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b" + integrity sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA== + dependencies: + jake "^10.8.5" + electron-to-chromium@^1.5.28: version "1.5.33" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.33.tgz#8f64698661240e70fdbc4b032e6085e391f05e09" @@ -6664,6 +7273,11 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +enabled@2.0.x: + version "2.0.0" + resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2" + integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ== + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -6863,7 +7477,7 @@ esbuild-register@^3.5.0: dependencies: debug "^4.3.4" -"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0": +"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0", esbuild@^0.23.0: version "0.23.1" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.23.1.tgz#40fdc3f9265ec0beae6f59824ade1bd3d3d2dab8" integrity sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg== @@ -6893,7 +7507,7 @@ esbuild-register@^3.5.0: "@esbuild/win32-ia32" "0.23.1" "@esbuild/win32-x64" "0.23.1" -esbuild@^0.19.2: +esbuild@^0.19.2, esbuild@^0.19.7: version "0.19.12" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.12.tgz#dc82ee5dc79e82f5a5c3b4323a2a641827db3e04" integrity sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg== @@ -6922,6 +7536,34 @@ esbuild@^0.19.2: "@esbuild/win32-ia32" "0.19.12" "@esbuild/win32-x64" "0.19.12" +esbuild@~0.18.20: + version "0.18.20" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.18.20.tgz#4709f5a34801b43b799ab7d6d82f7284a9b7a7a6" + integrity sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA== + optionalDependencies: + "@esbuild/android-arm" "0.18.20" + "@esbuild/android-arm64" "0.18.20" + "@esbuild/android-x64" "0.18.20" + "@esbuild/darwin-arm64" "0.18.20" + "@esbuild/darwin-x64" "0.18.20" + "@esbuild/freebsd-arm64" "0.18.20" + "@esbuild/freebsd-x64" "0.18.20" + "@esbuild/linux-arm" "0.18.20" + "@esbuild/linux-arm64" "0.18.20" + "@esbuild/linux-ia32" "0.18.20" + "@esbuild/linux-loong64" "0.18.20" + "@esbuild/linux-mips64el" "0.18.20" + "@esbuild/linux-ppc64" "0.18.20" + "@esbuild/linux-riscv64" "0.18.20" + "@esbuild/linux-s390x" "0.18.20" + "@esbuild/linux-x64" "0.18.20" + "@esbuild/netbsd-x64" "0.18.20" + "@esbuild/openbsd-x64" "0.18.20" + "@esbuild/sunos-x64" "0.18.20" + "@esbuild/win32-arm64" "0.18.20" + "@esbuild/win32-ia32" "0.18.20" + "@esbuild/win32-x64" "0.18.20" + escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" @@ -6932,16 +7574,16 @@ escape-html@~1.0.3: resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== +escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== -escape-string-regexp@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" - integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== - escodegen@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17" @@ -7110,12 +7752,25 @@ eslint-scope@^7.2.2: esrecurse "^4.3.0" estraverse "^5.2.0" +eslint-scope@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.1.0.tgz#70214a174d4cbffbc3e8a26911d8bf51b9ae9d30" + integrity sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: version "3.4.3" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint@8, eslint@^8.57.0: +eslint-visitor-keys@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.1.0.tgz#1f785cc5e81eb7534523d85922248232077d2f8c" + integrity sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg== + +eslint@8: version "8.57.1" resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9" integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== @@ -7159,6 +7814,61 @@ eslint@8, eslint@^8.57.0: strip-ansi "^6.0.1" text-table "^0.2.0" +eslint@^9.9.1: + version "9.12.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.12.0.tgz#54fcba2876c90528396da0fa44b6446329031e86" + integrity sha512-UVIOlTEWxwIopRL1wgSQYdnVDcEvs2wyaO6DGo5mXqe3r16IoCNWkR29iHhyaP4cICWjbgbmFUGAhh0GJRuGZw== + dependencies: + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.11.0" + "@eslint/config-array" "^0.18.0" + "@eslint/core" "^0.6.0" + "@eslint/eslintrc" "^3.1.0" + "@eslint/js" "9.12.0" + "@eslint/plugin-kit" "^0.2.0" + "@humanfs/node" "^0.16.5" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.3.1" + "@types/estree" "^1.0.6" + "@types/json-schema" "^7.0.15" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^8.1.0" + eslint-visitor-keys "^4.1.0" + espree "^10.2.0" + esquery "^1.5.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + text-table "^0.2.0" + +esm@^3.2.25: + version "3.2.25" + resolved "https://registry.yarnpkg.com/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10" + integrity sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA== + +espree@^10.0.1, espree@^10.2.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.2.0.tgz#f4bcead9e05b0615c968e85f83816bc386a45df6" + integrity sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g== + dependencies: + acorn "^8.12.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.1.0" + espree@^9.6.0, espree@^9.6.1: version "9.6.1" resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" @@ -7173,7 +7883,7 @@ esprima@^4.0.1, esprima@~4.0.0: resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.4.2: +esquery@^1.4.2, esquery@^1.5.0: version "1.6.0" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== @@ -7234,7 +7944,7 @@ events@^3.2.0, events@^3.3.0: resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== -execa@^5.0.0: +execa@^5.0.0, execa@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== @@ -7298,6 +8008,15 @@ extend@^3.0.0: resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== +external-editor@^3.0.3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" + integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== + dependencies: + chardet "^0.7.0" + iconv-lite "^0.4.24" + tmp "^0.0.33" + fast-copy@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/fast-copy/-/fast-copy-3.0.2.tgz#59c68f59ccbcac82050ba992e0d5c389097c9d35" @@ -7368,11 +8087,28 @@ fault@^2.0.0: dependencies: format "^0.2.0" +fdir@^6.4.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.0.tgz#8e80ab4b18a2ac24beebf9d20d71e1bc2627dbae" + integrity sha512-3oB133prH1o4j/L5lLW7uOCF1PlD+/It2L0eL/iAqWMB91RBbqTewABqxhj0ibBd90EEmWZq7ntIWzVaWcXTGQ== + +fecha@^4.2.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.3.tgz#4d9ccdbc61e8629b259fdca67e65891448d569fd" + integrity sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw== + fflate@^0.4.8: version "0.4.8" resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae" integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA== +figures@^3.0.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" + integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== + dependencies: + escape-string-regexp "^1.0.5" + file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -7380,6 +8116,13 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + file-selector@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.6.0.tgz#fa0a8d9007b829504db4d07dd4de0310b65287dc" @@ -7387,6 +8130,13 @@ file-selector@^0.6.0: dependencies: tslib "^2.4.0" +filelist@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" + integrity sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q== + dependencies: + minimatch "^5.0.1" + filesize@^10.0.12: version "10.1.6" resolved "https://registry.yarnpkg.com/filesize/-/filesize-10.1.6.tgz#31194da825ac58689c0bce3948f33ce83aabd361" @@ -7470,11 +8220,24 @@ flat-cache@^3.0.4: keyv "^4.5.3" rimraf "^3.0.2" +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.4" + flatted@^3.2.9: version "3.3.1" resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a" integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw== +fn.name@1.x.x: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" + integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== + follow-redirects@^1.15.6: version "1.15.9" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" @@ -7576,7 +8339,7 @@ fs-extra@^10.0.0: jsonfile "^6.0.1" universalify "^2.0.0" -fs-extra@^11.0.0, fs-extra@^11.1.0: +fs-extra@^11.0.0, fs-extra@^11.1.0, fs-extra@^11.1.1: version "11.2.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" integrity sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw== @@ -7625,6 +8388,11 @@ functions-have-names@^1.2.3: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +generic-pool@3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/generic-pool/-/generic-pool-3.9.0.tgz#36f4a678e963f4fdb8707eab050823abc4e8f5e4" + integrity sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" @@ -7659,6 +8427,11 @@ get-nonce@^1.0.0: resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q== +get-package-type@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" + integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== + get-stdin@^9.0.0: version "9.0.0" resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-9.0.0.tgz#3983ff82e03d56f1b2ea0d3e60325f39d703a575" @@ -7678,7 +8451,7 @@ get-symbol-description@^1.0.2: es-errors "^1.3.0" get-intrinsic "^1.2.4" -get-tsconfig@^4.7.5: +get-tsconfig@^4.7.0, get-tsconfig@^4.7.5: version "4.8.1" resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.8.1.tgz#8995eb391ae6e1638d251118c7b56de7eb425471" integrity sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg== @@ -7766,6 +8539,11 @@ globals@^13.19.0: dependencies: type-fest "^0.20.2" +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== + globalthis@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" @@ -7774,7 +8552,7 @@ globalthis@^1.0.3: define-properties "^1.2.1" gopd "^1.0.1" -globby@^11.0.3, globby@^11.1.0: +globby@^11.0.3, globby@^11.0.4, globby@^11.1.0: version "11.1.0" resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== @@ -7815,6 +8593,11 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== +graphql@^15.4.0: + version "15.9.0" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.9.0.tgz#4e8ca830cfd30b03d44d3edd9cac2b0690304b53" + integrity sha512-GCOQdvm7XxV1S4U4CGrsdlEN37245eC8P9zaYCMr6K1BG0IPGy5lUwmJsEOGyl1GD6HXjOtl2keCP9asRBwNvA== + gud@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/gud/-/gud-1.0.0.tgz#a489581b17e6a70beca9abe3ae57de7a499852c0" @@ -8045,7 +8828,7 @@ hyphen@^1.6.4: resolved "https://registry.yarnpkg.com/hyphen/-/hyphen-1.10.6.tgz#0e779d280e696102b97d7e42f5ca5de2cc97e274" integrity sha512-fXHXcGFTXOvZTSkPJuGOQf5Lv5T/R2itiiCVPg9LxAje5D00O0pP83yJShFq5V89Ly//Gt6acj7z8pbBr34stw== -iconv-lite@0.4.24: +iconv-lite@0.4.24, iconv-lite@^0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -8064,7 +8847,7 @@ icss-utils@^5.0.0, icss-utils@^5.1.0: resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== -ieee754@^1.2.1: +ieee754@^1.1.13, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -8115,7 +8898,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.3: +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -8125,6 +8908,27 @@ inline-style-parser@0.1.1: resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz#ec8a3b429274e9c0a1f1c4ffa9453a7fef72cea1" integrity sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q== +inquirer@^8.2.0: + version "8.2.6" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-8.2.6.tgz#733b74888195d8d400a67ac332011b5fae5ea562" + integrity sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg== + dependencies: + ansi-escapes "^4.2.1" + chalk "^4.1.1" + cli-cursor "^3.1.0" + cli-width "^3.0.0" + external-editor "^3.0.3" + figures "^3.0.0" + lodash "^4.17.21" + mute-stream "0.0.8" + ora "^5.4.1" + run-async "^2.4.0" + rxjs "^7.5.5" + string-width "^4.1.0" + strip-ansi "^6.0.0" + through "^2.3.6" + wrap-ansi "^6.0.1" + internal-slot@^1.0.4, internal-slot@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802" @@ -8291,6 +9095,11 @@ is-docker@^2.0.0, is-docker@^2.1.1: resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== +is-docker@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-3.0.0.tgz#90093aa3106277d8a77a5910dbae71747e15a200" + integrity sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ== + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -8322,6 +9131,18 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" +is-inside-container@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-inside-container/-/is-inside-container-1.0.0.tgz#e81fba699662eb31dbdaf26766a61d4814717ea4" + integrity sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA== + dependencies: + is-docker "^3.0.0" + +is-interactive@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" + integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== + is-map@^2.0.2, is-map@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" @@ -8344,6 +9165,11 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +is-observable@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-observable/-/is-observable-2.1.0.tgz#5c8d733a0b201c80dff7bb7c0df58c6a255c7c69" + integrity sha512-DailKdLb0WU+xX8K5w7VsJhapwHLZ9jjmazqCJq4X12CTgqq73TKnbRcnSLuXYPOoLQgV5IrD7ePiX/h1vnkBw== + is-path-inside@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" @@ -8417,6 +9243,11 @@ is-typed-array@^1.1.13, is-typed-array@^1.1.3: dependencies: which-typed-array "^1.1.14" +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + is-url@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/is-url/-/is-url-1.2.4.tgz#04a4df46d28c4cff3d73d01ff06abeb318a1aa52" @@ -8449,11 +9280,28 @@ is-wsl@^2.2.0: dependencies: is-docker "^2.0.0" +is-wsl@^3: + version "3.1.0" + resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-3.1.0.tgz#e1c657e39c10090afcbedec61720f6b924c3cbd2" + integrity sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw== + dependencies: + is-inside-container "^1.0.0" + +isarray@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" + integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== + isarray@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + isexe@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" @@ -8468,6 +9316,14 @@ isomorphic-dompurify@^2.12.0: dompurify "^3.1.7" jsdom "^25.0.1" +isomorphic-unfetch@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/isomorphic-unfetch/-/isomorphic-unfetch-3.1.0.tgz#87341d5f4f7b63843d468438128cb087b7c3e98f" + integrity sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q== + dependencies: + node-fetch "^2.6.1" + unfetch "^4.2.0" + isomorphic.js@^0.2.4: version "0.2.5" resolved "https://registry.yarnpkg.com/isomorphic.js/-/isomorphic.js-0.2.5.tgz#13eecf36f2dba53e85d355e11bf9d4208c6f7f88" @@ -8502,6 +9358,16 @@ jackspeak@^3.1.2: optionalDependencies: "@pkgjs/parseargs" "^0.11.0" +jake@^10.8.5: + version "10.9.2" + resolved "https://registry.yarnpkg.com/jake/-/jake-10.9.2.tgz#6ae487e6a69afec3a5e167628996b59f35ae2b7f" + integrity sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA== + dependencies: + async "^3.2.3" + chalk "^4.0.2" + filelist "^1.0.4" + minimatch "^3.1.2" + jay-peg@^1.0.2: version "1.1.0" resolved "https://registry.yarnpkg.com/jay-peg/-/jay-peg-1.1.0.tgz#5cc2bc7a3023f62fcd862b97472263e308f6a3c6" @@ -8518,6 +9384,15 @@ jest-worker@^27.4.5: merge-stream "^2.0.0" supports-color "^8.0.0" +jira.js@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/jira.js/-/jira.js-4.0.2.tgz#13ab0ef2ecdc1809e0ebc6c33fb8378fff58c6cc" + integrity sha512-m9iwxYkJjGfExm/4I/XyLPraPyXAxuMrJXA3gXqZOEXQCwk1Ia7zmLVsqeDD8p9hhdX5iAaLJ8pg8iJTV5X1kw== + dependencies: + axios "^1.7.7" + form-data "^4.0.0" + tslib "^2.7.0" + jiti@^1.18.2, jiti@^1.21.0: version "1.21.6" resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.6.tgz#6c7f7398dd4b3142767f9a168af2f317a428d268" @@ -8660,7 +9535,7 @@ keycon@^1.2.0: "@scena/event-emitter" "^1.0.2" keycode "^2.2.0" -keyv@^4.5.3: +keyv@^4.5.3, keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== @@ -8672,6 +9547,11 @@ kleur@^4.0.3, kleur@^4.1.4: resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780" integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ== +kuler@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3" + integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A== + language-subtag-registry@^0.3.20: version "0.3.23" resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz#23529e04d9e3b74679d70142df3fd2eb6ec572e7" @@ -8704,7 +9584,7 @@ lilconfig@^2.1.0: resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ== -lilconfig@^3.0.0, lilconfig@^3.1.1: +lilconfig@^3.0.0, lilconfig@^3.1.1, lilconfig@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.2.tgz#e4a7c3cb549e3a606c8dcc32e5ae1005e62c05cb" integrity sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow== @@ -8808,6 +9688,26 @@ lodash@^4.0.1, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +log-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +logform@^2.6.0, logform@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/logform/-/logform-2.6.1.tgz#71403a7d8cae04b2b734147963236205db9b3df0" + integrity sha512-CdaO738xRapbKIMVn2m4F6KTj4j7ooJ8POVnebSgKo3KBz5axNXRAL7ZdRjIV6NOr2Uf4vjtRkxrFETOioCqSA== + dependencies: + "@colors/colors" "1.6.0" + "@types/triple-beam" "^1.3.2" + fecha "^4.2.0" + ms "^2.1.1" + safe-stable-stringify "^2.3.1" + triple-beam "^1.3.0" + loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -8845,7 +9745,7 @@ lowlight@^3.0.0: devlop "^1.0.0" highlight.js "~11.9.0" -lru-cache@^10.2.0: +lru-cache@^10.0.0, lru-cache@^10.2.0: version "10.4.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== @@ -9286,6 +10186,13 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" +minimatch@^5.0.1: + version "5.1.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" + integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + dependencies: + brace-expansion "^2.0.1" + minimatch@^8.0.2: version "8.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-8.0.4.tgz#847c1b25c014d4e9a7f68aaf63dedd668a626229" @@ -9293,7 +10200,7 @@ minimatch@^8.0.2: dependencies: brace-expansion "^2.0.1" -minimatch@^9.0.1, minimatch@^9.0.4: +minimatch@^9.0.1, minimatch@^9.0.4, minimatch@^9.0.5: version "9.0.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== @@ -9315,6 +10222,13 @@ minipass@^4.2.4: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== +mkdirp@^0.5.4: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + mobx-react-lite@^4.0.7: version "4.0.7" resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-4.0.7.tgz#f4e21e18d05c811010dcb1d3007e797924c4d90b" @@ -9370,6 +10284,29 @@ ms@2.1.3, ms@^2.1.1, ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +multer@^1.4.5-lts.1: + version "1.4.5-lts.1" + resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.5-lts.1.tgz#803e24ad1984f58edffbc79f56e305aec5cfd1ac" + integrity sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ== + dependencies: + append-field "^1.0.0" + busboy "^1.0.0" + concat-stream "^1.5.2" + mkdirp "^0.5.4" + object-assign "^4.1.1" + type-is "^1.6.4" + xtend "^4.0.0" + +mute-stream@0.0.8: + version "0.0.8" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" + integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== + +mylas@^2.1.9: + version "2.1.13" + resolved "https://registry.yarnpkg.com/mylas/-/mylas-2.1.13.tgz#1e23b37d58fdcc76e15d8a5ed23f9ae9fc0cbdf4" + integrity sha512-+MrqnJRtxdF+xngFfUUkIMQrUUL0KsxbADUkn23Z/4ibGg192Q+z+CQyiYwvWTsYjJygmMR8+w3ZDa98Zh6ESg== + mz@^2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32" @@ -9457,7 +10394,7 @@ node-abort-controller@^3.0.1: resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz#a94377e964a9a37ac3976d848cb5c765833b8548" integrity sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ== -node-fetch@^2.6.12, node-fetch@^2.6.7: +node-fetch@^2.6.1, node-fetch@^2.6.12, node-fetch@^2.6.7: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -9477,7 +10414,7 @@ node-releases@^2.0.18: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.18.tgz#f010e8d35e2fe8d6b2944f03f70213ecedc4ca3f" integrity sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g== -nodemon@^3.1.7: +nodemon@^3.1.4, nodemon@^3.1.7: version "3.1.7" resolved "https://registry.yarnpkg.com/nodemon/-/nodemon-3.1.7.tgz#07cb1f455f8bece6a499e0d72b5e029485521a54" integrity sha512-hLj7fuMow6f0lbB0cD14Lz2xNjwsyruH251Pk4t/yIitCFJbmY1myuLlHm/q06aST4jg6EgAh74PIBBrRqpVAQ== @@ -9619,6 +10556,11 @@ objectorarray@^1.0.5: resolved "https://registry.yarnpkg.com/objectorarray/-/objectorarray-1.0.5.tgz#2c05248bbefabd8f43ad13b41085951aac5e68a5" integrity sha512-eJJDYkhJFFbBBAxeh8xW+weHlkI28n2ZdQV/J/DNfWfSKlGEf2xcfAbZTv3riEXHAhL9SVOTs2pRmXiSTf78xg== +observable-fns@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/observable-fns/-/observable-fns-0.6.1.tgz#636eae4fdd1132e88c0faf38d33658cc79d87e37" + integrity sha512-9gRK4+sRWzeN6AOewNBTLXir7Zl/i3GB6Yl26gK4flxz8BXVpD3kt8amREmWNb0mxYOGDotvE5a4N+PtGGKdkg== + obuf@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" @@ -9655,7 +10597,14 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0: dependencies: wrappy "1" -onetime@^5.1.2: +one-time@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45" + integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g== + dependencies: + fn.name "1.x.x" + +onetime@^5.1.0, onetime@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== @@ -9683,11 +10632,31 @@ optionator@^0.9.3: type-check "^0.4.0" word-wrap "^1.2.5" +ora@^5.4.0, ora@^5.4.1: + version "5.4.1" + resolved "https://registry.yarnpkg.com/ora/-/ora-5.4.1.tgz#1b2678426af4ac4a509008e5e4ac9e9959db9e18" + integrity sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ== + dependencies: + bl "^4.1.0" + chalk "^4.1.0" + cli-cursor "^3.1.0" + cli-spinners "^2.5.0" + is-interactive "^1.0.0" + is-unicode-supported "^0.1.0" + log-symbols "^4.1.0" + strip-ansi "^6.0.0" + wcwidth "^1.0.1" + orderedmap@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.1.1.tgz#61481269c44031c449915497bf5a4ad273c512d2" integrity sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g== +os-tmpdir@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" + integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== + overlap-area@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/overlap-area/-/overlap-area-1.1.0.tgz#1fcaa21bdb9cb1ace973d9aa299ae6b56557a4c2" @@ -9755,6 +10724,11 @@ pako@~1.0.5: resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== +papaparse@^5.4.1: + version "5.4.1" + resolved "https://registry.yarnpkg.com/papaparse/-/papaparse-5.4.1.tgz#f45c0f871853578bd3a30f92d96fdcfb6ebea127" + integrity sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw== + param-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5" @@ -9920,6 +10894,11 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== +picomatch@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" + integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== + pify@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -10009,6 +10988,13 @@ pkg-up@^3.1.0: dependencies: find-up "^3.0.0" +plimit-lit@^1.2.6: + version "1.6.1" + resolved "https://registry.yarnpkg.com/plimit-lit/-/plimit-lit-1.6.1.tgz#a34594671b31ee8e93c72d505dfb6852eb72374a" + integrity sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA== + dependencies: + queue-lit "^1.5.1" + polished@^4.2.2: version "4.3.1" resolved "https://registry.yarnpkg.com/polished/-/polished-4.3.1.tgz#5a00ae32715609f83d89f6f31d0f0261c6170548" @@ -10076,6 +11062,13 @@ postcss-load-config@^5.0.0: lilconfig "^3.1.1" yaml "^2.4.2" +postcss-load-config@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz#6fd7dcd8ae89badcf1b2d644489cbabf83aa8096" + integrity sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g== + dependencies: + lilconfig "^3.1.1" + postcss-modules-extract-imports@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz#b4497cb85a9c0c4b5aabeb759bb25e8d89f15002" @@ -10207,6 +11200,11 @@ postgres-range@^1.1.1: resolved "https://registry.yarnpkg.com/postgres-range/-/postgres-range-1.1.4.tgz#a59c5f9520909bcec5e63e8cf913a92e4c952863" integrity sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w== +postgres@^3.4.4: + version "3.4.4" + resolved "https://registry.yarnpkg.com/postgres/-/postgres-3.4.4.tgz#adbe08dc1fff0dea3559aa4f83ded70a289a6cb8" + integrity sha512-IbyN+9KslkqcXa8AO9fxpk97PA4pzewvpi2B3Dwy9u4zpV32QicaEdgmF3eSQUzdRk7ttDHQejNgAEr4XoeH4A== + posthog-js@^1.131.3: version "1.167.0" resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.167.0.tgz#9a6f7dc26b292f846938655b513ecda484210d59" @@ -10236,12 +11234,17 @@ prettier-plugin-tailwindcss@^0.5.4: resolved "https://registry.yarnpkg.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.14.tgz#4482eed357d5e22eac259541c70aca5a4c7b9d5c" integrity sha512-Puaz+wPUAhFp8Lo9HuciYKM2Y2XExESjeT+9NQoVFXZsPPnc9VYss2SpxdQ6vbatmt8/4+SN0oe0I1cPDABg9Q== +prettier@3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.0.3.tgz#432a51f7ba422d1469096c0fdc28e235db8f9643" + integrity sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg== + prettier@^2.8.8: version "2.8.8" resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== -prettier@^3.2.5, prettier@latest: +prettier@^3.2.5, prettier@^3.3.3, prettier@latest: version "3.3.3" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105" integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew== @@ -10268,6 +11271,11 @@ pretty-hrtime@^1.0.3: resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" integrity sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A== +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + process-warning@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-4.0.0.tgz#581e3a7a1fb456c5f4fd239f76bce75897682d5a" @@ -10504,6 +11512,16 @@ qs@6.13.0, qs@^6.12.3: dependencies: side-channel "^1.0.6" +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + +queue-lit@^1.5.1: + version "1.5.2" + resolved "https://registry.yarnpkg.com/queue-lit/-/queue-lit-1.5.2.tgz#83c24d4f4764802377b05a6e5c73017caf3f8747" + integrity sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw== + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" @@ -10812,7 +11830,39 @@ read-cache@^1.0.0: dependencies: pify "^2.3.0" -readable-stream@^4.0.0: +"readable-stream@1.x >=1.1.9": + version "1.1.14" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9" + integrity sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.1" + isarray "0.0.1" + string_decoder "~0.10.x" + +readable-stream@^2.2.2: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^3.4.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readable-stream@^4.0.0, readable-stream@^4.5.2: version "4.5.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.5.2.tgz#9e7fc4c45099baeed934bff6eb97ba6cf2729e09" integrity sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g== @@ -10892,6 +11942,18 @@ redis-parser@^3.0.0: dependencies: redis-errors "^1.0.0" +redis@*, redis@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/redis/-/redis-4.7.0.tgz#b401787514d25dd0cfc22406d767937ba3be55d6" + integrity sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ== + dependencies: + "@redis/bloom" "1.2.0" + "@redis/client" "1.6.0" + "@redis/graph" "1.1.1" + "@redis/json" "1.0.7" + "@redis/search" "1.2.0" + "@redis/time-series" "1.1.0" + redlock@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/redlock/-/redlock-4.2.0.tgz#c26590768559afd5fff76aa1133c94b411ff4f5f" @@ -10899,6 +11961,11 @@ redlock@^4.2.0: dependencies: bluebird "^3.7.2" +reflect-metadata@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz#400c845b6cba87a21f2c65c4aeb158f4fa4d9c5b" + integrity sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q== + reflect.getprototypeof@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz#3ab04c32a8390b770712b7a8633972702d278859" @@ -11047,6 +12114,11 @@ require-in-the-middle@^7.1.1: module-details-from-path "^1.0.3" resolve "^1.22.8" +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + reselect@^4.1.7: version "4.1.8" resolved "https://registry.yarnpkg.com/reselect/-/reselect-4.1.8.tgz#3f5dc671ea168dccdeb3e141236f69f02eaec524" @@ -11085,6 +12157,14 @@ resolve@^2.0.0-next.5: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +restore-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" + integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + restructure@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/restructure/-/restructure-3.0.2.tgz#e6b2fad214f78edee21797fa8160fef50eb9b49a" @@ -11109,7 +12189,7 @@ rollup@3.29.5: optionalDependencies: fsevents "~2.3.2" -rollup@^4.0.2: +rollup@^4.0.2, rollup@^4.19.0: version "4.24.0" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.24.0.tgz#c14a3576f20622ea6a5c9cad7caca5e6e9555d05" integrity sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg== @@ -11144,6 +12224,11 @@ rrweb-cssom@^0.7.1: resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz#c73451a484b86dd7cfb1e0b2898df4b703183e4b" integrity sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg== +run-async@^2.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" + integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ== + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" @@ -11151,7 +12236,7 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -rxjs@^7.8.1: +rxjs@^7.4.0, rxjs@^7.5.5, rxjs@^7.8.1: version "7.8.1" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543" integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== @@ -11175,7 +12260,7 @@ safe-array-concat@^1.1.2: has-symbols "^1.0.3" isarray "^2.0.5" -safe-buffer@5.1.2: +safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1, safe-buffer@~5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== @@ -11421,7 +12506,7 @@ side-channel@^1.0.4, side-channel@^1.0.6: get-intrinsic "^1.2.4" object-inspect "^1.13.1" -signal-exit@^3.0.3: +signal-exit@^3.0.2, signal-exit@^3.0.3: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== @@ -11492,7 +12577,7 @@ source-map-js@^1.0.2, source-map-js@^1.2.1: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== -source-map-support@~0.5.20: +source-map-support@^0.5.21, source-map-support@~0.5.20: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== @@ -11527,6 +12612,11 @@ split2@^4.0.0: resolved "https://registry.yarnpkg.com/split2/-/split2-4.2.0.tgz#c9c5920904d148bab0b9f67145f245a86aadbfa4" integrity sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg== +stack-trace@0.0.x: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0" + integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg== + stacktrace-parser@^0.1.10: version "0.1.10" resolved "https://registry.yarnpkg.com/stacktrace-parser/-/stacktrace-parser-0.1.10.tgz#29fb0cae4e0d0b85155879402857a1639eb6051a" @@ -11563,7 +12653,16 @@ streamsearch@^1.1.0: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -11650,7 +12749,26 @@ string_decoder@^1.1.1, string_decoder@^1.3.0: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +string_decoder@~0.10.x: + version "0.10.31" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" + integrity sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ== + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -11717,7 +12835,7 @@ stylis@4.2.0: resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.2.0.tgz#79daee0208964c8fe695a42fcffcac633a211a51" integrity sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw== -sucrase@^3.20.3, sucrase@^3.32.0: +sucrase@^3.20.3, sucrase@^3.32.0, sucrase@^3.35.0: version "3.35.0" resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.0.tgz#57f17a3d7e19b36d8995f06679d121be914ae263" integrity sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA== @@ -11744,7 +12862,7 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -supports-color@^8.0.0, supports-color@^8.1.1: +supports-color@^8, supports-color@^8.0.0, supports-color@^8.1.1: version "8.1.1" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== @@ -11891,6 +13009,11 @@ terser@^5.10.0, terser@^5.26.0: commander "^2.20.0" source-map-support "~0.5.20" +text-hex@1.0.x: + version "1.0.0" + resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5" + integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg== + text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -11922,6 +13045,23 @@ thread-stream@^3.0.0: dependencies: real-require "^0.2.0" +threads@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/threads/-/threads-1.7.0.tgz#d9e9627bfc1ef22ada3b733c2e7558bbe78e589c" + integrity sha512-Mx5NBSHX3sQYR6iI9VYbgHKBLisyB+xROCBGjjWm1O9wb9vfLxdaGtmT/KCjUqMsSNW6nERzCW3T6H43LqjDZQ== + dependencies: + callsites "^3.1.0" + debug "^4.2.0" + is-observable "^2.1.0" + observable-fns "^0.6.1" + optionalDependencies: + tiny-worker ">= 2" + +through@^2.3.6: + version "2.3.8" + resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" + integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== + tiny-inflate@^1.0.0, tiny-inflate@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" @@ -11932,11 +13072,26 @@ tiny-invariant@^1.3.1, tiny-invariant@^1.3.3: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== +"tiny-worker@>= 2": + version "2.3.0" + resolved "https://registry.yarnpkg.com/tiny-worker/-/tiny-worker-2.3.0.tgz#715ae34304c757a9af573ae9a8e3967177e6011e" + integrity sha512-pJ70wq5EAqTAEl9IkGzA+fN0836rycEuz2Cn6yeZ6FRzlVS5IDOkFHpIoEsksPRQV34GDqXm65+OlnZqUSyK2g== + dependencies: + esm "^3.2.25" + tinycolor2@^1.4.1: version "1.6.0" resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== +tinyglobby@^0.2.1: + version "0.2.9" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.9.tgz#6baddd1b0fe416403efb0dd40442c7d7c03c1c66" + integrity sha512-8or1+BGEdk1Zkkw2ii16qSS7uVrQJPre5A9o/XkWPATkk23FZh/15BKFxPnlTy6vkljZxLqYCzzBMj30ZrSvjw== + dependencies: + fdir "^6.4.0" + picomatch "^4.0.2" + tinyrainbow@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-1.2.0.tgz#5c57d2fc0fb3d1afd78465c33ca885d04f02abb5" @@ -11976,6 +13131,13 @@ tldts@^6.1.32: dependencies: tldts-core "^6.1.50" +tmp@^0.0.33: + version "0.0.33" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" + integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== + dependencies: + os-tmpdir "~1.0.2" + to-fast-properties@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" @@ -12034,6 +13196,11 @@ trim-lines@^3.0.0: resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== +triple-beam@^1.3.0: + version "1.4.1" + resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.4.1.tgz#6fde70271dc6e5d73ca0c3b24e2d92afb7441984" + integrity sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg== + trough@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f" @@ -12073,6 +13240,40 @@ ts-node@^10.9.2: v8-compile-cache-lib "^3.0.1" yn "3.1.1" +ts-to-zod@^3.13.0: + version "3.13.0" + resolved "https://registry.yarnpkg.com/ts-to-zod/-/ts-to-zod-3.13.0.tgz#6c61083cf0e0bde3997caf5358fa821c6ece15be" + integrity sha512-cxWldaz14ta79Npacn4nVTQgoNIyUMNHIv8JIvLQswAlx/z8S3piHn7EzA6SSKOEhXtn67JvNiEKWcggTCllBQ== + dependencies: + "@oclif/core" ">=3.26.0" + "@typescript/vfs" "^1.5.0" + case "^1.6.3" + chokidar "^3.5.1" + fs-extra "^11.1.1" + inquirer "^8.2.0" + lodash "^4.17.21" + ora "^5.4.0" + prettier "3.0.3" + rxjs "^7.4.0" + slash "^3.0.0" + threads "^1.7.0" + tslib "^2.3.1" + tsutils "^3.21.0" + typescript "^5.2.2" + zod "^3.23.8" + +tsc-alias@^1.8.10: + version "1.8.10" + resolved "https://registry.yarnpkg.com/tsc-alias/-/tsc-alias-1.8.10.tgz#279f9bf0dd8bc10fb27820393d4881db5a303938" + integrity sha512-Ibv4KAWfFkFdKJxnWfVtdOmB0Zi1RJVxcbPGiCDsFpCQSsmpWyuzHG3rQyI5YkobWwxFPEyQfu1hdo4qLG2zPw== + dependencies: + chokidar "^3.5.3" + commander "^9.0.0" + globby "^11.0.4" + mylas "^2.1.9" + normalize-path "^3.0.0" + plimit-lit "^1.2.6" + tsconfig-paths@^3.15.0: version "3.15.0" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4" @@ -12097,7 +13298,7 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.7.0: version "2.7.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.7.0.tgz#d9b40c5c40ab59e8738f297df3087bf1a2690c01" integrity sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA== @@ -12127,6 +13328,28 @@ tsup@^7.2.0: sucrase "^3.20.3" tree-kill "^1.2.2" +tsup@^8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/tsup/-/tsup-8.3.0.tgz#c7dae40b13d11d81fb144c0f90077a99102a572a" + integrity sha512-ALscEeyS03IomcuNdFdc0YWGVIkwH1Ws7nfTbAPuoILvEV2hpGQAY72LIOjglGo4ShWpZfpBqP/jpQVCzqYQag== + dependencies: + bundle-require "^5.0.0" + cac "^6.7.14" + chokidar "^3.6.0" + consola "^3.2.3" + debug "^4.3.5" + esbuild "^0.23.0" + execa "^5.1.1" + joycon "^3.1.1" + picocolors "^1.0.1" + postcss-load-config "^6.0.1" + resolve-from "^5.0.0" + rollup "^4.19.0" + source-map "0.8.0-beta.0" + sucrase "^3.35.0" + tinyglobby "^0.2.1" + tree-kill "^1.2.2" + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -12193,6 +13416,11 @@ type-fest@^0.20.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== + type-fest@^0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.7.1.tgz#8dda65feaf03ed78f0a3f9678f1869147f7c5c48" @@ -12203,7 +13431,7 @@ type-fest@^2.19.0, type-fest@~2.19: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== -type-is@~1.6.18: +type-is@^1.6.4, type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== @@ -12260,22 +13488,31 @@ typed-styles@^0.0.7: resolved "https://registry.yarnpkg.com/typed-styles/-/typed-styles-0.0.7.tgz#93392a008794c4595119ff62dde6809dbc40a3d9" integrity sha512-pzP0PWoZUhsECYjABgCGQlRGL1n7tOHsgwYv3oIiEpJwGhFTuty/YNeduxQYzXXa3Ge5BdT6sHYIQYpl4uJ+5Q== +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== + +typescript-eslint@^8.4.0: + version "8.8.1" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.8.1.tgz#b375c877b2184d883b6228170bc66f0fca847c9a" + integrity sha512-R0dsXFt6t4SAFjUSKFjMh4pXDtq04SsFKCVGDP3ZOzNP7itF0jBcZYU4fMsZr4y7O7V7Nc751dDeESbe4PbQMQ== + dependencies: + "@typescript-eslint/eslint-plugin" "8.8.1" + "@typescript-eslint/parser" "8.8.1" + "@typescript-eslint/utils" "8.8.1" + typescript@5.3.3: version "5.3.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== -typescript@5.4.5: - version "5.4.5" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611" - integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== - typescript@^4.7.2: version "4.9.5" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== -typescript@^5.6.2: +typescript@^5.2.2, typescript@^5.3.3: version "5.6.2" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.2.tgz#d1de67b6bef77c41823f822df8f0b3bcff60a5a0" integrity sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw== @@ -12310,6 +13547,11 @@ undici-types@~6.19.2: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== +unfetch@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/unfetch/-/unfetch-4.2.0.tgz#7e21b0ef7d363d8d9af0fb929a5555f6ef97a3be" + integrity sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA== + unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz#cb3173fe47ca743e228216e4a3ddc4c84d628cc2" @@ -12434,6 +13676,16 @@ unist-util-visit@^5.0.0: unist-util-is "^6.0.0" unist-util-visit-parents "^6.0.0" +universal-github-app-jwt@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/universal-github-app-jwt/-/universal-github-app-jwt-2.2.0.tgz#dc6c8929e76f1996a766ba2a08fb420f73365d77" + integrity sha512-G5o6f95b5BggDGuUfKDApKaCgNYy2x7OdHY0zSMF081O0EJobw+1130VONhrA7ezGSV2FNOGyM+KQpQZAr9bIQ== + +universal-user-agent@^7.0.0, universal-user-agent@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-7.0.2.tgz#52e7d0e9b3dc4df06cc33cb2b9fd79041a54827e" + integrity sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q== + universalify@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" @@ -12491,6 +13743,14 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +url-parse@~1.5.10: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + url@^0.11.0: version "0.11.4" resolved "https://registry.yarnpkg.com/url/-/url-0.11.4.tgz#adca77b3562d56b72746e76b330b7f27b6721f3c" @@ -12531,7 +13791,7 @@ use-sync-external-store@^1.2.0, use-sync-external-store@^1.2.2: resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9" integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw== -util-deprecate@^1.0.1, util-deprecate@^1.0.2: +util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== @@ -12661,6 +13921,13 @@ watchpack@^2.4.1: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" +wcwidth@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" + integrity sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg== + dependencies: + defaults "^1.0.3" + web-vitals@^4.0.1: version "4.2.3" resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-4.2.3.tgz#270c4baecfbc6ec6fc15da1989e465e5f9b94fb7" @@ -12839,12 +14106,68 @@ which@^2.0.1, which@^2.0.2: dependencies: isexe "^2.0.0" +widest-line@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" + integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== + dependencies: + string-width "^4.0.0" + +winston-transport@^4.7.0: + version "4.8.0" + resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.8.0.tgz#a15080deaeb80338455ac52c863418c74fcf38ea" + integrity sha512-qxSTKswC6llEMZKgCQdaWgDuMJQnhuvF5f2Nk3SNXc4byfQ+voo2mX1Px9dkNOuR8p0KAjfPG29PuYUSIb+vSA== + dependencies: + logform "^2.6.1" + readable-stream "^4.5.2" + triple-beam "^1.3.0" + +winston@^3.14.2: + version "3.15.0" + resolved "https://registry.yarnpkg.com/winston/-/winston-3.15.0.tgz#4df7b70be091bc1a38a4f45b969fa79589b73ff5" + integrity sha512-RhruH2Cj0bV0WgNL+lOfoUBI4DVfdUNjVnJGVovWZmrcKtrFTTRzgXYK2O9cymSGjrERCtaAeHwMNnUWXlwZow== + dependencies: + "@colors/colors" "^1.6.0" + "@dabh/diagnostics" "^2.0.2" + async "^3.2.3" + is-stream "^2.0.0" + logform "^2.6.0" + one-time "^1.0.0" + readable-stream "^3.4.0" + safe-stable-stringify "^2.3.1" + stack-trace "0.0.x" + triple-beam "^1.3.0" + winston-transport "^4.7.0" + word-wrap@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +wordwrap@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" + integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^6.0.1: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -12918,6 +14241,11 @@ y18n@^5.0.5: resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== +yallist@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" @@ -12981,6 +14309,11 @@ zeed-dom@^0.15.1: css-what "^6.1.0" entities "^5.0.0" +zod@^3.23.8: + version "3.23.8" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" + integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== + zxcvbn@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/zxcvbn/-/zxcvbn-4.4.2.tgz#28ec17cf09743edcab056ddd8b1b06262cc73c30"