From 1646bdf35e1324720a705fd8089b79fc4226dd66 Mon Sep 17 00:00:00 2001 From: martmull Date: Mon, 22 Jun 2026 21:08:40 +0200 Subject: [PATCH] Add twenty-partners on internal ci apps (#21975) as title Review in cubic --- .github/workflows/ci-internal-apps.yaml | 11 +--- .../internal/twenty-partners/.oxlintrc.json | 44 ++++++++++++++- .../src/__tests__/rate-limit-retry.setup.ts | 56 +++++++++++++++++++ .../on-partner-application-created.test.ts | 4 +- .../on-opportunity-auto-match.ts | 2 +- .../src/logic-functions/post-install.ts | 2 +- .../internal/twenty-partners/tsconfig.json | 2 +- .../internal/twenty-partners/vitest.config.ts | 24 +++----- 8 files changed, 114 insertions(+), 31 deletions(-) create mode 100644 packages/twenty-apps/internal/twenty-partners/src/__tests__/rate-limit-retry.setup.ts diff --git a/.github/workflows/ci-internal-apps.yaml b/.github/workflows/ci-internal-apps.yaml index 8348b6908c..a3efb721a7 100644 --- a/.github/workflows/ci-internal-apps.yaml +++ b/.github/workflows/ci-internal-apps.yaml @@ -8,7 +8,7 @@ on: workflow_dispatch: inputs: application: - description: 'Internal app folder name to test (e.g. "twenty-linear"). Leave empty to test all non-excluded apps. Runs even if in CI_EXCLUDED_APPLICATIONS.' + description: 'Internal app folder name to test (e.g. "twenty-linear"). Leave empty to test all apps.' required: false default: '' type: string @@ -62,11 +62,6 @@ jobs: const fs = require('fs'); const path = require('path'); const root = 'packages/twenty-apps/internal'; - // CI_EXCLUDED_APPLICATIONS is temporary: it skips applications from CI to - // keep this PR small while the remaining apps are made CI-ready. Remove an - // app from this list once its checks pass, and delete the list entirely - // once every application is covered. - const CI_EXCLUDED_APPLICATIONS = ['twenty-meeting-bot', 'twenty-partners']; const eventName = process.env.EVENT_NAME; const changedFiles = JSON.parse(process.env.CHANGED_FILES || '[]'); const changedApps = new Set(); @@ -81,9 +76,9 @@ jobs: .filter((name) => fs.existsSync(path.join(root, name, 'package.json'))) .filter((name) => { if (eventName === 'workflow_dispatch') { - return requestedApp ? name === requestedApp : !CI_EXCLUDED_APPLICATIONS.includes(name); + return requestedApp ? name === requestedApp : true; } - return !CI_EXCLUDED_APPLICATIONS.includes(name) && changedApps.has(name); + return changedApps.has(name); }) .map((name) => { const appPath = path.join(root, name); diff --git a/packages/twenty-apps/internal/twenty-partners/.oxlintrc.json b/packages/twenty-apps/internal/twenty-partners/.oxlintrc.json index 34c54bff59..2c2035b0fe 100644 --- a/packages/twenty-apps/internal/twenty-partners/.oxlintrc.json +++ b/packages/twenty-apps/internal/twenty-partners/.oxlintrc.json @@ -1,20 +1,58 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", "extends": ["../../.oxlintrc.base.json"], - "plugins": ["typescript"], + "plugins": ["react", "typescript", "import", "unicorn"], "categories": { "correctness": "off" }, "ignorePatterns": ["node_modules", "dist"], "rules": { + "func-style": ["error", "declaration", { "allowArrowFunctions": true }], + "no-console": "off", + "no-control-regex": "off", + "no-debugger": "error", + "no-duplicate-imports": "error", + "no-undef": "off", "no-unused-vars": "off", - + "no-redeclare": "off", + "import/no-duplicates": "error", + "typescript/no-redeclare": "error", + "typescript/ban-ts-comment": "error", + "typescript/consistent-type-imports": [ + "error", + { + "prefer": "type-imports", + "fixStyle": "inline-type-imports" + } + ], + "typescript/explicit-function-return-type": "off", + "typescript/explicit-module-boundary-types": "off", + "typescript/no-empty-object-type": [ + "error", + { + "allowInterfaces": "with-single-extends" + } + ], + "typescript/no-empty-function": "off", + "typescript/no-explicit-any": "off", "typescript/no-unused-vars": [ "warn", { + "vars": "all", + "varsIgnorePattern": "^_", + "args": "after-used", "argsIgnorePattern": "^_" } ], - "typescript/no-explicit-any": "off" + "react/no-unescaped-entities": "off", + "react/prop-types": "off", + "react/jsx-key": "off", + "react/display-name": "off", + "react/jsx-uses-react": "off", + "react/react-in-jsx-scope": "off", + "react/jsx-no-useless-fragment": "off", + "react/jsx-props-no-spreading": ["error", { "explicitSpread": "ignore" }], + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn" } } diff --git a/packages/twenty-apps/internal/twenty-partners/src/__tests__/rate-limit-retry.setup.ts b/packages/twenty-apps/internal/twenty-partners/src/__tests__/rate-limit-retry.setup.ts new file mode 100644 index 0000000000..aaf6d4fd8f --- /dev/null +++ b/packages/twenty-apps/internal/twenty-partners/src/__tests__/rate-limit-retry.setup.ts @@ -0,0 +1,56 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; + +// Integration tests authenticate with the workspace API key, which the server +// rate-limits per workspace (API_RATE_LIMITING_LONG_LIMIT requests per window). +// The full suite issues more API calls than one window allows, so later calls +// would fail with "Limit reached". The server refuses a throttled request +// without consuming a token and its bucket refills continuously, so waiting +// briefly frees a token. Transparently retry rate-limited SDK calls after a +// short delay — patching the client prototype once covers every test. + +const RATE_LIMIT_MAX_RETRIES = 30; +const RATE_LIMIT_RETRY_DELAY_MS = 700; + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +const isRateLimitError = (error: unknown): boolean => { + const message = error instanceof Error ? error.message : String(error); + return /limit reached|tokens per/i.test(message); +}; + +type AsyncMethod = (...args: unknown[]) => Promise; + +const RATE_LIMIT_PATCHED = Symbol.for('twenty-partners.rateLimitRetryPatched'); + +const patchMethod = (methodName: 'query' | 'mutation' | 'uploadFile'): void => { + const prototype = CoreApiClient.prototype as unknown as Record< + string, + AsyncMethod + >; + const original = prototype[methodName]; + if (typeof original !== 'function') return; + + prototype[methodName] = async function rateLimitRetry(...args: unknown[]) { + let attempt = 0; + for (;;) { + try { + return await original.apply(this, args); + } catch (error) { + if (!isRateLimitError(error) || attempt >= RATE_LIMIT_MAX_RETRIES) { + throw error; + } + attempt += 1; + await sleep(RATE_LIMIT_RETRY_DELAY_MS); + } + } + }; +}; + +const clientConstructor = CoreApiClient as unknown as Record; +if (!clientConstructor[RATE_LIMIT_PATCHED]) { + patchMethod('query'); + patchMethod('mutation'); + patchMethod('uploadFile'); + clientConstructor[RATE_LIMIT_PATCHED] = true; +} diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-partner-application-created.test.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-partner-application-created.test.ts index ebe575ff7d..87485cdf89 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-partner-application-created.test.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/__tests__/on-partner-application-created.test.ts @@ -4,7 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // factory reference the mock fn safely despite hoisting. const { queryMock } = vi.hoisted(() => ({ queryMock: vi.fn() })); vi.mock('twenty-client-sdk/core', () => ({ - CoreApiClient: vi.fn(() => ({ query: queryMock })), + CoreApiClient: vi.fn(function () { + return { query: queryMock }; + }), })); import { diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-auto-match.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-auto-match.ts index 5825c4b8a2..88400ce386 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-auto-match.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/on-opportunity-auto-match.ts @@ -1,4 +1,4 @@ -import { DatabaseEventPayload, defineLogicFunction } from 'twenty-sdk/define'; +import { type DatabaseEventPayload, defineLogicFunction } from 'twenty-sdk/define'; import { CoreApiClient } from 'twenty-client-sdk/core'; import { ON_OPP_AUTO_MATCH_FN_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; diff --git a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/post-install.ts b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/post-install.ts index 87f57f0424..dceb5b3c46 100644 --- a/packages/twenty-apps/internal/twenty-partners/src/logic-functions/post-install.ts +++ b/packages/twenty-apps/internal/twenty-partners/src/logic-functions/post-install.ts @@ -1,4 +1,4 @@ -import { InstallPayload, definePostInstallLogicFunction } from 'twenty-sdk/define'; +import { type InstallPayload, definePostInstallLogicFunction } from 'twenty-sdk/define'; import { CoreApiClient } from 'twenty-client-sdk/core'; const handler = async (_payload: InstallPayload) => { diff --git a/packages/twenty-apps/internal/twenty-partners/tsconfig.json b/packages/twenty-apps/internal/twenty-partners/tsconfig.json index d574c8c810..f71645f97a 100644 --- a/packages/twenty-apps/internal/twenty-partners/tsconfig.json +++ b/packages/twenty-apps/internal/twenty-partners/tsconfig.json @@ -6,7 +6,7 @@ "outDir": "./dist", "rootDir": ".", "jsx": "react-jsx", - "moduleResolution": "node", + "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, diff --git a/packages/twenty-apps/internal/twenty-partners/vitest.config.ts b/packages/twenty-apps/internal/twenty-partners/vitest.config.ts index 7ed938cad9..0263e53fd9 100644 --- a/packages/twenty-apps/internal/twenty-partners/vitest.config.ts +++ b/packages/twenty-apps/internal/twenty-partners/vitest.config.ts @@ -1,23 +1,14 @@ -import { loadEnv } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; import { defineConfig } from 'vitest/config'; -// Integration tests authenticate to a local Twenty server. Credentials are -// resolved from the shell env, then a gitignored .env.local in this directory -// (see .env.example). No API key is committed; if none is found, global-setup -// fails with a clear message. -const fileEnv = loadEnv('test', process.cwd(), 'TWENTY_'); +const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020'; +const TWENTY_API_KEY = + process.env.TWENTY_API_KEY ?? + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc'; -const TWENTY_API_URL = - process.env.TWENTY_API_URL ?? fileEnv.TWENTY_API_URL ?? 'http://localhost:2020'; -const TWENTY_API_KEY = process.env.TWENTY_API_KEY ?? fileEnv.TWENTY_API_KEY; - -// Make env available to globalSetup (runs in the main process); test.env below -// covers the worker processes. +// Make env vars available to globalSetup (test.env only applies to workers) process.env.TWENTY_API_URL = TWENTY_API_URL; -if (TWENTY_API_KEY) { - process.env.TWENTY_API_KEY = TWENTY_API_KEY; -} +process.env.TWENTY_API_KEY = TWENTY_API_KEY; export default defineConfig({ plugins: [ @@ -32,9 +23,10 @@ export default defineConfig({ fileParallelism: false, include: ['src/**/*.integration-test.ts'], globalSetup: ['src/__tests__/global-setup.ts'], + setupFiles: ['src/__tests__/rate-limit-retry.setup.ts'], env: { TWENTY_API_URL, - ...(TWENTY_API_KEY ? { TWENTY_API_KEY } : {}), + TWENTY_API_KEY, }, }, });