diff --git a/packages/twenty-apps/community/github-connector/.gitignore b/packages/twenty-apps/community/github-connector/.gitignore new file mode 100644 index 0000000000..5fc90b42ab --- /dev/null +++ b/packages/twenty-apps/community/github-connector/.gitignore @@ -0,0 +1,32 @@ +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn + +# codegen +generated + +# dev +/dist/ +.twenty + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* + +# typescript +*.tsbuildinfo +*.d.ts diff --git a/packages/twenty-apps/community/github-connector/.nvmrc b/packages/twenty-apps/community/github-connector/.nvmrc new file mode 100644 index 0000000000..341cb50613 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/.nvmrc @@ -0,0 +1 @@ +24.5.0 diff --git a/packages/twenty-apps/community/github-connector/.oxlintrc.json b/packages/twenty-apps/community/github-connector/.oxlintrc.json new file mode 100644 index 0000000000..87c62c5183 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/.oxlintrc.json @@ -0,0 +1,19 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript"], + "categories": { + "correctness": "off" + }, + "ignorePatterns": ["node_modules", "dist"], + "rules": { + "no-unused-vars": "off", + + "typescript/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_" + } + ], + "typescript/no-explicit-any": "off" + } +} diff --git a/packages/twenty-apps/community/github-connector/.yarnrc.yml b/packages/twenty-apps/community/github-connector/.yarnrc.yml new file mode 100644 index 0000000000..3186f3f079 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/packages/twenty-apps/community/github-connector/README.md b/packages/twenty-apps/community/github-connector/README.md new file mode 100644 index 0000000000..a7383c4da0 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/README.md @@ -0,0 +1,254 @@ +# GitHub Connector + +Sync pull requests, issues, contributors and project items from GitHub into +Twenty, and react to GitHub webhook events in real time. + +This app showcases how to build a non-trivial third-party connector with the +Twenty SDK: custom objects with rich relationships, navigation menu items, +table views, a dashboard page layout, logic functions for periodic syncs, an +HTTP webhook handler, and authenticated GraphQL/REST calls against an +external provider. + +![GitHub Connector marketplace listing in Settings → Apps with About / Content / Permissions / Settings tabs](public/screenshots/app-listing.png) + +![GitHub Dashboard with PR / review counters, weekly histograms, and top-contributor leaderboards](public/screenshots/github-dashboard.png) + +![Pull Requests view with the Fetch Pull Requests command](public/screenshots/pull-requests-view.png) + +![Contributor detail page with the Contributor Stats panel showing PRs authored, merged and reviewed over time](public/screenshots/contributor-stats.png) + +## What it adds to your workspace + +### Custom objects + +Six custom objects, each with fields, relationships and table views: + +- `pullRequest` +- `pullRequestReview` +- `pullRequestReviewEvent` +- `issue` +- `projectItem` +- `contributor` + +### Navigation + +A top-level **GitHub** folder in the left sidebar with: + +- Pull Requests +- Issues +- Project Items +- Contributors +- Pull Request Reviews +- Pull Request Review Events +- GitHub Dashboard (a page layout that aggregates PR activity over time and + surfaces top contributors) + +### Logic functions + +| Function | Trigger | +| --------------------------------- | ------------------------------------------------ | +| `count-prs` | HTTP `POST /github/count-prs` | +| `fetch-prs` | HTTP `POST /github/fetch-prs` | +| `count-issues` | HTTP `POST /github/count-issues` | +| `fetch-issues` | HTTP `POST /github/fetch-issues` | +| `count-contributors` | HTTP `POST /github/count-contributors` | +| `fetch-contributors` | HTTP `POST /github/fetch-contributors` | +| `count-project-items` | HTTP `POST /github/count-project-items` | +| `fetch-project-items` | HTTP `POST /github/fetch-project-items` | +| `handle-github-webhook` | HTTP `POST /github/webhook` (no auth, signed) | +| `search-contributors` | HTTP `POST /contributors/search` | +| `contributor-stats` | HTTP `POST /contributors/stats` | +| `top-contributors` | HTTP `POST /contributors/top` | +| `recompute-pull-request-reviews` | HTTP `POST /pull-request-reviews/recompute` | + +### Front components + +Seven front components surface the connector inside the Twenty UI: + +- **Fetch Pull Requests** — command on the Pull Request object +- **Fetch Issues** — command on the Issue object +- **Fetch Contributors** — command on the Contributor object +- **Fetch Project Items** — command on the Project Item object +- **Contributor Stats** — panel on the Contributor object that renders a + bar chart of PRs authored / merged / reviewed over the selected period +- **Top PR Authors** — dashboard widget that ranks the top 20 PR authors + over the last 90 days +- **Top Reviewers** — dashboard widget that ranks the top 20 PR reviewers + over the last 90 days + +## Install + +You have two options. Use **dev mode** for a tight edit/test loop while +iterating on the app, or **install** for a one-shot deploy. + +### Option A — Live development (`yarn twenty dev`) + +Use this when you want every code change to be re-synced into your local +Twenty server automatically. + +```bash +cd packages/twenty-apps/community/github-connector +yarn install + +# Register your local Twenty server as a remote (interactive prompt). +# When asked for the URL use http://localhost:2021 and paste an API key +# from Settings -> Developers in the Twenty UI. +yarn twenty remote add + +# Build, install, and watch for changes. +yarn twenty dev +``` + +The first `yarn twenty dev` run installs the app on the remote and starts +watching `src/`. Edit any file and the change is re-synced within seconds. + +### Option B — One-shot install + +```bash +cd packages/twenty-apps/community/github-connector +yarn install +yarn twenty remote add # same prompts as above +yarn twenty install # builds and installs once +``` + +## Configure authentication + +Once the app is installed, open the Twenty UI and go to +**Settings → Apps → GitHub Connector**. You only need one of the two auth +methods. + +### Option 1 — Personal Access Token (recommended for trying it out) + +| Variable | Required | Notes | +| --------------- | -------- | -------------------------------------------------------------------- | +| `GITHUB_TOKEN` | yes | Fine-grained PAT (`github_pat_…`). See permissions below. | + +Create a fine-grained PAT at +: + +1. **Resource owner**: the org (or user) that owns the repos in + `GITHUB_REPOS` and the projects in `GITHUB_PROJECTS`. Org-owned tokens + must be approved by an org admin before they can read org resources. +2. **Repository access**: pick the specific repos (or "All repositories"). +3. **Repository permissions** — set to **Read-only**: + - `Contents` + - `Issues` + - `Pull requests` + - `Metadata` (selected automatically) +4. **Organization permissions** — only if you want to sync GitHub Projects + (v2): set `Projects` to **Read-only**. +5. Generate, then copy the `github_pat_…` value. + +Classic PATs are intentionally not supported — fine-grained tokens are +scoped per-repo/per-org and avoid the all-or-nothing `repo` scope. + +When `GITHUB_TOKEN` is set, it always wins regardless of any GitHub App +config below. + +### Option 2 — GitHub App (recommended for production / org-wide installs) + +| Variable | Required | Notes | +| ---------------------------- | -------- | ---------------------------------------------------------------------- | +| `GITHUB_APP_ID` | yes | Numeric App ID from the GitHub App settings page. | +| `GITHUB_APP_PRIVATE_KEY` | yes | PEM private key (BEGIN/END PRIVATE KEY block). Newlines are tolerant. | +| `GITHUB_APP_INSTALLATION_ID` | yes | The installation id of the App on your org/user. | + +To create one: + +1. (or + `https://github.com/organizations//settings/apps/new`). +2. Grant the App these **repository permissions**: `Contents: Read`, + `Issues: Read`, `Pull Requests: Read`, `Metadata: Read`. For Projects v2 + add `Organization → Projects: Read`. +3. Generate a private key (downloads a `.pem`). +4. Install the App on your org/user — the URL bar of the post-install page + contains the installation id, e.g. `.../installations/12345678`. +5. Paste the App ID, the PEM contents, and the installation ID into the + variables above. + +The connector exchanges the App credentials for a short-lived installation +token (cached in-memory until shortly before expiry) and uses it for all +GitHub calls. + +### Common variables + +| Variable | Required | Notes | +| ------------------------- | -------- | --------------------------------------------------------------------------------------- | +| `GITHUB_REPOS` | yes | Comma-separated `owner/repo` list, e.g. `octocat/hello-world,octo-org/octo-repo`. | +| `GITHUB_PROJECTS` | no | Comma-separated GitHub Projects (v2). See format below. | +| `GITHUB_WEBHOOK_SECRET` | no | Shared secret to verify `X-Hub-Signature-256`. When unset, signatures are not verified. | + +`GITHUB_PROJECTS` accepts entries in either of these forms: + +- `owner/number` — e.g. `twentyhq/24,octo/3`. Owner can be an org or a user. +- Full project URL — e.g. `https://github.com/orgs/twentyhq/projects/24` or + `https://github.com/users/octocat/projects/3`. + +## Running a sync + +In the Twenty UI, open any of the GitHub objects (e.g. **Pull Requests**) and +trigger the matching command from the command palette (`Cmd/Ctrl+K`): + +| View | Command | Reads from | +| ----------------- | --------------------- | ----------------- | +| Pull Requests | Fetch Pull Requests | `GITHUB_REPOS` | +| Issues | Fetch Issues | `GITHUB_REPOS` | +| Contributors | Fetch Contributors | `GITHUB_REPOS` | +| Project Items | Fetch Project Items | `GITHUB_PROJECTS` | + +Each command iterates over every entry in the relevant variable and shows a +progress bar. + +## Webhooks + +Point a GitHub repo or App webhook at the public URL of your Twenty server, +path `POST /github/webhook`. Recommended event subscriptions: + +- Pull requests +- Pull request reviews +- Issues +- Project (v2) items + +Set the same value as `GITHUB_WEBHOOK_SECRET` on both sides to enable HMAC +verification. For local testing, expose your dev server with +[smee.io](https://smee.io/) or `ngrok` and use that URL as the webhook URL on +GitHub. + +> **Heads up — raw body required for signatures.** HMAC verification needs +> the original request body bytes. The Twenty SDK currently parses JSON +> requests before handing them to logic functions, so when the runtime +> delivers an already-parsed body the connector logs a warning and rejects +> the delivery instead of silently accepting it. Until the SDK exposes the +> raw bytes for HTTP routes, either leave `GITHUB_WEBHOOK_SECRET` unset (and +> rely on a hard-to-guess `/github/webhook` URL plus IP allow-listing), or +> terminate signature verification at a reverse proxy in front of Twenty. + +## How auth resolution works + +`src/modules/github/connector/auth.ts` returns a token using the following +order: + +1. `GITHUB_TOKEN` (fine-grained PAT, `github_pat_…`) if present. Classic + PATs are rejected at startup. +2. Cached installation token, if still valid. +3. Fresh installation token minted from the GitHub App credentials. + +This makes the example easy to try in 30 seconds with a PAT, while still +demonstrating the production-grade GitHub App flow. + +## Tests + +The app ships with a small integration test suite that runs against a local +`twenty-app-dev-test` container. + +```bash +docker run -d --name twenty-app-dev-test \ + -p 2021:2021 twentycrm/twenty-app-dev:v2.0.3 + +cd packages/twenty-apps/community/github-connector +yarn test +``` + +The suite installs the app into the container, then asserts that every +object/field/logic-function is wired up and that webhook signature +verification behaves correctly. diff --git a/packages/twenty-apps/community/github-connector/package.json b/packages/twenty-apps/community/github-connector/package.json new file mode 100644 index 0000000000..6f764da768 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/package.json @@ -0,0 +1,35 @@ +{ + "name": "github-connector", + "version": "0.1.0", + "license": "MIT", + "engines": { + "node": "^24.5.0", + "npm": "please-use-yarn", + "yarn": ">=4.0.2" + }, + "keywords": [ + "twenty-app" + ], + "packageManager": "yarn@4.9.2", + "scripts": { + "twenty": "twenty", + "lint": "oxlint -c .oxlintrc.json .", + "lint:fix": "oxlint --fix -c .oxlintrc.json .", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "twenty-client-sdk": "2.0.0", + "twenty-sdk": "2.0.0" + }, + "devDependencies": { + "@types/node": "^24.7.2", + "@types/react": "^19.0.0", + "oxlint": "^0.16.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "typescript": "^5.9.3", + "vite-tsconfig-paths": "^4.2.1", + "vitest": "^3.1.1" + } +} diff --git a/packages/twenty-apps/community/github-connector/public/screenshots/app-listing.png b/packages/twenty-apps/community/github-connector/public/screenshots/app-listing.png new file mode 100644 index 0000000000..1b3b88a238 Binary files /dev/null and b/packages/twenty-apps/community/github-connector/public/screenshots/app-listing.png differ diff --git a/packages/twenty-apps/community/github-connector/public/screenshots/contributor-stats.png b/packages/twenty-apps/community/github-connector/public/screenshots/contributor-stats.png new file mode 100644 index 0000000000..3b353e8bb4 Binary files /dev/null and b/packages/twenty-apps/community/github-connector/public/screenshots/contributor-stats.png differ diff --git a/packages/twenty-apps/community/github-connector/public/screenshots/github-dashboard.png b/packages/twenty-apps/community/github-connector/public/screenshots/github-dashboard.png new file mode 100644 index 0000000000..f33c31d23a Binary files /dev/null and b/packages/twenty-apps/community/github-connector/public/screenshots/github-dashboard.png differ diff --git a/packages/twenty-apps/community/github-connector/public/screenshots/pull-requests-view.png b/packages/twenty-apps/community/github-connector/public/screenshots/pull-requests-view.png new file mode 100644 index 0000000000..c8ad0ccb9c Binary files /dev/null and b/packages/twenty-apps/community/github-connector/public/screenshots/pull-requests-view.png differ diff --git a/packages/twenty-apps/community/github-connector/src/__tests__/global-setup.ts b/packages/twenty-apps/community/github-connector/src/__tests__/global-setup.ts new file mode 100644 index 0000000000..c3f48da953 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/__tests__/global-setup.ts @@ -0,0 +1,54 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { appDevOnce, appUninstall } from 'twenty-sdk/cli'; + +const APP_PATH = process.cwd(); +const CONFIG_DIR = path.join(os.homedir(), '.twenty'); + +function writeConfig(apiUrl: string, apiKey: string) { + const payload = JSON.stringify( + { + remotes: { + local: { apiUrl, apiKey, accessToken: apiKey }, + }, + defaultRemote: 'local', + }, + null, + 2, + ); + + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload); +} + +export async function setup() { + const apiUrl = process.env.TWENTY_API_URL!; + const apiKey = process.env.TWENTY_API_KEY!; + + writeConfig(apiUrl, apiKey); + + await appUninstall({ appPath: APP_PATH }).catch(() => {}); + + const result = await appDevOnce({ + appPath: APP_PATH, + onProgress: (message: string) => console.log(`[dev] ${message}`), + }); + + if (!result.success) { + throw new Error( + `Dev sync failed: ${result.error?.message ?? 'Unknown error'}`, + ); + } +} + +export async function teardown() { + const uninstallResult = await appUninstall({ appPath: APP_PATH }); + + if (!uninstallResult.success) { + console.warn( + `App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`, + ); + } +} diff --git a/packages/twenty-apps/community/github-connector/src/__tests__/helpers/metadata.ts b/packages/twenty-apps/community/github-connector/src/__tests__/helpers/metadata.ts new file mode 100644 index 0000000000..8d17ff9668 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/__tests__/helpers/metadata.ts @@ -0,0 +1,128 @@ +import { MetadataApiClient } from 'twenty-client-sdk/metadata'; + +const metadata = () => + new MetadataApiClient({ + headers: { + Authorization: `Bearer ${process.env.TWENTY_API_KEY}`, + }, + }); + +export async function findObjectByName(name: string) { + const client = metadata(); + const result = await client.query({ + objects: { + __args: { + filter: { isCustom: { is: true } }, + paging: { first: 50 }, + }, + edges: { + node: { + nameSingular: true, + fields: { + __args: { paging: { first: 500 } }, + edges: { node: { name: true, type: true } }, + }, + }, + }, + }, + }); + + return result.objects?.edges?.find((e) => e.node.nameSingular === name)?.node; +} + +type ExecutionResult = { + data: unknown; + status: string; + duration: number; + error: unknown; +}; + +export async function findLogicFunctionId( + universalIdentifier: string, +): Promise { + const client = metadata(); + const result = await client.query({ + findManyLogicFunctions: { + id: true, + universalIdentifier: true, + }, + }); + + const fn = result.findManyLogicFunctions?.find( + (f) => f.universalIdentifier === universalIdentifier, + ); + + if (!fn) { + throw new Error(`Logic function ${universalIdentifier} not found`); + } + + return fn.id; +} + +export async function executeLogicFunction( + id: string, + payload: Record, +): Promise { + const client = metadata(); + const result = await client.mutation({ + executeOneLogicFunction: { + __args: { input: { id, payload } }, + data: true, + status: true, + duration: true, + error: true, + }, + }); + + return result.executeOneLogicFunction as ExecutionResult; +} + +const BASE_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2021'; + +export async function callRoute( + path: string, + body: Record | string, + options: { + method?: string; + auth?: boolean; + headers?: Record; + } = {}, +): Promise<{ status: number; data: unknown }> { + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers ?? {}), + }; + + if (options.auth) { + headers['Authorization'] = `Bearer ${process.env.TWENTY_API_KEY}`; + } + + const res = await fetch(`${BASE_URL}/s${path}`, { + method: options.method ?? 'POST', + headers, + body: typeof body === 'string' ? body : JSON.stringify(body), + }); + + let data: unknown = null; + try { + data = await res.json(); + } catch { + data = await res.text().catch(() => null); + } + return { status: res.status, data }; +} + +export async function findInstalledApp(universalIdentifier: string) { + const client = metadata(); + const result = await client.query({ + findManyApplications: { + id: true, + name: true, + universalIdentifier: true, + }, + }); + + return result.findManyApplications?.find( + (app) => app.universalIdentifier === universalIdentifier, + ); +} diff --git a/packages/twenty-apps/community/github-connector/src/__tests__/logic-functions.integration-test.ts b/packages/twenty-apps/community/github-connector/src/__tests__/logic-functions.integration-test.ts new file mode 100644 index 0000000000..91f22016c0 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/__tests__/logic-functions.integration-test.ts @@ -0,0 +1,73 @@ +import { beforeAll, describe, expect, it } from 'vitest'; + +import { + executeLogicFunction, + findLogicFunctionId, +} from './helpers/metadata'; + +const COUNT_PRS_FN_UI = '082227ae-2acc-4320-8d31-62ad6c443da6'; +const COUNT_ISSUES_FN_UI = 'd8cc32bf-6be9-44fc-920a-8bba510f045f'; +const COUNT_PROJECT_ITEMS_FN_UI = 'f7a3e1b2-5c4d-4e6f-8a9b-0d1c2e3f4a5b'; +const COUNT_CONTRIBUTORS_FN_UI = 'fe0a6f00-0d63-4cb9-9b3c-1d8186181830'; +const HANDLE_WEBHOOK_FN_UI = '22b199b3-2851-4a4f-99fd-4e79c188fe7d'; + +const fnIds: Record = {}; + +beforeAll(async () => { + fnIds.prs = await findLogicFunctionId(COUNT_PRS_FN_UI); + fnIds.issues = await findLogicFunctionId(COUNT_ISSUES_FN_UI); + fnIds.projectItems = await findLogicFunctionId(COUNT_PROJECT_ITEMS_FN_UI); + fnIds.contributors = await findLogicFunctionId(COUNT_CONTRIBUTORS_FN_UI); + fnIds.webhook = await findLogicFunctionId(HANDLE_WEBHOOK_FN_UI); +}); + +describe('logic functions are wired up', () => { + it('count-prs is reachable and returns the expected payload shape', async () => { + const result = await executeLogicFunction(fnIds.prs, { + body: { repos: ['fixture-org/fixture-repo'] }, + }); + expect(['SUCCESS', 'ERROR']).toContain(result.status); + if (result.status === 'SUCCESS') { + const data = result.data as { + totalPages: number; + repos: Array<{ owner: string; repo: string; pages: number }>; + }; + expect(typeof data.totalPages).toBe('number'); + expect(Array.isArray(data.repos)).toBe(true); + } + }); + + it('count-issues is reachable', async () => { + const result = await executeLogicFunction(fnIds.issues, { + body: { repos: ['fixture-org/fixture-repo'] }, + }); + expect(['SUCCESS', 'ERROR']).toContain(result.status); + }); + + it('count-project-items is reachable', async () => { + const result = await executeLogicFunction(fnIds.projectItems, { + body: { projects: [{ owner: 'fixture-org', number: 9999999 }] }, + }); + expect(['SUCCESS', 'ERROR']).toContain(result.status); + }); + + it('count-contributors iterates configured repos and returns the per-repo split', async () => { + const result = await executeLogicFunction(fnIds.contributors, { + body: { repos: ['fixture-org/fixture-repo'] }, + }); + expect(['SUCCESS', 'ERROR']).toContain(result.status); + if (result.status === 'SUCCESS') { + const data = result.data as { + totalPages: number; + repos: Array<{ owner: string; repo: string; pages: number }>; + }; + expect(typeof data.totalPages).toBe('number'); + expect(Array.isArray(data.repos)).toBe(true); + expect('orgMembers' in (data as Record)).toBe(false); + } + }); + + it('handle-github-webhook is registered (but rejects unsigned requests gracefully)', async () => { + expect(fnIds.webhook).toBeDefined(); + }); +}); diff --git a/packages/twenty-apps/community/github-connector/src/__tests__/schema.integration-test.ts b/packages/twenty-apps/community/github-connector/src/__tests__/schema.integration-test.ts new file mode 100644 index 0000000000..47990cf855 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/__tests__/schema.integration-test.ts @@ -0,0 +1,124 @@ +import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/modules/shared/universal-identifiers'; +import { describe, expect, it } from 'vitest'; + +import { findInstalledApp, findObjectByName } from './helpers/metadata'; + +describe('App installation', () => { + it('finds the installed GitHub Connector app', async () => { + const app = await findInstalledApp(APPLICATION_UNIVERSAL_IDENTIFIER); + expect(app).toBeDefined(); + expect(app?.name).toMatch(/github/i); + }); +}); + +describe('Contributor object', () => { + it('exists with the expected GitHub-only fields', async () => { + const obj = await findObjectByName('contributor'); + expect(obj).toBeDefined(); + + const names = obj!.fields.edges.map((e) => e.node.name); + expect(names).toContain('name'); + expect(names).toContain('ghLogin'); + expect(names).toContain('githubId'); + expect(names).toContain('avatarUrl'); + expect(names).toContain('contributions'); + + expect(names).not.toContain('isCoreTeam'); + expect(names).not.toContain('discordId'); + }); +}); + +describe('PullRequest object', () => { + it('exists with the expected fields and relations', async () => { + const obj = await findObjectByName('pullRequest'); + expect(obj).toBeDefined(); + + const names = obj!.fields.edges.map((e) => e.node.name); + expect(names).toContain('name'); + expect(names).toContain('githubNumber'); + expect(names).toContain('uniqueIdentifier'); + expect(names).toContain('url'); + expect(names).toContain('state'); + expect(names).toContain('mergedAt'); + expect(names).toContain('closedAt'); + expect(names).toContain('githubCreatedAt'); + expect(names).toContain('author'); + expect(names).toContain('merger'); + expect(names).toContain('reviews'); + expect(names).toContain('projectItems'); + }); +}); + +describe('PullRequestReviewEvent object', () => { + it('exists with the expected fields and relations', async () => { + const obj = await findObjectByName('pullRequestReviewEvent'); + expect(obj).toBeDefined(); + + const names = obj!.fields.edges.map((e) => e.node.name); + expect(names).toContain('title'); + expect(names).toContain('githubReviewId'); + expect(names).toContain('state'); + expect(names).toContain('submittedAt'); + expect(names).toContain('reviewer'); + expect(names).toContain('pullRequest'); + expect(names).toContain('review'); + }); +}); + +describe('PullRequestReview object', () => { + it('exists with the expected fields and relations', async () => { + const obj = await findObjectByName('pullRequestReview'); + expect(obj).toBeDefined(); + + const names = obj!.fields.edges.map((e) => e.node.name); + expect(names).toContain('title'); + expect(names).toContain('reviewKey'); + expect(names).toContain('state'); + expect(names).toContain('firstSubmittedAt'); + expect(names).toContain('lastSubmittedAt'); + expect(names).toContain('eventCount'); + expect(names).toContain('reviewer'); + expect(names).toContain('pullRequest'); + expect(names).toContain('reviewEvents'); + }); +}); + +describe('Issue object', () => { + it('exists with the expected fields and relations', async () => { + const obj = await findObjectByName('issue'); + expect(obj).toBeDefined(); + + const names = obj!.fields.edges.map((e) => e.node.name); + expect(names).toContain('title'); + expect(names).toContain('githubNumber'); + expect(names).toContain('uniqueIdentifier'); + expect(names).toContain('githubUrl'); + expect(names).toContain('state'); + expect(names).toContain('labels'); + expect(names).toContain('githubCreatedAt'); + expect(names).toContain('closedAt'); + expect(names).toContain('repo'); + expect(names).toContain('author'); + expect(names).toContain('projectItems'); + }); +}); + +describe('ProjectItem object', () => { + it('exists with the expected fields and relations', async () => { + const obj = await findObjectByName('projectItem'); + expect(obj).toBeDefined(); + + const names = obj!.fields.edges.map((e) => e.node.name); + expect(names).toContain('name'); + expect(names).toContain('githubProjectItemId'); + expect(names).toContain('status'); + expect(names).toContain('sprint'); + expect(names).toContain('assignees'); + expect(names).toContain('priority'); + expect(names).toContain('mainAssignee'); + expect(names).toContain('linkedIssue'); + expect(names).toContain('linkedPullRequest'); + expect(names).toContain('githubUrl'); + expect(names).toContain('repo'); + }); +}); diff --git a/packages/twenty-apps/community/github-connector/src/__tests__/webhook-signature.integration-test.ts b/packages/twenty-apps/community/github-connector/src/__tests__/webhook-signature.integration-test.ts new file mode 100644 index 0000000000..a12d17c2ea --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/__tests__/webhook-signature.integration-test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { createHmac } from 'crypto'; + +import { + getRawBodyForSignature, + verifyGitHubSignature, +} from 'src/modules/github/connector/webhook-signature'; + +const SECRET = 'super-secret-shared-string'; + +function sign(body: string): string { + return `sha256=${createHmac('sha256', SECRET).update(body).digest('hex')}`; +} + +describe('verifyGitHubSignature', () => { + it('accepts a valid signature', () => { + const body = '{"action":"opened","number":42}'; + const result = verifyGitHubSignature({ + rawBody: body, + signatureHeader: sign(body), + secret: SECRET, + }); + expect(result.ok).toBe(true); + }); + + it('rejects a tampered body', () => { + const body = '{"action":"opened","number":42}'; + const signature = sign(body); + const result = verifyGitHubSignature({ + rawBody: body.replace('42', '43'), + signatureHeader: signature, + secret: SECRET, + }); + expect(result.ok).toBe(false); + }); + + it('rejects a wrong secret', () => { + const body = '{"action":"opened","number":42}'; + const result = verifyGitHubSignature({ + rawBody: body, + signatureHeader: sign(body), + secret: 'other-secret', + }); + expect(result.ok).toBe(false); + }); + + it('rejects a missing header', () => { + const result = verifyGitHubSignature({ + rawBody: 'anything', + signatureHeader: undefined, + secret: SECRET, + }); + expect(result).toMatchObject({ + ok: false, + reason: 'missing X-Hub-Signature-256 header', + }); + }); + + it('rejects a header without sha256= prefix', () => { + const result = verifyGitHubSignature({ + rawBody: 'anything', + signatureHeader: 'sha1=deadbeef', + secret: SECRET, + }); + expect(result).toMatchObject({ + ok: false, + reason: 'malformed signature header', + }); + }); + + it('rejects when length differs', () => { + const result = verifyGitHubSignature({ + rawBody: 'anything', + signatureHeader: 'sha256=tooshort', + secret: SECRET, + }); + expect(result).toMatchObject({ + ok: false, + reason: 'signature length mismatch', + }); + }); +}); + +describe('getRawBodyForSignature', () => { + it('returns the string as-is for string body', () => { + expect( + getRawBodyForSignature({ body: '{"a":1}', isBase64Encoded: false }), + ).toBe('{"a":1}'); + }); + + it('decodes base64 bodies', () => { + const original = '{"a":1}'; + const b64 = Buffer.from(original, 'utf8').toString('base64'); + expect(getRawBodyForSignature({ body: b64, isBase64Encoded: true })).toBe( + original, + ); + }); + + it('returns null for parsed object bodies (raw bytes lost)', () => { + expect(getRawBodyForSignature({ body: { a: 1 } })).toBeNull(); + }); + + it('returns empty string for null/undefined', () => { + expect(getRawBodyForSignature({ body: null })).toBe(''); + }); +}); + +describe('verifyGitHubSignature with parsed body', () => { + it('rejects with a clear reason when the runtime parsed the JSON', () => { + const result = verifyGitHubSignature({ + rawBody: null, + signatureHeader: 'sha256=deadbeef', + secret: SECRET, + }); + expect(result).toMatchObject({ + ok: false, + reason: + 'raw request body is unavailable (the runtime parsed it as JSON); HMAC cannot be verified', + }); + }); +}); diff --git a/packages/twenty-apps/community/github-connector/src/application-config.ts b/packages/twenty-apps/community/github-connector/src/application-config.ts new file mode 100644 index 0000000000..673c442388 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/application-config.ts @@ -0,0 +1,66 @@ +import { defineApplication } from 'twenty-sdk/define'; + +import { + APP_ABOUT_DESCRIPTION, + APP_DESCRIPTION, + APP_DISPLAY_NAME, + APPLICATION_UNIVERSAL_IDENTIFIER, + DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, +} from 'src/modules/shared/universal-identifiers'; + +export default defineApplication({ + universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + displayName: APP_DISPLAY_NAME, + description: APP_DESCRIPTION, + aboutDescription: APP_ABOUT_DESCRIPTION, + icon: 'IconBrandGithub', + defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, + screenshots: [ + 'public/screenshots/app-listing.png', + 'public/screenshots/github-dashboard.png', + 'public/screenshots/pull-requests-view.png', + 'public/screenshots/contributor-stats.png', + ], + applicationVariables: { + GITHUB_TOKEN: { + universalIdentifier: 'fb1d2e91-3a75-4c89-9d6b-1e2f7a4c5d8e', + description: + 'Fine-grained Personal Access Token (github_pat_…) from https://github.com/settings/personal-access-tokens — needs Read-only access to Contents, Issues, Pull requests and Metadata (and Organization → Projects for Projects v2). Classic PATs are not supported. When set, takes precedence over the GitHub App credentials below.', + isSecret: true, + }, + GITHUB_APP_ID: { + universalIdentifier: '2a196d91-4c1b-4f8d-bc34-6693fcdaa771', + description: + 'GitHub App ID. Used together with GITHUB_APP_PRIVATE_KEY and GITHUB_APP_INSTALLATION_ID when GITHUB_TOKEN is unset.', + isSecret: false, + }, + GITHUB_APP_PRIVATE_KEY: { + universalIdentifier: 'c5591dd4-f653-4b92-bec9-970ddc8e10cc', + description: 'GitHub App PEM private key (BEGIN/END PRIVATE KEY block).', + isSecret: true, + }, + GITHUB_APP_INSTALLATION_ID: { + universalIdentifier: '0c39a59a-ee2e-49a9-88c7-3fbe6cb04ad0', + description: 'GitHub App installation ID for your org.', + isSecret: false, + }, + GITHUB_WEBHOOK_SECRET: { + universalIdentifier: 'b9f3c2d8-1e7a-4d56-9c8b-3a2f1e5d7c9a', + description: + 'Shared secret used to verify the X-Hub-Signature-256 HMAC on incoming GitHub webhooks. When unset, signature verification is skipped (use only in dev/test).', + isSecret: true, + }, + GITHUB_REPOS: { + universalIdentifier: '7d1e9c84-2f63-4a58-9b0d-5e8a3c1f7b29', + description: + 'Comma-separated list of `owner/repo` to sync (e.g. `twentyhq/twenty,octo/hello`). Used by the manual fetch routes.', + isSecret: false, + }, + GITHUB_PROJECTS: { + universalIdentifier: 'e3a8c7d2-4b95-4e1f-8a6c-9d2b5f7e1c84', + description: + 'Comma-separated list of GitHub Projects (v2) to sync. Each entry is `owner/number` (e.g. `twentyhq/24,octo/3`). Owner can be an organization or a user. Full URLs like `https://github.com/orgs/twentyhq/projects/24` or `https://github.com/users/octo/projects/3` are also accepted.', + isSecret: false, + }, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/default-role.ts b/packages/twenty-apps/community/github-connector/src/default-role.ts new file mode 100644 index 0000000000..8d3e930cc1 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/default-role.ts @@ -0,0 +1,16 @@ +import { defineRole } from 'twenty-sdk/define'; + +import { + APP_DISPLAY_NAME, + DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, +} from 'src/modules/shared/universal-identifiers'; + +export default defineRole({ + universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, + label: `${APP_DISPLAY_NAME} default function role`, + description: `${APP_DISPLAY_NAME} default function role`, + canReadAllObjectRecords: true, + canUpdateAllObjectRecords: true, + canSoftDeleteAllObjectRecords: true, + canDestroyAllObjectRecords: false, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/connector/auth.ts b/packages/twenty-apps/community/github-connector/src/modules/github/connector/auth.ts new file mode 100644 index 0000000000..5cb01a98ba --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/connector/auth.ts @@ -0,0 +1,122 @@ +import { createSign } from 'crypto'; + +export function normalizePem(raw: string): string { + let pem = raw.replace(/\\n/g, '\n').trim(); + + if (!pem.includes('\n')) { + const beginMatch = pem.match(/^(-----BEGIN [A-Z ]+-----)/); + const endMatch = pem.match(/(-----END [A-Z ]+-----)$/); + + if (beginMatch && endMatch) { + const header = beginMatch[1]; + const footer = endMatch[1]; + const body = pem.slice(header.length, pem.length - footer.length); + const lines = body.match(/.{1,64}/g) ?? []; + pem = [header, ...lines, footer].join('\n'); + } + } + + return pem; +} + +function base64url(input: Buffer | string): string { + const buf = typeof input === 'string' ? Buffer.from(input) : input; + return buf.toString('base64url'); +} + +function signJwt(appId: string, privateKey: string): string { + const now = Math.floor(Date.now() / 1000); + const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' })); + const payload = base64url( + JSON.stringify({ iat: now - 60, exp: now + 600, iss: appId }), + ); + + const signature = createSign('RSA-SHA256') + .update(`${header}.${payload}`) + .sign(privateKey, 'base64url'); + + return `${header}.${payload}.${signature}`; +} + +type CachedToken = { token: string; expiresAt: number }; +let cached: CachedToken | null = null; + +const TOKEN_MARGIN_MS = 5 * 60 * 1000; + +async function requestInstallationToken( + appId: string, + privateKey: string, + installationId: string, +): Promise { + const jwt = signJwt(appId, privateKey); + + const res = await fetch( + `https://api.github.com/app/installations/${installationId}/access_tokens`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }, + ); + + if (!res.ok) { + const body = await res.text(); + throw new Error( + `GitHub App token exchange failed (${res.status}): ${body}`, + ); + } + + const data = (await res.json()) as { + token: string; + expires_at: string; + }; + + return { + token: data.token, + expiresAt: new Date(data.expires_at).getTime(), + }; +} + +function assertFineGrainedPat(token: string): void { + if (token.startsWith('github_pat_')) return; + throw new Error( + 'GITHUB_TOKEN must be a fine-grained Personal Access Token (starts with `github_pat_`). Classic PATs are not supported — create one at https://github.com/settings/personal-access-tokens.', + ); +} + +export async function getGitHubToken(): Promise { + const pat = process.env.GITHUB_TOKEN?.trim(); + if (pat) { + assertFineGrainedPat(pat); + return pat; + } + + if (cached && Date.now() < cached.expiresAt - TOKEN_MARGIN_MS) { + return cached.token; + } + + const appId = process.env.GITHUB_APP_ID; + const rawKey = process.env.GITHUB_APP_PRIVATE_KEY; + const installationId = process.env.GITHUB_APP_INSTALLATION_ID; + + if (!appId || !rawKey || !installationId) { + const missing = [ + !appId && 'GITHUB_APP_ID', + !rawKey && 'GITHUB_APP_PRIVATE_KEY', + !installationId && 'GITHUB_APP_INSTALLATION_ID', + ] + .filter(Boolean) + .join(', '); + throw new Error( + `Missing GitHub credentials. Set GITHUB_TOKEN (fine-grained PAT) or all of: ${missing}.`, + ); + } + + const privateKey = normalizePem(rawKey); + cached = await requestInstallationToken(appId, privateKey, installationId); + + return cached.token; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/connector/config.ts b/packages/twenty-apps/community/github-connector/src/modules/github/connector/config.ts new file mode 100644 index 0000000000..c74eb46d31 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/connector/config.ts @@ -0,0 +1,55 @@ +export type GithubRepo = { owner: string; repo: string }; + +export function parseGithubRepo(entry: string): GithubRepo | null { + const trimmed = entry.trim(); + if (!trimmed) return null; + const match = trimmed.match(/^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/); + if (!match) return null; + return { owner: match[1], repo: match[2] }; +} + +export function getGithubRepos(): string[] { + const raw = process.env.GITHUB_REPOS ?? ''; + return raw + .split(',') + .map(parseGithubRepo) + .filter((r): r is GithubRepo => r !== null) + .map((r) => `${r.owner}/${r.repo}`); +} + +export type GithubProject = { owner: string; number: number }; + +export function parseGithubProject(entry: string): GithubProject | null { + const trimmed = entry.trim(); + if (!trimmed) return null; + + const urlMatch = trimmed.match( + /github\.com\/(?:orgs|users)\/([^/]+)\/projects\/(\d+)/i, + ); + if (urlMatch) { + const owner = urlMatch[1]; + const number = Number.parseInt(urlMatch[2], 10); + if (owner && Number.isFinite(number) && number > 0) { + return { owner, number }; + } + } + + const shortMatch = trimmed.match(/^([^/\s]+)\/(\d+)$/); + if (shortMatch) { + const owner = shortMatch[1]; + const number = Number.parseInt(shortMatch[2], 10); + if (owner && Number.isFinite(number) && number > 0) { + return { owner, number }; + } + } + + return null; +} + +export function getGithubProjects(): GithubProject[] { + const raw = process.env.GITHUB_PROJECTS ?? ''; + return raw + .split(',') + .map(parseGithubProject) + .filter((p): p is GithubProject => p !== null); +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/connector/count-across-repos.ts b/packages/twenty-apps/community/github-connector/src/modules/github/connector/count-across-repos.ts new file mode 100644 index 0000000000..414218a25e --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/connector/count-across-repos.ts @@ -0,0 +1,45 @@ +import { + getGithubRepos, + parseGithubRepo, +} from 'src/modules/github/connector/config'; + +export type RepoCount = { + owner: string; + repo: string; + totalCount: number; + pages: number; +}; + +export type CountAcrossReposResult = { + totalPages: number; + repos: RepoCount[]; +}; + +const PAGE_SIZE = 100; + +export async function countAcrossRepos( + bodyRepos: string[] | undefined, + count: (owner: string, repo: string) => Promise, + logTag: string, +): Promise { + const repos = + bodyRepos && bodyRepos.length > 0 ? bodyRepos : getGithubRepos(); + + const results: RepoCount[] = []; + let totalPages = 0; + + for (const fullRepo of repos) { + const parsed = parseGithubRepo(fullRepo); + if (!parsed) { + console.warn(`[${logTag}] Skipping malformed repo entry: ${fullRepo}`); + continue; + } + const { owner, repo } = parsed; + const totalCount = await count(owner, repo); + const pages = Math.max(Math.ceil(totalCount / PAGE_SIZE), 1); + results.push({ owner, repo, totalCount, pages }); + totalPages += pages; + } + + return { totalPages, repos: results }; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/connector/github-client.ts b/packages/twenty-apps/community/github-connector/src/modules/github/connector/github-client.ts new file mode 100644 index 0000000000..46051e6d71 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/connector/github-client.ts @@ -0,0 +1,157 @@ +import { getGitHubToken } from 'src/modules/github/connector/auth'; + +const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql'; +const LOW_REMAINING_THRESHOLD = 200; + +export class GitHubRateLimitError extends Error { + constructor( + message: string, + public readonly kind: 'primary' | 'secondary', + public readonly remaining: number | null, + public readonly limit: number | null, + public readonly resetAt: Date | null, + public readonly retryAfterSeconds: number | null, + ) { + super(message); + this.name = 'GitHubRateLimitError'; + } +} + +function readRateLimitHeaders(res: Response) { + const num = (h: string) => { + const v = res.headers.get(h); + return v === null ? null : Number(v); + }; + const reset = num('x-ratelimit-reset'); + return { + limit: num('x-ratelimit-limit'), + remaining: num('x-ratelimit-remaining'), + used: num('x-ratelimit-used'), + resource: res.headers.get('x-ratelimit-resource'), + resetAt: + reset === null || Number.isNaN(reset) ? null : new Date(reset * 1000), + retryAfter: num('retry-after'), + }; +} + +export async function githubGraphql( + query: string, + variables: Record, +): Promise { + const queryName = query.match(/query\s*\(/) + ? query.slice(0, 60).replace(/\s+/g, ' ').trim() + : 'mutation'; + console.log( + `[github-gql] Executing: ${queryName} vars=${JSON.stringify(Object.keys(variables))}`, + ); + const token = await getGitHubToken(); + const res = await fetch(GITHUB_GRAPHQL_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query, variables }), + }); + + const rl = readRateLimitHeaders(res); + if (rl.remaining !== null && rl.limit !== null) { + const resetIn = rl.resetAt + ? Math.max(0, Math.round((rl.resetAt.getTime() - Date.now()) / 1000)) + : null; + const isLow = + rl.remaining <= LOW_REMAINING_THRESHOLD || + rl.remaining / rl.limit < 0.1; + const tag = isLow + ? '[github-gql][rate-limit-low]' + : '[github-gql][rate-limit]'; + console.log( + `${tag} resource=${rl.resource ?? 'graphql'} remaining=${rl.remaining}/${rl.limit} used=${rl.used ?? '?'} resetIn=${resetIn ?? '?'}s`, + ); + } + + if (!res.ok) { + const body = await res.text(); + const lower = body.toLowerCase(); + const isSecondary = + res.status === 403 && + (lower.includes('secondary rate limit') || + lower.includes('abuse detection')); + const isPrimary = + (res.status === 403 || res.status === 429) && + lower.includes('rate limit') && + !isSecondary; + + if (isPrimary || isSecondary) { + throw new GitHubRateLimitError( + `GitHub ${isSecondary ? 'secondary' : 'primary'} rate limit hit (${res.status}): ${body.slice(0, 200)}`, + isSecondary ? 'secondary' : 'primary', + rl.remaining, + rl.limit, + rl.resetAt, + rl.retryAfter, + ); + } + + throw new Error( + `GitHub GraphQL ${res.status} ${res.statusText}: ${body.slice(0, 500)}`, + ); + } + + const json = (await res.json()) as { + data: T; + errors?: Array<{ type?: string; message: string }>; + }; + + if (json.errors?.length) { + const isRateLimited = json.errors.some( + (e) => e.type === 'RATE_LIMITED' || /rate limit/i.test(e.message), + ); + if (isRateLimited) { + throw new GitHubRateLimitError( + `GitHub GraphQL RATE_LIMITED: ${json.errors.map((e) => e.message).join(', ')}`, + 'primary', + rl.remaining, + rl.limit, + rl.resetAt, + rl.retryAfter, + ); + } + throw new Error( + `GraphQL errors: ${json.errors.map((e) => e.message).join(', ')}`, + ); + } + + return json.data; +} + +export async function githubGraphqlOptional( + query: string, + variables: Record, +): Promise { + try { + return await githubGraphql(query, variables); + } catch (err) { + const msg = err instanceof Error ? err.message : ''; + if (/Could not resolve to a (User|Organization)/i.test(msg)) return null; + throw err; + } +} + +export type GithubPage = { + totalCount: number; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + nodes: T[]; +}; + +export type Paged> = TItems & { + totalCount: number; + hasMore: boolean; + endCursor: string | null; +}; + +export const EMPTY_PAGE = { + totalCount: 0, + hasMore: false, + endCursor: null, +} as const; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/connector/github-user.ts b/packages/twenty-apps/community/github-connector/src/modules/github/connector/github-user.ts new file mode 100644 index 0000000000..297596ed90 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/connector/github-user.ts @@ -0,0 +1,5 @@ +export type GitHubUser = { + login: string; + id: number; + avatar_url?: string | null; +}; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/connector/webhook-payload.ts b/packages/twenty-apps/community/github-connector/src/modules/github/connector/webhook-payload.ts new file mode 100644 index 0000000000..c71c26c5b9 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/connector/webhook-payload.ts @@ -0,0 +1,15 @@ +import type { GitHubUser } from 'src/modules/github/connector/github-user'; +import type { GitHubPullRequest } from 'src/modules/github/pull-request/types/github-pull-request'; +import type { GitHubReview } from 'src/modules/github/pull-request-review-event/types/github-review'; +import type { GitHubIssue } from 'src/modules/github/issue/types/github-issue'; +import type { GitHubProjectV2Item } from 'src/modules/github/project-item/types/github-project-v2-item'; + +export type GitHubWebhookPayload = { + action: string; + pull_request?: GitHubPullRequest; + review?: GitHubReview; + issue?: GitHubIssue; + projects_v2_item?: GitHubProjectV2Item; + sender: GitHubUser; + repository?: { full_name: string }; +}; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/connector/webhook-signature.ts b/packages/twenty-apps/community/github-connector/src/modules/github/connector/webhook-signature.ts new file mode 100644 index 0000000000..ef9c76e2f9 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/connector/webhook-signature.ts @@ -0,0 +1,54 @@ +import { createHmac, timingSafeEqual } from 'crypto'; + +export type SignatureVerificationResult = + | { ok: true } + | { ok: false; reason: string }; + +export function getRawBodyForSignature(event: { + body: unknown; + isBase64Encoded?: boolean; +}): string | null { + const raw = event.body; + if (raw == null) return ''; + if (typeof raw === 'string') { + return event.isBase64Encoded + ? Buffer.from(raw, 'base64').toString('utf8') + : raw; + } + return null; +} + +export function verifyGitHubSignature({ + rawBody, + signatureHeader, + secret, +}: { + rawBody: string | null; + signatureHeader: string | undefined; + secret: string; +}): SignatureVerificationResult { + if (rawBody === null) { + return { + ok: false, + reason: + 'raw request body is unavailable (the runtime parsed it as JSON); HMAC cannot be verified', + }; + } + if (!signatureHeader) { + return { ok: false, reason: 'missing X-Hub-Signature-256 header' }; + } + const prefix = 'sha256='; + if (!signatureHeader.startsWith(prefix)) { + return { ok: false, reason: 'malformed signature header' }; + } + const provided = signatureHeader.slice(prefix.length); + const expected = createHmac('sha256', secret).update(rawBody).digest('hex'); + if (provided.length !== expected.length) { + return { ok: false, reason: 'signature length mismatch' }; + } + const ok = timingSafeEqual( + Buffer.from(provided, 'utf8'), + Buffer.from(expected, 'utf8'), + ); + return ok ? { ok: true } : { ok: false, reason: 'signature mismatch' }; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/components/leaderboard.tsx b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/components/leaderboard.tsx new file mode 100644 index 0000000000..688f7981e9 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/components/leaderboard.tsx @@ -0,0 +1,282 @@ +import { type ReactNode } from 'react'; + +import { THEME } from 'src/modules/github/contributor/components/theme'; + +export type LeaderboardEntry = { + id: string; + name: string | null; + ghLogin: string | null; + avatarUrl: string | null; + count: number; +}; + +const ROW_HEIGHT = 32; +const CELL_PADDING_X = 8; +const RANK_COL_WIDTH = 36; +const COUNT_COL_WIDTH = 72; + +const styles = { + root: { + display: 'flex', + flexDirection: 'column', + width: '100%', + height: '100%', + minHeight: 0, + fontFamily: THEME.fontFamily, + color: THEME.fontPrimary, + background: THEME.bgPrimary, + boxSizing: 'border-box', + overflow: 'hidden', + } as const, + table: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + overflowY: 'auto', + overflowX: 'hidden', + width: '100%', + } as const, + headerRow: { + display: 'flex', + alignItems: 'stretch', + height: ROW_HEIGHT, + flexShrink: 0, + borderBottom: `1px solid ${THEME.borderLight}`, + background: THEME.bgPrimary, + } as const, + headerCell: { + display: 'flex', + alignItems: 'center', + height: '100%', + padding: `0 ${CELL_PADDING_X}px`, + borderRight: `1px solid ${THEME.borderLight}`, + color: THEME.fontTertiary, + fontSize: 12, + fontWeight: 500, + boxSizing: 'border-box', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + userSelect: 'none', + } as const, + headerCellLast: { + borderRight: 'none', + } as const, + row: { + display: 'flex', + alignItems: 'stretch', + height: ROW_HEIGHT, + flexShrink: 0, + width: '100%', + background: THEME.bgPrimary, + color: THEME.fontPrimary, + textDecoration: 'none', + boxSizing: 'border-box', + } as const, + cell: { + display: 'flex', + alignItems: 'center', + height: '100%', + padding: `0 ${CELL_PADDING_X}px`, + borderBottom: `1px solid ${THEME.borderLight}`, + borderRight: `1px solid ${THEME.borderLight}`, + fontSize: 13, + boxSizing: 'border-box', + minWidth: 0, + overflow: 'hidden', + transition: 'background-color 80ms ease', + } as const, + cellLast: { + borderRight: 'none', + } as const, + rankCell: { + width: RANK_COL_WIDTH, + flexShrink: 0, + justifyContent: 'flex-end', + color: THEME.fontTertiary, + fontSize: 12, + fontVariantNumeric: 'tabular-nums', + } as const, + contributorCell: { + flex: 1, + minWidth: 0, + gap: 6, + } as const, + countCell: { + width: COUNT_COL_WIDTH, + flexShrink: 0, + justifyContent: 'flex-end', + color: THEME.fontSecondary, + fontVariantNumeric: 'tabular-nums', + fontWeight: 500, + } as const, + avatar: { + width: 16, + height: 16, + borderRadius: '50%', + objectFit: 'cover', + background: THEME.bgTertiary, + flexShrink: 0, + } as const, + nameWrapper: { + display: 'flex', + alignItems: 'baseline', + gap: 6, + minWidth: 0, + flex: 1, + lineHeight: 1.2, + } as const, + name: { + fontSize: 13, + fontWeight: 500, + color: THEME.fontPrimary, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + } as const, + login: { + fontSize: 12, + fontWeight: 400, + color: THEME.fontTertiary, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + } as const, + empty: { + flex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: THEME.fontTertiary, + fontSize: 13, + padding: 16, + textAlign: 'center', + } as const, +}; + +type LeaderboardProps = { + contributorColumnLabel: string; + countColumnLabel: string; + entries: LeaderboardEntry[]; + loading: boolean; + error: string | null; + emptyMessage?: string; +}; + +const renderState = (content: ReactNode) => ( +
{content}
+); + +const Row = ({ + rank, + entry, + countLabel, +}: { + rank: number; + entry: LeaderboardEntry; + countLabel: string; +}) => { + const label = entry.name ?? entry.ghLogin ?? 'Unknown'; + const profileHref = entry.ghLogin + ? `https://github.com/${entry.ghLogin}` + : undefined; + + const onEnter = (e: React.MouseEvent) => { + e.currentTarget.style.backgroundColor = THEME.bgSecondary; + }; + const onLeave = (e: React.MouseEvent) => { + e.currentTarget.style.backgroundColor = THEME.bgPrimary; + }; + + const cells = ( + <> +
{rank}
+
+ {entry.avatarUrl ? ( + + ) : ( +
+ )} +
+ {label} + {entry.ghLogin && entry.ghLogin !== label && ( + @{entry.ghLogin} + )} +
+
+
+ {entry.count} +
+ + ); + + if (profileHref) { + return ( + + {cells} + + ); + } + + return ( +
+ {cells} +
+ ); +}; + +export const Leaderboard = ({ + contributorColumnLabel, + countColumnLabel, + entries, + loading, + error, + emptyMessage = 'No activity in the last 90 days', +}: LeaderboardProps) => ( +
+
+
#
+
+ {contributorColumnLabel} +
+
+ {countColumnLabel} +
+
+ {error ? ( + renderState(error) + ) : loading && entries.length === 0 ? ( + renderState('Loading...') + ) : entries.length === 0 ? ( + renderState(emptyMessage) + ) : ( +
+ {entries.map((entry, i) => ( + + ))} +
+ )} +
+); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/components/theme.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/components/theme.ts new file mode 100644 index 0000000000..d55b5f74f4 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/components/theme.ts @@ -0,0 +1,23 @@ +export const THEME = { + fontFamily: + 'var(--t-font-family, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)', + borderRadius: 'var(--t-border-radius-sm, 4px)', + + fontPrimary: 'var(--t-font-color-primary)', + fontSecondary: 'var(--t-font-color-secondary)', + fontTertiary: 'var(--t-font-color-tertiary)', + fontLight: 'var(--t-font-color-light)', + + borderLight: 'var(--t-border-color-light)', + borderMedium: 'var(--t-border-color-medium)', + + bgPrimary: 'var(--t-background-primary)', + bgSecondary: 'var(--t-background-secondary)', + bgTertiary: 'var(--t-background-tertiary)', + bgTransparentLight: 'var(--t-background-transparent-light)', + bgTransparentLighter: 'var(--t-background-transparent-lighter)', + + chartAuthored: 'var(--t-color-purple)', + chartMerged: 'var(--t-color-green)', + chartReviewed: 'var(--t-color-blue)', +} as const; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/assigned-project-items-on-contributor.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/assigned-project-items-on-contributor.field.ts new file mode 100644 index 0000000000..0199c89905 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/assigned-project-items-on-contributor.field.ts @@ -0,0 +1,24 @@ +import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; +import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object'; +import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object'; +import { MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/main-assignee-on-project-item.field'; + +export const ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER = + 'b35aed82-2c01-4f37-b3f9-cf67dc269d2c'; + +export default defineField({ + universalIdentifier: + ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'assignedProjectItems', + label: 'Assigned Items', + icon: 'IconLayoutKanban', + relationTargetObjectMetadataUniversalIdentifier: + PROJECT_ITEM_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/authored-issues-on-contributor.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/authored-issues-on-contributor.field.ts new file mode 100644 index 0000000000..5d6b2c1375 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/authored-issues-on-contributor.field.ts @@ -0,0 +1,22 @@ +import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; +import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object'; +import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object'; +import { ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/fields/author-on-issue.field'; + +export const AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER = + '31f185c0-d2a2-47e2-9132-8cbac05f174a'; + +export default defineField({ + universalIdentifier: AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'authoredIssues', + label: 'Authored Issues', + icon: 'IconBug', + relationTargetObjectMetadataUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/authored-prs-on-contributor.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/authored-prs-on-contributor.field.ts new file mode 100644 index 0000000000..4f72b653b9 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/authored-prs-on-contributor.field.ts @@ -0,0 +1,23 @@ +import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; +import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object'; +import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object'; +import { AUTHOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/author-on-pull-request.field'; + +export const AUTHORED_PRS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER = + 'c3d4e5f6-2b3c-4d4e-9f6a-7b8c9d0e1f2a'; + +export default defineField({ + universalIdentifier: AUTHORED_PRS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'authoredPrs', + label: 'Authored PRs', + icon: 'IconGitPullRequest', + relationTargetObjectMetadataUniversalIdentifier: + PULL_REQUEST_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + AUTHOR_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/merged-pull-requests-on-contributor.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/merged-pull-requests-on-contributor.field.ts new file mode 100644 index 0000000000..9dde443d05 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/merged-pull-requests-on-contributor.field.ts @@ -0,0 +1,24 @@ +import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; +import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object'; +import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object'; +import { MERGER_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/merger-on-pull-request.field'; + +export const MERGED_PULL_REQUESTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER = + '02ec0165-fb5a-46f9-b7c7-a0fd605befa2'; + +export default defineField({ + universalIdentifier: + MERGED_PULL_REQUESTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'mergedPullRequests', + label: 'Merged Pull Requests', + icon: 'IconGitMerge', + relationTargetObjectMetadataUniversalIdentifier: + PULL_REQUEST_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + MERGER_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/review-events-on-contributor.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/review-events-on-contributor.field.ts new file mode 100644 index 0000000000..2081d4d0d2 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/review-events-on-contributor.field.ts @@ -0,0 +1,23 @@ +import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; +import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object'; +import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object'; +import { REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/fields/reviewer-on-review-event.field'; + +export const REVIEW_EVENTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER = + 'e4f5a6b7-8c9d-4e0f-a1b2-c3d4e5f6a7b8'; + +export default defineField({ + universalIdentifier: REVIEW_EVENTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'reviewEvents', + label: 'Review Events', + icon: 'IconEye', + relationTargetObjectMetadataUniversalIdentifier: + PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/reviews-on-contributor.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/reviews-on-contributor.field.ts new file mode 100644 index 0000000000..3d99635a41 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/reviews-on-contributor.field.ts @@ -0,0 +1,23 @@ +import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; +import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object'; +import { PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/objects/pull-request-review.object'; +import { REVIEWER_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/fields/reviewer-on-review.field'; + +export const REVIEWS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER = + '564e7f97-74dc-4e9b-a4bc-ed4215ab9ac7'; + +export default defineField({ + universalIdentifier: REVIEWS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'reviews', + label: 'Reviews', + icon: 'IconEye', + relationTargetObjectMetadataUniversalIdentifier: + PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + REVIEWER_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/front-components/contributor-stats.front-component.tsx b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/front-components/contributor-stats.front-component.tsx new file mode 100644 index 0000000000..91cf928460 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/front-components/contributor-stats.front-component.tsx @@ -0,0 +1,693 @@ +import { useEffect, useMemo, useState } from 'react'; +import { defineFrontComponent } from 'twenty-sdk/define'; +import { + enqueueSnackbar, + objectMetadataItem, + useRecordId, +} from 'twenty-sdk/front-component'; + +import { THEME } from 'src/modules/github/contributor/components/theme'; +import { callAppRoute } from 'src/modules/shared/call-app-route'; + +type Period = 'week' | 'month' | '3months' | 'year'; + +type Bucket = { + key: string; + label: string; + start: string; + end: string; + prAuthored: number; + prMerged: number; + prReviewed: number; +}; + +type ContributorInfo = { + id: string; + name: string | null; + ghLogin: string | null; + avatarUrl: string | null; +}; + +type StatsResponse = { + contributor: ContributorInfo; + period: Period; + granularity: 'day' | 'week' | 'month'; + buckets: Bucket[]; + totals: { prAuthored: number; prMerged: number; prReviewed: number }; + truncated: { + prAuthored: boolean; + prMerged: boolean; + prReviewed: boolean; + }; + error?: string; +}; + +type SearchResponse = { + contributors: ContributorInfo[]; +}; + +const readSerializedValue = ( + e: React.SyntheticEvent, +): string | undefined => { + const obj = e as { detail?: { value?: string }; value?: string }; + if (typeof obj.detail?.value === 'string') return obj.detail.value; + if (typeof obj.value === 'string') return obj.value; + return undefined; +}; + +const onValueChange = + (fn: (value: string) => void) => + (e: React.SyntheticEvent) => { + const v = readSerializedValue(e); + if (typeof v === 'string') fn(v); + }; + +const PERIOD_OPTIONS: Array<{ value: Period; label: string }> = [ + { value: 'week', label: 'Last week' }, + { value: 'month', label: 'Last month' }, + { value: '3months', label: 'Last 3 months' }, + { value: 'year', label: 'Last year' }, +]; + +const WIDGET_HEADER_HEIGHT = 24; + +const styles = { + root: { + display: 'flex', + flexDirection: 'column', + width: '100%', + height: '100%', + minHeight: 0, + boxSizing: 'border-box', + padding: 12, + gap: 12, + fontFamily: THEME.fontFamily, + color: THEME.fontPrimary, + background: THEME.bgSecondary, + overflow: 'hidden', + } as const, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + flexWrap: 'wrap', + padding: '8px 12px', + background: THEME.bgPrimary, + border: `1px solid ${THEME.borderLight}`, + borderRadius: THEME.borderRadius, + boxSizing: 'border-box', + flexShrink: 0, + } as const, + contributor: { + display: 'flex', + alignItems: 'center', + gap: 8, + minWidth: 0, + } as const, + avatar: { + width: 24, + height: 24, + borderRadius: '50%', + objectFit: 'cover', + background: THEME.bgTertiary, + flexShrink: 0, + } as const, + smallAvatar: { + width: 16, + height: 16, + borderRadius: '50%', + objectFit: 'cover', + background: THEME.bgTertiary, + flexShrink: 0, + } as const, + nameCol: { + display: 'flex', + alignItems: 'baseline', + minWidth: 0, + gap: 6, + lineHeight: 1.2, + } as const, + name: { + fontSize: 14, + fontWeight: 500, + color: THEME.fontPrimary, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + } as const, + login: { + fontSize: 12, + fontWeight: 400, + color: THEME.fontTertiary, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + } as const, + controls: { + display: 'flex', + alignItems: 'center', + gap: 6, + flexWrap: 'wrap', + } as const, + select: { + fontFamily: THEME.fontFamily, + fontSize: 13, + height: 28, + lineHeight: '26px', + padding: '0 24px 0 10px', + border: `1px solid ${THEME.borderMedium}`, + borderRadius: THEME.borderRadius, + background: `${THEME.bgPrimary} url("data:image/svg+xml;utf8,") no-repeat right 8px center`, + color: THEME.fontPrimary, + cursor: 'pointer', + outline: 'none', + appearance: 'none', + WebkitAppearance: 'none', + MozAppearance: 'none', + } as const, + buttonNeutral: { + fontFamily: THEME.fontFamily, + fontSize: 13, + fontWeight: 500, + height: 28, + lineHeight: '26px', + padding: '0 10px', + border: `1px solid ${THEME.borderMedium}`, + borderRadius: THEME.borderRadius, + background: THEME.bgPrimary, + color: THEME.fontPrimary, + cursor: 'pointer', + outline: 'none', + } as const, + body: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + gap: 12, + } as const, + chartCard: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + border: `1px solid ${THEME.borderLight}`, + borderRadius: THEME.borderRadius, + padding: 8, + background: THEME.bgPrimary, + boxSizing: 'border-box', + } as const, + chartHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + height: WIDGET_HEADER_HEIGHT, + padding: '0 4px', + flexShrink: 0, + } as const, + chartTitle: { + fontSize: 13, + fontWeight: 500, + color: THEME.fontPrimary, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + } as const, + chartTotal: { + fontSize: 13, + fontWeight: 500, + color: THEME.fontTertiary, + fontVariantNumeric: 'tabular-nums', + flexShrink: 0, + } as const, + chartContainer: { + flex: '1 1 0%', + minHeight: 90, + width: '100%', + position: 'relative', + marginTop: 4, + } as const, + centered: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flex: 1, + color: THEME.fontTertiary, + fontSize: 13, + textAlign: 'center', + padding: 16, + } as const, + searchWrapper: { + display: 'flex', + flexDirection: 'column', + gap: 8, + flex: 1, + minHeight: 0, + } as const, + searchInput: { + fontFamily: THEME.fontFamily, + fontSize: 13, + height: 28, + padding: '0 10px', + border: `1px solid ${THEME.borderMedium}`, + borderRadius: THEME.borderRadius, + outline: 'none', + background: THEME.bgPrimary, + color: THEME.fontPrimary, + } as const, + searchList: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minHeight: 0, + overflowY: 'auto', + border: `1px solid ${THEME.borderLight}`, + borderRadius: THEME.borderRadius, + background: THEME.bgPrimary, + } as const, + searchItem: { + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '6px 10px', + cursor: 'pointer', + borderBottom: `1px solid ${THEME.borderLight}`, + fontSize: 13, + color: THEME.fontPrimary, + } as const, + truncatedHint: { + fontSize: 11, + color: THEME.fontTertiary, + marginTop: 4, + paddingLeft: 4, + } as const, +}; + +type BarChartProps = { + buckets: Bucket[]; + valueKey: 'prAuthored' | 'prMerged' | 'prReviewed'; + color: string; +}; + +const CHART_PADDING = { top: 6, right: 4, bottom: 16, left: 22 }; + +const BarChart = ({ buckets, valueKey, color }: BarChartProps) => { + const yMax = useMemo(() => { + const m = buckets.reduce((acc, b) => Math.max(acc, b[valueKey]), 0); + if (m === 0) return 1; + return m <= 5 ? m : Math.ceil(m / 5) * 5; + }, [buckets, valueKey]); + + const yTicks = useMemo(() => [0, Math.round(yMax / 2), yMax], [yMax]); + + const n = buckets.length; + const labelEvery = (() => { + if (n <= 6) return 1; + if (n <= 14) return 2; + return Math.ceil(n / 6); + })(); + + return ( +
+
+ {yTicks.map((t) => { + const bottomPct = (t / yMax) * 100; + return ( +
+ {t} +
+ ); + })} +
+ +
+ {yTicks.map((t, i) => { + const bottomPct = (t / yMax) * 100; + return ( +
+ ); + })} + +
+ {buckets.map((b, i) => { + const v = b[valueKey]; + const heightPct = (v / yMax) * 100; + const showLabel = i % labelEvery === 0 || i === n - 1; + return ( +
+
0 ? 2 : 0, + background: color, + borderRadius: '2px 2px 0 0', + }} + /> + {showLabel && ( +
+ {b.label} +
+ )} +
+ ); + })} +
+
+
+ ); +}; + +const ContributorStats = () => { + const recordId = useRecordId(); + const [period, setPeriod] = useState('month'); + const [selectedId, setSelectedId] = useState(recordId); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [searchLoading, setSearchLoading] = useState(false); + + useEffect(() => { + setSelectedId(recordId); + }, [recordId]); + + useEffect(() => { + if (selectedId) return; + let cancelled = false; + setSearchLoading(true); + const handle = setTimeout(async () => { + try { + const res = (await callAppRoute('/contributors/search', { + query: searchQuery, + limit: 20, + })) as SearchResponse; + if (!cancelled) setSearchResults(res.contributors ?? []); + } catch (err) { + if (!cancelled) { + enqueueSnackbar({ + message: + err instanceof Error ? err.message : 'Failed to search contributors', + variant: 'error', + }); + } + } finally { + if (!cancelled) setSearchLoading(false); + } + }, 200); + return () => { + cancelled = true; + clearTimeout(handle); + }; + }, [selectedId, searchQuery]); + + useEffect(() => { + if (!selectedId) { + setStats(null); + return; + } + let cancelled = false; + setLoading(true); + setError(null); + (async () => { + try { + const res = (await callAppRoute('/contributors/stats', { + contributorId: selectedId, + period, + })) as StatsResponse; + if (cancelled) return; + if (res.error) { + setError(res.error); + setStats(null); + } else { + setStats(res); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Failed to load stats'); + setStats(null); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [selectedId, period]); + + const renderHeader = (contributor: ContributorInfo | null) => ( +
+
+ {contributor?.avatarUrl ? ( + + ) : ( +
+ )} +
+ + {contributor?.name ?? contributor?.ghLogin ?? 'Contributor stats'} + + {contributor?.ghLogin && + contributor.ghLogin !== contributor.name && ( + @{contributor.ghLogin} + )} +
+
+
+ {selectedId && !recordId && ( + + )} + +
+
+ ); + + if (!selectedId) { + return ( +
+ {renderHeader(null)} +
+ +
+ {searchLoading && searchResults.length === 0 && ( +
+ Searching... +
+ )} + {!searchLoading && searchResults.length === 0 && ( +
+ No contributors found. +
+ )} + {searchResults.map((eng) => ( +
setSelectedId(eng.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') setSelectedId(eng.id); + }} + style={styles.searchItem} + > + {eng.avatarUrl ? ( + + ) : ( +
+ )} +
+ + {eng.name ?? eng.ghLogin ?? 'Unknown'} + + {eng.ghLogin && eng.ghLogin !== eng.name && ( + @{eng.ghLogin} + )} +
+
+ ))} +
+
+
+ ); + } + + return ( +
+ {renderHeader(stats?.contributor ?? null)} +
+ {loading && !stats && ( +
Loading contributor stats...
+ )} + {error &&
{error}
} + {stats && ( + <> +
+
+ PRs authored (merged) + {stats.totals.prAuthored} +
+ + {stats.truncated.prAuthored && ( + + Showing partial data (results truncated). + + )} +
+ +
+
+ PRs merged + {stats.totals.prMerged} +
+ + {stats.truncated.prMerged && ( + + Showing partial data (results truncated). + + )} +
+ +
+
+ PRs reviewed + {stats.totals.prReviewed} +
+ + {stats.truncated.prReviewed && ( + + Showing partial data (results truncated). + + )} +
+ + )} +
+
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: '6c2f1a8d-4b9e-4f7a-9c5d-1e8b2a3d4c5f', + name: 'Contributor Stats', + description: + 'Displays time-bucketed charts of PRs authored (merged only), merged and reviewed by a contributor over the last week, month, 3 months or year.', + component: ContributorStats, + command: { + universalIdentifier: '7d3f2b9e-5c0a-4e8b-ad6e-2f9c3b4d5e6a', + label: 'Contributor Stats', + icon: 'IconChartBar', + isPinned: false, + conditionalAvailabilityExpression: + objectMetadataItem.nameSingular === 'contributor', + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/front-components/fetch-contributors.front-component.tsx b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/front-components/fetch-contributors.front-component.tsx new file mode 100644 index 0000000000..6f7bd34cdf --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/front-components/fetch-contributors.front-component.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from 'react'; +import { defineFrontComponent } from 'twenty-sdk/define'; +import { + enqueueSnackbar, + objectMetadataItem, + unmountFrontComponent, + updateProgress, +} from 'twenty-sdk/front-component'; +import { callAppRoute } from 'src/modules/shared/call-app-route'; + +type CountResponse = { + totalPages: number; + repos: Array<{ owner: string; repo: string; totalCount: number; pages: number }>; +}; + +type FetchPageResponse = { + contributorCount: number; + totalCount: number; + hasMore: boolean; + endCursor: string | null; +}; + +type SyncStatus = 'syncing' | 'done' | 'error'; + +const FetchContributors = () => { + const [status, setStatus] = useState('syncing'); + + useEffect(() => { + const run = async () => { + try { + const counts = (await callAppRoute( + '/github/count-contributors', + {}, + )) as CountResponse; + + if (counts.repos.length === 0) { + throw new Error( + 'No repos resolved. Set GITHUB_REPOS in the application variables.', + ); + } + + const totalPages = Math.max(counts.totalPages, 1); + let pagesProcessed = 0; + let totalSynced = 0; + + for (const { owner, repo } of counts.repos) { + let cursor: string | null = null; + let hasMore = true; + + while (hasMore) { + const data = (await callAppRoute('/github/fetch-contributors', { + owner, + repo, + cursor, + })) as FetchPageResponse; + + totalSynced += data.contributorCount; + hasMore = data.hasMore && data.contributorCount > 0; + cursor = data.endCursor; + pagesProcessed++; + + updateProgress( + Math.min(Math.round((pagesProcessed / totalPages) * 100), 99), + ); + } + } + + updateProgress(100); + enqueueSnackbar({ + message: `Synced ${totalSynced} contributors`, + variant: 'success', + }); + setStatus('done'); + } catch (err) { + const message = + err instanceof Error ? err.message : 'Failed to fetch contributors'; + enqueueSnackbar({ message, variant: 'error' }); + setStatus('error'); + } finally { + unmountFrontComponent(); + } + }; + run(); + }, []); + + if (status === 'syncing') return
Fetching contributors...
; + if (status === 'error') return
Failed to fetch contributors.
; + return
Done
; +}; + +export default defineFrontComponent({ + universalIdentifier: '08f40f82-24ed-4f3e-8c99-695151e90e38', + name: 'Fetch Contributors', + description: + 'Fetches contributors from every configured GitHub repo (GITHUB_REPOS) and upserts them as Contributor records.', + isHeadless: true, + component: FetchContributors, + command: { + universalIdentifier: '4640992f-c2c9-4bba-b5df-9c8f05dc9e80', + label: 'Fetch Contributors', + icon: 'IconUsers', + isPinned: false, + conditionalAvailabilityExpression: + objectMetadataItem.nameSingular === 'contributor', + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/front-components/top-pr-authors.front-component.tsx b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/front-components/top-pr-authors.front-component.tsx new file mode 100644 index 0000000000..9818b1e28b --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/front-components/top-pr-authors.front-component.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; +import { defineFrontComponent } from 'twenty-sdk/define'; +import { enqueueSnackbar } from 'twenty-sdk/front-component'; +import { + Leaderboard, + type LeaderboardEntry, +} from 'src/modules/github/contributor/components/leaderboard'; +import { callAppRoute } from 'src/modules/shared/call-app-route'; + +type Response = { + topAuthors: LeaderboardEntry[]; +}; + +const TopPRAuthors = () => { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + (async () => { + try { + const res = (await callAppRoute('/contributors/top', { + days: 90, + limit: 20, + kind: 'authors', + })) as Response; + if (!cancelled) setEntries(res.topAuthors ?? []); + } catch (err) { + if (!cancelled) { + const message = + err instanceof Error ? err.message : 'Failed to load top authors'; + setError(message); + enqueueSnackbar({ message, variant: 'error' }); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + return ( + + ); +}; + +export const TOP_PR_AUTHORS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER = + 'a1d4f7e2-9b3c-4e8a-bf21-5d6c8a9b2e3f'; + +export default defineFrontComponent({ + universalIdentifier: TOP_PR_AUTHORS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + name: 'Top PR Authors', + description: 'Leaderboard of the top 20 pull-request authors over the last 90 days.', + component: TopPRAuthors, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/front-components/top-reviewers.front-component.tsx b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/front-components/top-reviewers.front-component.tsx new file mode 100644 index 0000000000..457a682b24 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/front-components/top-reviewers.front-component.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react'; +import { defineFrontComponent } from 'twenty-sdk/define'; +import { enqueueSnackbar } from 'twenty-sdk/front-component'; +import { + Leaderboard, + type LeaderboardEntry, +} from 'src/modules/github/contributor/components/leaderboard'; +import { callAppRoute } from 'src/modules/shared/call-app-route'; + +type Response = { + topReviewers: LeaderboardEntry[]; +}; + +const TopReviewers = () => { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + (async () => { + try { + const res = (await callAppRoute('/contributors/top', { + days: 90, + limit: 20, + kind: 'reviewers', + })) as Response; + if (!cancelled) setEntries(res.topReviewers ?? []); + } catch (err) { + if (!cancelled) { + const message = + err instanceof Error ? err.message : 'Failed to load top reviewers'; + setError(message); + enqueueSnackbar({ message, variant: 'error' }); + } + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + return ( + + ); +}; + +export const TOP_REVIEWERS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER = + 'b2e5f8a3-ac4d-4f9b-8032-6e7d9bac3f4a'; + +export default defineFrontComponent({ + universalIdentifier: TOP_REVIEWERS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + name: 'Top Reviewers', + description: 'Leaderboard of the top 20 pull-request reviewers over the last 90 days.', + component: TopReviewers, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/github/count-contributors.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/github/count-contributors.ts new file mode 100644 index 0000000000..343c1f0b83 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/github/count-contributors.ts @@ -0,0 +1,20 @@ +import { githubGraphql } from 'src/modules/github/connector/github-client'; + +const QUERY = ` +query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + mentionableUsers { totalCount } + } +}`; + +type Response = { + repository: { mentionableUsers: { totalCount: number } } | null; +}; + +export async function countContributors( + owner: string, + name: string, +): Promise { + const data = await githubGraphql(QUERY, { owner, name }); + return data.repository?.mentionableUsers.totalCount ?? 0; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/github/fetch-contributors.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/github/fetch-contributors.ts new file mode 100644 index 0000000000..78c489eb92 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/github/fetch-contributors.ts @@ -0,0 +1,52 @@ +import { + EMPTY_PAGE, + type GithubPage, + githubGraphql, +} from 'src/modules/github/connector/github-client'; + +const QUERY = ` +query($owner: String!, $name: String!, $cursor: String) { + repository(owner: $owner, name: $name) { + mentionableUsers(first: 100, after: $cursor) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { + login + databaseId + avatarUrl + } + } + } +}`; + +export type GqlContributor = { + login: string; + databaseId: number; + avatarUrl: string; +}; + +type Response = { + repository: { mentionableUsers: GithubPage } | null; +}; + +export async function fetchContributors( + owner: string, + name: string, + cursor: string | null = null, +): Promise<{ + contributors: GqlContributor[]; + totalCount: number; + hasMore: boolean; + endCursor: string | null; +}> { + const data = await githubGraphql(QUERY, { owner, name, cursor }); + const conn = data.repository?.mentionableUsers; + if (!conn) return { contributors: [], ...EMPTY_PAGE }; + + return { + contributors: conn.nodes, + totalCount: conn.totalCount, + hasMore: conn.pageInfo.hasNextPage, + endCursor: conn.pageInfo.endCursor, + }; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/mutations/batch-upsert.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/mutations/batch-upsert.ts new file mode 100644 index 0000000000..b6f3f7e867 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/mutations/batch-upsert.ts @@ -0,0 +1,21 @@ +import type { ContributorRow } from 'src/modules/github/contributor/types/contributor-row'; +import { chunkedBatchCreate } from 'src/modules/shared/twenty-client'; + +export async function batchUpsertContributors( + items: Array<{ + ghLogin: string; + name: string; + githubId: number; + avatarUrl?: { primaryLinkLabel: string; primaryLinkUrl: string; secondaryLinks: null } | null; + contributions?: number; + }>, +): Promise { + return chunkedBatchCreate('createContributors', items, { + id: true, + ghLogin: true, + name: true, + githubId: true, + avatarUrl: true, + contributions: true, + }) as Promise; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/queries/find-by-gh-login.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/queries/find-by-gh-login.ts new file mode 100644 index 0000000000..9ee5b6a64b --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/queries/find-by-gh-login.ts @@ -0,0 +1,27 @@ +import type { ContributorRow } from 'src/modules/github/contributor/types/contributor-row'; +import { getClient } from 'src/modules/shared/twenty-client'; + +export async function findContributorByGhLogin( + ghLogin: string, +): Promise { + const client = getClient(); + const res = await client.query({ + contributors: { + __args: { + filter: { ghLogin: { eq: ghLogin } }, + first: 1, + }, + edges: { + node: { + id: true, + ghLogin: true, + name: true, + githubId: true, + }, + }, + }, + }); + + const edges = res.contributors?.edges; + return (edges?.[0]?.node as ContributorRow | undefined) ?? null; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/queries/search-contributors.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/queries/search-contributors.ts new file mode 100644 index 0000000000..5c45ba376c --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/queries/search-contributors.ts @@ -0,0 +1,52 @@ +import { getClient } from 'src/modules/shared/twenty-client'; + +export type ContributorSearchResult = { + id: string; + name: string | null; + ghLogin: string | null; + avatarUrl: string | null; +}; + +export async function searchContributors( + query: string, + limit: number, +): Promise { + const client = getClient(); + + const filter = + query.length === 0 + ? undefined + : { + or: [ + { name: { ilike: `%${query}%` } }, + { ghLogin: { ilike: `%${query}%` } }, + ], + }; + + const res = await client.query({ + contributors: { + __args: { + ...(filter ? { filter } : {}), + orderBy: [{ name: 'AscNullsLast' }], + first: limit, + }, + edges: { + node: { + id: true, + name: true, + ghLogin: true, + avatarUrl: { primaryLinkUrl: true }, + }, + }, + }, + }); + + const edges = res.contributors?.edges ?? []; + + return edges.map((e) => ({ + id: e.node.id, + name: e.node.name ?? null, + ghLogin: e.node.ghLogin ?? null, + avatarUrl: e.node.avatarUrl?.primaryLinkUrl ?? null, + })); +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/contributor-stats.logic-function.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/contributor-stats.logic-function.ts new file mode 100644 index 0000000000..cc49cc60ae --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/contributor-stats.logic-function.ts @@ -0,0 +1,376 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; +import { getClient } from 'src/modules/shared/twenty-client'; + +export type StatsPeriod = 'week' | 'month' | '3months' | 'year'; + +type Granularity = 'day' | 'week' | 'month'; + +type Bucket = { + key: string; + label: string; + start: string; + end: string; + prAuthored: number; + prMerged: number; + prReviewed: number; +}; + +type Totals = { + prAuthored: number; + prMerged: number; + prReviewed: number; +}; + +type ContributorInfo = { + id: string; + name: string | null; + ghLogin: string | null; + avatarUrl: string | null; +}; + +type ContributorStatsPayload = { + contributorId?: string; + period?: StatsPeriod; +}; + +type ContributorStatsResponse = + | { + contributor: ContributorInfo; + period: StatsPeriod; + granularity: Granularity; + buckets: Bucket[]; + totals: Totals; + truncated: { + prAuthored: boolean; + prMerged: boolean; + prReviewed: boolean; + }; + } + | { error: string }; + +const PAGE_SIZE = 100; +const MAX_PAGES = 30; + +const PERIOD_CONFIG: Record< + StatsPeriod, + { granularity: Granularity; rangeMs: number; bucketCount: number } +> = { + week: { granularity: 'day', rangeMs: 7 * 24 * 3600 * 1000, bucketCount: 7 }, + month: { granularity: 'day', rangeMs: 30 * 24 * 3600 * 1000, bucketCount: 30 }, + '3months': { + granularity: 'week', + rangeMs: 13 * 7 * 24 * 3600 * 1000, + bucketCount: 13, + }, + year: { + granularity: 'month', + rangeMs: 365 * 24 * 3600 * 1000, + bucketCount: 12, + }, +}; + +const startOfUtcDay = (d: Date): Date => + new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); + +const startOfUtcWeek = (d: Date): Date => { + const day = startOfUtcDay(d); + const dow = day.getUTCDay(); + const diff = (dow + 6) % 7; + day.setUTCDate(day.getUTCDate() - diff); + return day; +}; + +const startOfUtcMonth = (d: Date): Date => + new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1)); + +const addBucket = (start: Date, granularity: Granularity, n: number): Date => { + const out = new Date(start); + if (granularity === 'day') out.setUTCDate(out.getUTCDate() + n); + else if (granularity === 'week') out.setUTCDate(out.getUTCDate() + 7 * n); + else out.setUTCMonth(out.getUTCMonth() + n); + return out; +}; + +const bucketStartFor = (d: Date, granularity: Granularity): Date => { + if (granularity === 'day') return startOfUtcDay(d); + if (granularity === 'week') return startOfUtcWeek(d); + return startOfUtcMonth(d); +}; + +const formatBucketLabel = (start: Date, granularity: Granularity): string => { + const month = start.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' }); + if (granularity === 'month') { + return `${month} ${String(start.getUTCFullYear()).slice(2)}`; + } + return `${month} ${start.getUTCDate()}`; +}; + +const buildBuckets = ( + now: Date, + period: StatsPeriod, +): { buckets: Bucket[]; rangeStart: Date; granularity: Granularity } => { + const { granularity, bucketCount } = PERIOD_CONFIG[period]; + const lastBucketStart = bucketStartFor(now, granularity); + const firstBucketStart = addBucket( + lastBucketStart, + granularity, + -(bucketCount - 1), + ); + + const buckets: Bucket[] = []; + for (let i = 0; i < bucketCount; i++) { + const start = addBucket(firstBucketStart, granularity, i); + const end = addBucket(start, granularity, 1); + buckets.push({ + key: start.toISOString(), + label: formatBucketLabel(start, granularity), + start: start.toISOString(), + end: end.toISOString(), + prAuthored: 0, + prMerged: 0, + prReviewed: 0, + }); + } + return { buckets, rangeStart: firstBucketStart, granularity }; +}; + +type Edge = { node: T }; +type Connection = { + edges: Edge[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; +}; + +async function paginateUntil( + fetchPage: (cursor: string | null) => Promise>, + isOlderThanRange: (item: T) => boolean, +): Promise<{ items: T[]; truncated: boolean }> { + const items: T[] = []; + let cursor: string | null = null; + for (let page = 0; page < MAX_PAGES; page++) { + const conn = await fetchPage(cursor); + let stop = false; + for (const edge of conn.edges) { + if (isOlderThanRange(edge.node)) { + stop = true; + break; + } + items.push(edge.node); + } + if (stop) return { items, truncated: false }; + if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor) { + return { items, truncated: false }; + } + cursor = conn.pageInfo.endCursor; + } + return { items, truncated: true }; +} + +type MergedPrNode = { mergedAt: string | null }; +type ReviewNode = { firstSubmittedAt: string | null }; + +const fetchContributorInfo = async ( + contributorId: string, +): Promise => { + const client = getClient(); + const res = await client.query({ + contributors: { + __args: { filter: { id: { eq: contributorId } }, first: 1 }, + edges: { + node: { + id: true, + name: true, + ghLogin: true, + avatarUrl: { primaryLinkUrl: true }, + }, + }, + }, + }); + const node = res.contributors?.edges?.[0]?.node; + if (!node) return null; + return { + id: node.id, + name: node.name ?? null, + ghLogin: node.ghLogin ?? null, + avatarUrl: node.avatarUrl?.primaryLinkUrl ?? null, + }; +}; + +const handler = async ( + event: RoutePayload, +): Promise => { + const contributorId = event.body?.contributorId; + const period: StatsPeriod = event.body?.period ?? 'month'; + + if (!contributorId) { + return { error: 'contributorId is required' }; + } + if (!(period in PERIOD_CONFIG)) { + return { error: `Unsupported period: ${period}` }; + } + + const contributor = await fetchContributorInfo(contributorId); + if (!contributor) { + return { error: 'Contributor not found' }; + } + + const now = new Date(); + const { buckets, rangeStart, granularity } = buildBuckets(now, period); + const rangeStartMs = rangeStart.getTime(); + const bucketIndex = new Map(); + buckets.forEach((b, i) => bucketIndex.set(b.key, i)); + + const client = getClient(); + + const mergedResult = await paginateUntil( + async (cursor) => { + const res = await client.query({ + pullRequests: { + __args: { + filter: { + and: [ + { mergerId: { eq: contributorId } }, + { state: { eq: 'MERGED' } }, + ], + }, + orderBy: [{ mergedAt: 'DescNullsLast' }], + first: PAGE_SIZE, + after: cursor, + }, + edges: { node: { mergedAt: true } }, + pageInfo: { hasNextPage: true, endCursor: true }, + }, + }); + return ( + (res.pullRequests as Connection) ?? { + edges: [], + pageInfo: { hasNextPage: false, endCursor: null }, + } + ); + }, + (n) => { + if (!n.mergedAt) return false; + return new Date(n.mergedAt).getTime() < rangeStartMs; + }, + ); + + const authoredResult = await paginateUntil( + async (cursor) => { + const res = await client.query({ + pullRequests: { + __args: { + filter: { + and: [ + { authorId: { eq: contributorId } }, + { state: { eq: 'MERGED' } }, + ], + }, + orderBy: [{ mergedAt: 'DescNullsLast' }], + first: PAGE_SIZE, + after: cursor, + }, + edges: { node: { mergedAt: true } }, + pageInfo: { hasNextPage: true, endCursor: true }, + }, + }); + return ( + (res.pullRequests as Connection) ?? { + edges: [], + pageInfo: { hasNextPage: false, endCursor: null }, + } + ); + }, + (n) => { + if (!n.mergedAt) return false; + return new Date(n.mergedAt).getTime() < rangeStartMs; + }, + ); + + const reviewedResult = await paginateUntil( + async (cursor) => { + const res = await client.query({ + pullRequestReviews: { + __args: { + filter: { reviewerId: { eq: contributorId } }, + orderBy: [{ firstSubmittedAt: 'DescNullsLast' }], + first: PAGE_SIZE, + after: cursor, + }, + edges: { node: { firstSubmittedAt: true } }, + pageInfo: { hasNextPage: true, endCursor: true }, + }, + }); + return ( + (res.pullRequestReviews as Connection) ?? { + edges: [], + pageInfo: { hasNextPage: false, endCursor: null }, + } + ); + }, + (n) => { + if (!n.firstSubmittedAt) return false; + return new Date(n.firstSubmittedAt).getTime() < rangeStartMs; + }, + ); + + const totals: Totals = { prAuthored: 0, prMerged: 0, prReviewed: 0 }; + + for (const pr of authoredResult.items) { + if (!pr.mergedAt) continue; + const d = new Date(pr.mergedAt); + if (d.getTime() < rangeStartMs) continue; + const key = bucketStartFor(d, granularity).toISOString(); + const idx = bucketIndex.get(key); + if (idx === undefined) continue; + buckets[idx].prAuthored++; + totals.prAuthored++; + } + + for (const pr of mergedResult.items) { + if (!pr.mergedAt) continue; + const d = new Date(pr.mergedAt); + if (d.getTime() < rangeStartMs) continue; + const key = bucketStartFor(d, granularity).toISOString(); + const idx = bucketIndex.get(key); + if (idx === undefined) continue; + buckets[idx].prMerged++; + totals.prMerged++; + } + + for (const r of reviewedResult.items) { + if (!r.firstSubmittedAt) continue; + const d = new Date(r.firstSubmittedAt); + if (d.getTime() < rangeStartMs) continue; + const key = bucketStartFor(d, granularity).toISOString(); + const idx = bucketIndex.get(key); + if (idx === undefined) continue; + buckets[idx].prReviewed++; + totals.prReviewed++; + } + + return { + contributor, + period, + granularity, + buckets, + totals, + truncated: { + prAuthored: authoredResult.truncated, + prMerged: mergedResult.truncated, + prReviewed: reviewedResult.truncated, + }, + }; +}; + +export default defineLogicFunction({ + universalIdentifier: 'a3c9e1b6-2f47-4d8a-9b0f-7e6d1a2c3b4f', + name: 'contributor-stats', + description: + 'Returns time-bucketed counts of PRs authored (merged only), merged and reviewed by a contributor over the selected period.', + timeoutSeconds: 30, + handler, + httpRouteTriggerSettings: { + path: '/contributors/stats', + httpMethod: 'POST', + isAuthRequired: true, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/count-contributors.logic-function.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/count-contributors.logic-function.ts new file mode 100644 index 0000000000..9f291819fd --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/count-contributors.logic-function.ts @@ -0,0 +1,28 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; +import { countAcrossRepos } from 'src/modules/github/connector/count-across-repos'; +import { countContributors } from 'src/modules/github/contributor/graphql/github/count-contributors'; + +type CountContributorsPayload = { + repos?: string[]; +}; + +const handler = async (event: RoutePayload) => + countAcrossRepos( + event.body?.repos, + countContributors, + 'count-contributors', + ); + +export default defineLogicFunction({ + universalIdentifier: 'fe0a6f00-0d63-4cb9-9b3c-1d8186181830', + name: 'count-contributors', + description: + 'Counts contributors across configured repos and returns the per-repo page split.', + timeoutSeconds: 30, + handler, + httpRouteTriggerSettings: { + path: '/github/count-contributors', + httpMethod: 'POST', + isAuthRequired: true, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/fetch-contributors.logic-function.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/fetch-contributors.logic-function.ts new file mode 100644 index 0000000000..9233dbe56e --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/fetch-contributors.logic-function.ts @@ -0,0 +1,70 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; +import { + fetchContributors, + type GqlContributor, +} from 'src/modules/github/contributor/graphql/github/fetch-contributors'; +import { batchUpsertContributors } from 'src/modules/github/contributor/graphql/mutations/batch-upsert'; +import { isFixtureAllowed } from 'src/modules/shared/fixtures'; + +export type FetchContributorsFixturePage = { + contributors: GqlContributor[]; + totalCount: number; + hasMore: boolean; + endCursor: string | null; +}; + +type FetchContributorsPayload = { + owner: string; + repo: string; + cursor?: string | null; + fixturePage?: FetchContributorsFixturePage; +}; + +const handler = async (event: RoutePayload) => { + const { owner, repo, cursor = null, fixturePage } = event.body ?? {}; + if (!owner || !repo) { + return { error: 'owner and repo are required' }; + } + + const result = + fixturePage && isFixtureAllowed() + ? fixturePage + : await fetchContributors(owner, repo, cursor); + const { contributors, totalCount, hasMore, endCursor } = result; + + if (contributors.length === 0) { + return { contributorCount: 0, totalCount, hasMore: false, endCursor: null }; + } + + const contributorData = contributors.map((c: GqlContributor) => ({ + ghLogin: c.login, + name: c.login, + githubId: c.databaseId ?? 0, + avatarUrl: c.avatarUrl + ? { primaryLinkLabel: c.login, primaryLinkUrl: c.avatarUrl, secondaryLinks: null } + : null, + })); + + await batchUpsertContributors(contributorData); + + return { + contributorCount: contributors.length, + totalCount, + hasMore, + endCursor, + }; +}; + +export default defineLogicFunction({ + universalIdentifier: 'ed9ba981-4172-4924-a18f-51fc6dcbcb23', + name: 'fetch-contributors', + description: + 'Fetches one page of contributors via GitHub GraphQL API and batch upserts them into the workspace.', + timeoutSeconds: 300, + handler, + httpRouteTriggerSettings: { + path: '/github/fetch-contributors', + httpMethod: 'POST', + isAuthRequired: true, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/search-contributors.logic-function.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/search-contributors.logic-function.ts new file mode 100644 index 0000000000..5074cfb65f --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/search-contributors.logic-function.ts @@ -0,0 +1,43 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; +import { + searchContributors, + type ContributorSearchResult, +} from 'src/modules/github/contributor/graphql/queries/search-contributors'; + +type SearchContributorsPayload = { + query?: string; + limit?: number; +}; + +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 50; + +const handler = async ( + event: RoutePayload, +): Promise<{ contributors: ContributorSearchResult[] }> => { + const queryInput = event.body?.query; + const rawQuery = typeof queryInput === 'string' ? queryInput.trim() : ''; + const limitInput = event.body?.limit; + const limitNumber = + typeof limitInput === 'number' && Number.isFinite(limitInput) + ? limitInput + : DEFAULT_LIMIT; + const limit = Math.min(Math.max(Math.floor(limitNumber), 1), MAX_LIMIT); + + const contributors = await searchContributors(rawQuery, limit); + return { contributors }; +}; + +export default defineLogicFunction({ + universalIdentifier: 'b4d8f2a7-3e58-4f9b-ac1d-8f7e2b3c4d5e', + name: 'search-contributors', + description: + 'Searches contributors by name or GitHub login for use in front-end pickers.', + timeoutSeconds: 15, + handler, + httpRouteTriggerSettings: { + path: '/contributors/search', + httpMethod: 'POST', + isAuthRequired: true, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/top-contributors.logic-function.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/top-contributors.logic-function.ts new file mode 100644 index 0000000000..6395156d40 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/top-contributors.logic-function.ts @@ -0,0 +1,257 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; +import { isBotLogin } from 'src/modules/github/contributor/utils/is-bot-login'; +import { getClient } from 'src/modules/shared/twenty-client'; + +type ContributorRef = { + id: string; + name: string | null; + ghLogin: string | null; + avatarUrl: string | null; +}; + +type LeaderboardEntry = ContributorRef & { + count: number; +}; + +type TopContributorsKind = 'authors' | 'reviewers' | 'both'; + +type TopContributorsPayload = { + days?: number; + limit?: number; + kind?: TopContributorsKind; +}; + +type TopContributorsResponse = { + days: number; + limit: number; + topAuthors: LeaderboardEntry[]; + topReviewers: LeaderboardEntry[]; + truncated: { authors: boolean; reviewers: boolean }; +}; + +const PAGE_SIZE = 100; +const MAX_PAGES = 50; +const DEFAULT_DAYS = 90; +const DEFAULT_LIMIT = 10; +const MAX_LIMIT = 50; +const MAX_DAYS = 365; + +type Edge = { node: T }; +type Connection = { + edges: Edge[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; +}; + +async function paginateUntil( + fetchPage: (cursor: string | null) => Promise>, + isOlderThanRange: (item: T) => boolean, +): Promise<{ items: T[]; truncated: boolean }> { + const items: T[] = []; + let cursor: string | null = null; + for (let page = 0; page < MAX_PAGES; page++) { + const conn = await fetchPage(cursor); + let stop = false; + for (const edge of conn.edges) { + if (isOlderThanRange(edge.node)) { + stop = true; + break; + } + items.push(edge.node); + } + if (stop) return { items, truncated: false }; + if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor) { + return { items, truncated: false }; + } + cursor = conn.pageInfo.endCursor; + } + return { items, truncated: true }; +} + +type AuthorInfo = { + id: string; + name: string | null; + ghLogin: string | null; + avatarUrl: { primaryLinkUrl: string | null } | null; +}; + +type PrNode = { + githubCreatedAt: string | null; + author: AuthorInfo | null; +}; + +type ReviewNode = { + firstSubmittedAt: string | null; + reviewer: AuthorInfo | null; +}; + +const tally = ( + items: { contributor: AuthorInfo | null }[], + limit: number, +): LeaderboardEntry[] => { + const counts = new Map(); + for (const item of items) { + const c = item.contributor; + if (!c) continue; + if (isBotLogin(c.ghLogin)) continue; + const existing = counts.get(c.id); + if (existing) { + existing.count += 1; + } else { + counts.set(c.id, { + id: c.id, + name: c.name ?? null, + ghLogin: c.ghLogin ?? null, + avatarUrl: c.avatarUrl?.primaryLinkUrl ?? null, + count: 1, + }); + } + } + return Array.from(counts.values()) + .sort((a, b) => b.count - a.count || (a.ghLogin ?? '').localeCompare(b.ghLogin ?? '')) + .slice(0, limit); +}; + +const handler = async ( + event: RoutePayload, +): Promise => { + const daysInput = event.body?.days; + const limitInput = event.body?.limit; + + const days = Math.min( + Math.max( + Math.floor( + typeof daysInput === 'number' && Number.isFinite(daysInput) + ? daysInput + : DEFAULT_DAYS, + ), + 1, + ), + MAX_DAYS, + ); + const limit = Math.min( + Math.max( + Math.floor( + typeof limitInput === 'number' && Number.isFinite(limitInput) + ? limitInput + : DEFAULT_LIMIT, + ), + 1, + ), + MAX_LIMIT, + ); + + const kindInput = event.body?.kind; + const kind: TopContributorsKind = + kindInput === 'authors' || kindInput === 'reviewers' ? kindInput : 'both'; + + const sinceMs = Date.now() - days * 24 * 3600 * 1000; + const client = getClient(); + + const authoredResult: { items: PrNode[]; truncated: boolean } = + kind === 'reviewers' ? { items: [], truncated: false } : await paginateUntil( + async (cursor) => { + const res = await client.query({ + pullRequests: { + __args: { + orderBy: [{ githubCreatedAt: 'DescNullsLast' }], + first: PAGE_SIZE, + after: cursor, + }, + edges: { + node: { + githubCreatedAt: true, + author: { + id: true, + name: true, + ghLogin: true, + avatarUrl: { primaryLinkUrl: true }, + }, + }, + }, + pageInfo: { hasNextPage: true, endCursor: true }, + }, + }); + return ( + (res.pullRequests as Connection) ?? { + edges: [], + pageInfo: { hasNextPage: false, endCursor: null }, + } + ); + }, + (n) => { + if (!n.githubCreatedAt) return false; + return new Date(n.githubCreatedAt).getTime() < sinceMs; + }, + ); + + const reviewedResult: { items: ReviewNode[]; truncated: boolean } = + kind === 'authors' ? { items: [], truncated: false } : await paginateUntil( + async (cursor) => { + const res = await client.query({ + pullRequestReviews: { + __args: { + orderBy: [{ firstSubmittedAt: 'DescNullsLast' }], + first: PAGE_SIZE, + after: cursor, + }, + edges: { + node: { + firstSubmittedAt: true, + reviewer: { + id: true, + name: true, + ghLogin: true, + avatarUrl: { primaryLinkUrl: true }, + }, + }, + }, + pageInfo: { hasNextPage: true, endCursor: true }, + }, + }); + return ( + (res.pullRequestReviews as Connection) ?? { + edges: [], + pageInfo: { hasNextPage: false, endCursor: null }, + } + ); + }, + (n) => { + if (!n.firstSubmittedAt) return false; + return new Date(n.firstSubmittedAt).getTime() < sinceMs; + }, + ); + + const topAuthors = tally( + authoredResult.items.map((pr) => ({ contributor: pr.author })), + limit, + ); + const topReviewers = tally( + reviewedResult.items.map((r) => ({ contributor: r.reviewer })), + limit, + ); + + return { + days, + limit, + topAuthors, + topReviewers, + truncated: { + authors: authoredResult.truncated, + reviewers: reviewedResult.truncated, + }, + }; +}; + +export default defineLogicFunction({ + universalIdentifier: 'd5b9c4a2-7e3f-4a1b-9c8d-2f4e6a7b8c9d', + name: 'top-contributors', + description: + 'Returns the top contributors by pull-request authorship and review counts over a configurable time window.', + timeoutSeconds: 30, + handler, + httpRouteTriggerSettings: { + path: '/contributors/top', + httpMethod: 'POST', + isAuthRequired: true, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/normalizers.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/normalizers.ts new file mode 100644 index 0000000000..8462783aea --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/normalizers.ts @@ -0,0 +1,47 @@ +import { type LinksFieldValue, toLinksField } from 'src/modules/shared/types'; + +export type GhUser = { + login: string; + id?: number; + databaseId?: number; + avatarUrl?: string | null; + avatar_url?: string | null; +}; + +export type ContributorCanonical = { + ghLogin: string; + name: string; + githubId: number; + avatarUrl: LinksFieldValue | null; +}; + +export function contributorFromGhUser(user: GhUser): ContributorCanonical { + const avatarUrl = user.avatarUrl ?? user.avatar_url ?? null; + return { + ghLogin: user.login, + name: user.login, + githubId: user.id ?? user.databaseId ?? 0, + avatarUrl: avatarUrl ? toLinksField(avatarUrl, user.login) : null, + }; +} + +export function dedupeContributors( + users: Array, +): ContributorCanonical[] { + const seen = new Map(); + for (const u of users) { + if (!u || !u.login) continue; + const existing = seen.get(u.login); + if (!existing) { + seen.set(u.login, contributorFromGhUser(u)); + continue; + } + if (!existing.avatarUrl) { + const avatarUrl = u.avatarUrl ?? u.avatar_url ?? null; + if (avatarUrl) { + existing.avatarUrl = toLinksField(avatarUrl, u.login); + } + } + } + return [...seen.values()]; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/objects/contributor.object.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/objects/contributor.object.ts new file mode 100644 index 0000000000..c0de548677 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/objects/contributor.object.ts @@ -0,0 +1,72 @@ +import { defineObject, FieldType } from 'twenty-sdk/define'; + +export const CONTRIBUTOR_UNIVERSAL_IDENTIFIER = + '8e9464f0-fdc9-487a-9963-c7accac0d4bb'; + +export const CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER = + 'e2e77e0e-5300-44ce-ab1c-9ad0593b5d1a'; + +export const CONTRIBUTOR_GH_LOGIN_FIELD_UNIVERSAL_IDENTIFIER = + 'a1d3c7b2-4e5f-4a8b-9c6d-2e1f0b3a4c5d'; + +export const CONTRIBUTOR_GITHUB_ID_FIELD_UNIVERSAL_IDENTIFIER = + 'f7a8b9c0-1d2e-4f3a-8b5c-6d7e8f9a0b1c'; + +export const CONTRIBUTOR_AVATAR_URL_FIELD_UNIVERSAL_IDENTIFIER = + '450a612c-f7a3-4bba-aa36-e22339eb0720'; + +export const CONTRIBUTOR_CONTRIBUTIONS_FIELD_UNIVERSAL_IDENTIFIER = + '66f73e49-6edb-48fa-812b-c3e684ef6340'; + +export const CONTRIBUTOR_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER = + 'bb8d2a87-b41c-578b-98de-2e37dba12a14'; + +export default defineObject({ + universalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER, + nameSingular: 'contributor', + namePlural: 'contributors', + labelSingular: 'Contributor', + labelPlural: 'Contributors', + icon: 'IconUsers', + labelIdentifierFieldMetadataUniversalIdentifier: + CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER, + fields: [ + { + universalIdentifier: CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER, + name: 'name', + type: FieldType.TEXT, + label: 'Name', + icon: 'IconUser', + }, + { + universalIdentifier: CONTRIBUTOR_GH_LOGIN_FIELD_UNIVERSAL_IDENTIFIER, + name: 'ghLogin', + type: FieldType.TEXT, + label: 'GitHub Login', + icon: 'IconBrandGithub', + isUnique: true, + }, + { + universalIdentifier: CONTRIBUTOR_GITHUB_ID_FIELD_UNIVERSAL_IDENTIFIER, + name: 'githubId', + type: FieldType.NUMBER, + label: 'GitHub ID', + icon: 'IconHash', + }, + { + universalIdentifier: CONTRIBUTOR_AVATAR_URL_FIELD_UNIVERSAL_IDENTIFIER, + name: 'avatarUrl', + type: FieldType.LINKS, + label: 'Avatar', + icon: 'IconPhoto', + }, + { + universalIdentifier: CONTRIBUTOR_CONTRIBUTIONS_FIELD_UNIVERSAL_IDENTIFIER, + name: 'contributions', + type: FieldType.NUMBER, + label: 'Contributions', + icon: 'IconTrendingUp', + defaultValue: 0, + }, + ], +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/types/contributor-row.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/types/contributor-row.ts new file mode 100644 index 0000000000..0f4fbce38b --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/types/contributor-row.ts @@ -0,0 +1,10 @@ +import type { LinksFieldValue } from 'src/modules/shared/types'; + +export type ContributorRow = { + id: string; + ghLogin?: string | null; + name?: string | null; + githubId?: number | null; + avatarUrl?: LinksFieldValue | null; + contributions?: number | null; +}; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/utils/is-bot-login.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/utils/is-bot-login.ts new file mode 100644 index 0000000000..e5e031f3b1 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/utils/is-bot-login.ts @@ -0,0 +1,30 @@ +const BOT_LOGIN_SUFFIX = '[bot]'; + +const KNOWN_BOT_LOGINS = new Set([ + 'github-actions', + 'dependabot', + 'dependabot-preview', + 'renovate', + 'renovate-bot', + 'cubic-dev-ai', + 'greptile-apps', + 'sentry', + 'sentry-io', + 'codecov', + 'codecov-commenter', + 'snyk-bot', + 'mergify', + 'allcontributors', + 'imgbot', + 'pre-commit-ci', + 'semantic-release-bot', + 'stale', + 'web-flow', +]); + +export function isBotLogin(login: string | null | undefined): boolean { + if (!login) return false; + const lower = login.toLowerCase(); + if (lower.endsWith(BOT_LOGIN_SUFFIX)) return true; + return KNOWN_BOT_LOGINS.has(lower); +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/contributor/views/all-contributors.view.ts b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/views/all-contributors.view.ts new file mode 100644 index 0000000000..6346c1adad --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/contributor/views/all-contributors.view.ts @@ -0,0 +1,51 @@ +import { + defineView, + ViewKey, + ViewSortDirection, + ViewType, +} from 'twenty-sdk/define'; +import { + CONTRIBUTOR_UNIVERSAL_IDENTIFIER, + CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER, + CONTRIBUTOR_GH_LOGIN_FIELD_UNIVERSAL_IDENTIFIER, + CONTRIBUTOR_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/modules/github/contributor/objects/contributor.object'; + +export const ALL_CONTRIBUTORS_VIEW_UNIVERSAL_IDENTIFIER = + 'e2b51f8e-97ea-49e0-b38d-a69d2191236f'; + +export default defineView({ + universalIdentifier: ALL_CONTRIBUTORS_VIEW_UNIVERSAL_IDENTIFIER, + name: 'All Contributors', + objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER, + type: ViewType.TABLE, + icon: 'IconUsers', + key: ViewKey.INDEX, + position: 0, + fields: [ + { + universalIdentifier: 'b53cb9dd-0e2e-415e-81af-80bedbed89bc', + fieldMetadataUniversalIdentifier: + CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER, + position: 0, + isVisible: true, + size: 200, + }, + { + universalIdentifier: '604a2f1d-ef20-4821-8742-fe3b5b83ffcf', + fieldMetadataUniversalIdentifier: + CONTRIBUTOR_GH_LOGIN_FIELD_UNIVERSAL_IDENTIFIER, + position: 1, + isVisible: true, + size: 150, + }, + ], + sorts: [ + { + universalIdentifier: 'c0c54264-8de9-418b-b91a-cc4a2b94c539', + fieldMetadataUniversalIdentifier: + CONTRIBUTOR_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + direction: ViewSortDirection.DESC, + }, + ], +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/issue/fields/author-on-issue.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/issue/fields/author-on-issue.field.ts new file mode 100644 index 0000000000..9df18c1ba1 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/issue/fields/author-on-issue.field.ts @@ -0,0 +1,25 @@ +import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define'; +import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object'; +import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object'; +import { AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/fields/authored-issues-on-contributor.field'; + +export const ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER = + '5ff7ab7f-bcfa-4e7a-9a33-0860ec692736'; + +export default defineField({ + universalIdentifier: ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'author', + label: 'Author', + icon: 'IconUser', + relationTargetObjectMetadataUniversalIdentifier: + CONTRIBUTOR_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'authorId', + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/issue/fields/project-items-on-issue.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/issue/fields/project-items-on-issue.field.ts new file mode 100644 index 0000000000..b7713df535 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/issue/fields/project-items-on-issue.field.ts @@ -0,0 +1,23 @@ +import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; +import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object'; +import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object'; +import { LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/linked-issue-on-project-item.field'; + +export const PROJECT_ITEMS_ON_ISSUE_FIELD_UNIVERSAL_IDENTIFIER = + '8b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e'; + +export default defineField({ + universalIdentifier: PROJECT_ITEMS_ON_ISSUE_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'projectItems', + label: 'Project Items', + icon: 'IconLayoutKanban', + relationTargetObjectMetadataUniversalIdentifier: + PROJECT_ITEM_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/issue/front-components/fetch-issues.front-component.tsx b/packages/twenty-apps/community/github-connector/src/modules/github/issue/front-components/fetch-issues.front-component.tsx new file mode 100644 index 0000000000..303b035ee9 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/issue/front-components/fetch-issues.front-component.tsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from 'react'; +import { defineFrontComponent } from 'twenty-sdk/define'; +import { + enqueueSnackbar, + objectMetadataItem, + unmountFrontComponent, + updateProgress, +} from 'twenty-sdk/front-component'; +import { callAppRoute } from 'src/modules/shared/call-app-route'; +import { retry } from 'src/modules/shared/retry'; + +type CountResponse = { + totalPages: number; + repos: Array<{ owner: string; repo: string; totalCount: number; pages: number }>; +}; + +type FetchPageResponse = { + issueCount: number; + totalCount: number; + hasMore: boolean; + endCursor: string | null; +}; + +type SyncStatus = 'syncing' | 'done' | 'error'; + +const FetchIssues = () => { + const [status, setStatus] = useState('syncing'); + + useEffect(() => { + const run = async () => { + try { + const counts = (await callAppRoute( + '/github/count-issues', + {}, + )) as CountResponse; + + if (counts.repos.length === 0) { + throw new Error( + 'No repos resolved. Set GITHUB_REPOS in the application variables.', + ); + } + + const totalPages = Math.max(counts.totalPages, 1); + let pagesProcessed = 0; + let totalIssues = 0; + + for (const { owner, repo } of counts.repos) { + let cursor: string | null = null; + let hasMore = true; + + while (hasMore) { + const cursorTag = cursor ? `@${cursor.slice(0, 8)}` : ''; + const data = (await retry( + `fetch-issues ${owner}/${repo}${cursorTag}`, + () => + callAppRoute('/github/fetch-issues', { + owner, + repo, + cursor, + }), + )) as FetchPageResponse; + + totalIssues += data.issueCount; + hasMore = data.hasMore && data.issueCount > 0; + cursor = data.endCursor; + pagesProcessed++; + + updateProgress(Math.min(Math.round((pagesProcessed / totalPages) * 100), 99)); + } + } + + updateProgress(100); + enqueueSnackbar({ + message: `Fetched ${totalIssues} issues`, + variant: 'success', + }); + setStatus('done'); + } catch (err) { + const message = + err instanceof Error ? err.message : 'Failed to fetch issues'; + enqueueSnackbar({ message, variant: 'error' }); + setStatus('error'); + } finally { + unmountFrontComponent(); + } + }; + run(); + }, []); + + if (status === 'syncing') return
Fetching issues...
; + if (status === 'error') return
Failed to fetch issues.
; + return
Done
; +}; + +export default defineFrontComponent({ + universalIdentifier: '9430e4fc-9ecb-428d-9bde-2babeb1f452f', + name: 'Fetch Issues', + description: 'Fetches issues from GitHub repos', + isHeadless: true, + component: FetchIssues, + command: { + universalIdentifier: 'c34f56aa-ff65-43a4-9db2-774945dbcc53', + label: 'Fetch Issues', + icon: 'IconBug', + isPinned: false, + conditionalAvailabilityExpression: + objectMetadataItem.nameSingular === 'issue', + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/issue/graphql/github/count-issues.ts b/packages/twenty-apps/community/github-connector/src/modules/github/issue/graphql/github/count-issues.ts new file mode 100644 index 0000000000..b78777210c --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/issue/graphql/github/count-issues.ts @@ -0,0 +1,26 @@ +import { githubGraphql } from 'src/modules/github/connector/github-client'; + +const QUERY = ` +query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + issues(states: [OPEN, CLOSED]) { + totalCount + } + } +}`; + +type Response = { + repository: { issues: { totalCount: number } } | null; +}; + +export async function countIssues( + owner: string, + name: string, +): Promise { + try { + const data = await githubGraphql(QUERY, { owner, name }); + return data.repository?.issues.totalCount ?? 0; + } catch { + return 0; + } +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/issue/graphql/github/fetch-issues.ts b/packages/twenty-apps/community/github-connector/src/modules/github/issue/graphql/github/fetch-issues.ts new file mode 100644 index 0000000000..2b9df95a78 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/issue/graphql/github/fetch-issues.ts @@ -0,0 +1,66 @@ +import { + EMPTY_PAGE, + type GithubPage, + githubGraphql, +} from 'src/modules/github/connector/github-client'; + +const QUERY = ` +query($owner: String!, $name: String!, $cursor: String) { + repository(owner: $owner, name: $name) { + issues(first: 100, states: [OPEN, CLOSED], orderBy: {field: CREATED_AT, direction: DESC}, after: $cursor) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { + number + title + url + state + createdAt + closedAt + author { login avatarUrl ... on User { databaseId } } + labels(first: 50) { nodes { name } } + } + } + } +}`; + +export type GqlIssue = { + number: number; + title: string; + url: string; + state: 'OPEN' | 'CLOSED'; + createdAt: string; + closedAt: string | null; + author: { login: string; avatarUrl?: string | null; databaseId?: number } | null; + labels: { nodes: Array<{ name: string }> }; +}; + +type Response = { + repository: { issues: GithubPage } | null; +}; + +export async function fetchIssues( + owner: string, + name: string, + cursor: string | null = null, +): Promise<{ + issues: GqlIssue[]; + totalCount: number; + hasMore: boolean; + endCursor: string | null; +}> { + try { + const data = await githubGraphql(QUERY, { owner, name, cursor }); + const conn = data.repository?.issues; + if (!conn) return { issues: [], ...EMPTY_PAGE }; + + return { + issues: conn.nodes, + totalCount: conn.totalCount, + hasMore: conn.pageInfo.hasNextPage, + endCursor: conn.pageInfo.endCursor, + }; + } catch { + return { issues: [], ...EMPTY_PAGE }; + } +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/issue/graphql/mutations/batch-upsert.ts b/packages/twenty-apps/community/github-connector/src/modules/github/issue/graphql/mutations/batch-upsert.ts new file mode 100644 index 0000000000..2a2995e0f2 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/issue/graphql/mutations/batch-upsert.ts @@ -0,0 +1,27 @@ +import { chunkedBatchCreate } from 'src/modules/shared/twenty-client'; +import type { LinksFieldValue } from 'src/modules/shared/types'; +import type { IssueRow } from 'src/modules/github/issue/types/issue-row'; + +export async function batchUpsertIssues( + items: Array<{ + title: string; + githubNumber: number; + uniqueIdentifier: string; + githubUrl: LinksFieldValue; + state: string; + labels: string[]; + githubCreatedAt: string | null; + closedAt: string | null; + repo: string; + authorId: string | null; + }>, +): Promise { + return chunkedBatchCreate('createIssues', items, { + id: true, + githubNumber: true, + uniqueIdentifier: true, + title: true, + state: true, + repo: true, + }) as Promise; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/issue/graphql/queries/find-by-number-and-repo.ts b/packages/twenty-apps/community/github-connector/src/modules/github/issue/graphql/queries/find-by-number-and-repo.ts new file mode 100644 index 0000000000..5f7d7681b9 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/issue/graphql/queries/find-by-number-and-repo.ts @@ -0,0 +1,40 @@ +import { getClient } from 'src/modules/shared/twenty-client'; +import type { IssueRow } from 'src/modules/github/issue/types/issue-row'; + +export async function findIssueByNumberAndRepo( + githubNumber: number, + repo: string, +): Promise { + const client = getClient(); + const res = await client.query({ + issues: { + __args: { + filter: { + and: [ + { githubNumber: { eq: githubNumber } }, + { repo: { eq: repo } }, + ], + }, + first: 1, + }, + edges: { + node: { + id: true, + title: true, + githubNumber: true, + uniqueIdentifier: true, + githubUrl: { primaryLinkLabel: true, primaryLinkUrl: true }, + state: true, + labels: true, + githubCreatedAt: true, + closedAt: true, + repo: true, + authorId: true, + }, + }, + }, + }); + + const node = res.issues?.edges?.[0]?.node; + return (node as IssueRow | undefined) ?? null; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/issue/logic-functions/count-issues.logic-function.ts b/packages/twenty-apps/community/github-connector/src/modules/github/issue/logic-functions/count-issues.logic-function.ts new file mode 100644 index 0000000000..3fe155e805 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/issue/logic-functions/count-issues.logic-function.ts @@ -0,0 +1,24 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; +import { countAcrossRepos } from 'src/modules/github/connector/count-across-repos'; +import { countIssues } from 'src/modules/github/issue/graphql/github/count-issues'; + +type CountIssuesPayload = { + repos?: string[]; +}; + +const handler = async (event: RoutePayload) => + countAcrossRepos(event.body?.repos, countIssues, 'count-issues'); + +export default defineLogicFunction({ + universalIdentifier: 'd8cc32bf-6be9-44fc-920a-8bba510f045f', + name: 'count-issues', + description: + 'Counts total issue pages across configured repos using GraphQL totalCount', + timeoutSeconds: 30, + handler, + httpRouteTriggerSettings: { + path: '/github/count-issues', + httpMethod: 'POST', + isAuthRequired: true, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/issue/logic-functions/fetch-issues.logic-function.ts b/packages/twenty-apps/community/github-connector/src/modules/github/issue/logic-functions/fetch-issues.logic-function.ts new file mode 100644 index 0000000000..3bd014f8b9 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/issue/logic-functions/fetch-issues.logic-function.ts @@ -0,0 +1,93 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; +import { + fetchIssues, + type GqlIssue, +} from 'src/modules/github/issue/graphql/github/fetch-issues'; +import { batchUpsertContributors } from 'src/modules/github/contributor/graphql/mutations/batch-upsert'; +import { batchUpsertIssues } from 'src/modules/github/issue/graphql/mutations/batch-upsert'; +import { dedupeContributors } from 'src/modules/github/contributor/normalizers'; +import { issueFromGraphql } from 'src/modules/github/issue/normalizers'; +import { timed } from 'src/modules/shared/timing'; +import { isFixtureAllowed } from 'src/modules/shared/fixtures'; + +export type FetchIssuesFixturePage = { + issues: GqlIssue[]; + totalCount: number; + hasMore: boolean; + endCursor: string | null; +}; + +type FetchIssuesPayload = { + owner: string; + repo: string; + cursor?: string | null; + fixturePage?: FetchIssuesFixturePage; +}; + +const handler = async (event: RoutePayload) => { + const handlerStart = Date.now(); + const { owner, repo, cursor = null, fixturePage } = event.body ?? {}; + if (!owner || !repo) { + return { error: 'owner and repo are required' }; + } + + const tag = `${owner}/${repo}${cursor ? `@${cursor.slice(0, 8)}` : ''}`; + console.log(`[fetch-issues] start ${tag}${fixturePage ? ' (fixture)' : ''}`); + + const result = + fixturePage && isFixtureAllowed() + ? fixturePage + : await timed(`fetch-issues:github ${tag}`, () => + fetchIssues(owner, repo, cursor), + ); + const { issues, totalCount, hasMore, endCursor } = result; + + if (issues.length === 0) { + console.log(`[fetch-issues] empty page for ${tag}`); + return { issueCount: 0, totalCount, hasMore: false, endCursor: null }; + } + + const fullRepo = `${owner}/${repo}`; + + const contributorInputs = dedupeContributors(issues.map((i) => i.author)); + const contributors = await timed( + `fetch-issues:upsertContributors ${tag} (${contributorInputs.length})`, + () => batchUpsertContributors(contributorInputs), + ); + + const idByLogin = new Map(); + for (const c of contributors) { + if (c.ghLogin) idByLogin.set(c.ghLogin, c.id); + } + + const issueData = issues.map((issue) => ({ + ...issueFromGraphql(issue, fullRepo), + authorId: issue.author ? (idByLogin.get(issue.author.login) ?? null) : null, + })); + + await timed( + `fetch-issues:upsertIssues ${tag} (${issueData.length})`, + () => batchUpsertIssues(issueData), + ); + + const totalMs = Date.now() - handlerStart; + console.log( + `[fetch-issues] done ${tag} in ${totalMs}ms (issues=${issues.length}, hasMore=${hasMore})`, + ); + + return { issueCount: issues.length, totalCount, hasMore, endCursor }; +}; + +export default defineLogicFunction({ + universalIdentifier: 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e', + name: 'fetch-issues', + description: + 'Fetches one page of issues via GitHub GraphQL API and batch upserts them', + timeoutSeconds: 300, + handler, + httpRouteTriggerSettings: { + path: '/github/fetch-issues', + httpMethod: 'POST', + isAuthRequired: true, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/issue/navigation-menu-items/issues.navigation-menu-item.ts b/packages/twenty-apps/community/github-connector/src/modules/github/issue/navigation-menu-items/issues.navigation-menu-item.ts new file mode 100644 index 0000000000..ed4c89df6c --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/issue/navigation-menu-items/issues.navigation-menu-item.ts @@ -0,0 +1,11 @@ +import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define'; +import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object'; +import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item'; + +export default defineNavigationMenuItem({ + universalIdentifier: '7c4f8e1a-3b9d-4f2e-8a6c-1d5b7e9f3a2c', + position: 1, + type: NavigationMenuItemType.OBJECT, + targetObjectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER, + folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/issue/normalizers.ts b/packages/twenty-apps/community/github-connector/src/modules/github/issue/normalizers.ts new file mode 100644 index 0000000000..b4045d9b16 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/issue/normalizers.ts @@ -0,0 +1,63 @@ +import type { LinksFieldValue } from 'src/modules/shared/types'; +import { toLinksField } from 'src/modules/shared/types'; +import type { GitHubIssue } from 'src/modules/github/issue/types/github-issue'; +import type { GqlIssue } from 'src/modules/github/issue/graphql/github/fetch-issues'; + +export type IssueState = 'OPEN' | 'CLOSED'; + +export type IssueCanonical = { + title: string; + githubNumber: number; + uniqueIdentifier: string; + githubUrl: LinksFieldValue; + state: IssueState; + labels: string[]; + githubCreatedAt: string | null; + closedAt: string | null; + repo: string; +}; + +export function deriveIssueState(state: string): IssueState { + return state.toUpperCase() === 'CLOSED' ? 'CLOSED' : 'OPEN'; +} + +export function buildIssueUniqueIdentifier( + repoFullName: string, + number: number, +): string { + return `${repoFullName}#${number}`; +} + +export function issueFromWebhook( + issue: GitHubIssue, + repoFullName: string, +): IssueCanonical { + return { + title: issue.title, + githubNumber: issue.number, + uniqueIdentifier: buildIssueUniqueIdentifier(repoFullName, issue.number), + githubUrl: toLinksField(issue.html_url, issue.title), + state: deriveIssueState(issue.state), + labels: issue.labels.map((l) => l.name), + githubCreatedAt: issue.created_at, + closedAt: issue.closed_at, + repo: repoFullName, + }; +} + +export function issueFromGraphql( + issue: GqlIssue, + repoFullName: string, +): IssueCanonical { + return { + title: issue.title, + githubNumber: issue.number, + uniqueIdentifier: buildIssueUniqueIdentifier(repoFullName, issue.number), + githubUrl: toLinksField(issue.url, issue.title), + state: deriveIssueState(issue.state), + labels: issue.labels.nodes.map((l) => l.name), + githubCreatedAt: issue.createdAt, + closedAt: issue.closedAt, + repo: repoFullName, + }; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/issue/objects/issue.object.ts b/packages/twenty-apps/community/github-connector/src/modules/github/issue/objects/issue.object.ts new file mode 100644 index 0000000000..64fa230202 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/issue/objects/issue.object.ts @@ -0,0 +1,134 @@ +import { defineObject, FieldType } from 'twenty-sdk/define'; + +export const ISSUE_UNIVERSAL_IDENTIFIER = + '3a4b5c6d-7e8f-4a9b-8c0d-1e2f3a4b5c6d'; + +export const ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER = + '4b5c6d7e-8f9a-4b0c-9d1e-2f3a4b5c6d7e'; + +export const ISSUE_GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER = + '5c6d7e8f-9a0b-4c1d-ae2f-3a4b5c6d7e8f'; + +export const ISSUE_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER = + '6d7e8f9a-0b1c-4d2e-bf3a-4b5c6d7e8f9a'; + +export const ISSUE_LABELS_FIELD_UNIVERSAL_IDENTIFIER = + 'dfd35d92-f4a3-44c5-b758-494c0882fcc8'; + +export const ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER = + '76364df9-6f5c-477d-bd99-97b6738df3bf'; + +export const ISSUE_CLOSED_AT_FIELD_UNIVERSAL_IDENTIFIER = + '48ac555b-73b7-4ce9-a264-251c8e06b4e9'; + +export const ISSUE_REPO_FIELD_UNIVERSAL_IDENTIFIER = + '79c41cdc-912a-4e4d-b9de-3f2421bcb2fb'; + +export const ISSUE_UNIQUE_IDENTIFIER_FIELD_UNIVERSAL_IDENTIFIER = + '0ca04207-4d74-4298-8238-59cdf685862b'; + +enum IssueState { + OPEN = 'OPEN', + CLOSED = 'CLOSED', +} + +export const ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER = + '1c2d3e4f-5a6b-4c7d-a48e-9f0a1b2c3d4e'; + +export const ISSUE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER = + '440e9ba8-20d1-5843-91ef-3b7b699a59f4'; + +export default defineObject({ + universalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER, + nameSingular: 'issue', + namePlural: 'issues', + labelSingular: 'Issue', + labelPlural: 'Issues', + icon: 'IconBug', + labelIdentifierFieldMetadataUniversalIdentifier: + ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER, + fields: [ + { + universalIdentifier: ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER, + name: 'title', + type: FieldType.TEXT, + label: 'Title', + icon: 'IconTextCaption', + }, + { + universalIdentifier: ISSUE_GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER, + name: 'githubNumber', + type: FieldType.NUMBER, + label: 'Issue Number', + icon: 'IconHash', + }, + { + universalIdentifier: ISSUE_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER, + name: 'githubUrl', + type: FieldType.LINKS, + label: 'GitHub URL', + icon: 'IconLink', + }, + { + universalIdentifier: ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER, + name: 'state', + type: FieldType.SELECT, + label: 'State', + icon: 'IconCircleDot', + options: [ + { + value: IssueState.OPEN, + label: 'Open', + position: 0, + color: 'green', + }, + { + value: IssueState.CLOSED, + label: 'Closed', + position: 1, + color: 'red', + }, + ], + }, + { + universalIdentifier: ISSUE_LABELS_FIELD_UNIVERSAL_IDENTIFIER, + name: 'labels', + type: FieldType.ARRAY, + label: 'Labels', + icon: 'IconTag', + }, + { + universalIdentifier: ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + name: 'githubCreatedAt', + type: FieldType.DATE_TIME, + label: 'GitHub Created At', + icon: 'IconCalendarPlus', + isNullable: true, + defaultValue: null, + }, + { + universalIdentifier: ISSUE_CLOSED_AT_FIELD_UNIVERSAL_IDENTIFIER, + name: 'closedAt', + type: FieldType.DATE_TIME, + label: 'Closed At', + icon: 'IconCalendarOff', + isNullable: true, + defaultValue: null, + }, + { + universalIdentifier: ISSUE_REPO_FIELD_UNIVERSAL_IDENTIFIER, + name: 'repo', + type: FieldType.TEXT, + label: 'Repository', + icon: 'IconFolder', + }, + { + universalIdentifier: ISSUE_UNIQUE_IDENTIFIER_FIELD_UNIVERSAL_IDENTIFIER, + name: 'uniqueIdentifier', + type: FieldType.TEXT, + label: 'Unique Identifier', + icon: 'IconFingerprint', + isUnique: true, + }, + ], +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/issue/types/github-issue.ts b/packages/twenty-apps/community/github-connector/src/modules/github/issue/types/github-issue.ts new file mode 100644 index 0000000000..57619c0cbb --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/issue/types/github-issue.ts @@ -0,0 +1,13 @@ +import type { GitHubUser } from 'src/modules/github/connector/github-user'; + +export type GitHubIssue = { + number: number; + title: string; + html_url: string; + state: 'open' | 'closed'; + labels: Array<{ name: string }>; + created_at: string; + closed_at: string | null; + user: GitHubUser; + pull_request?: unknown; +}; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/issue/types/issue-row.ts b/packages/twenty-apps/community/github-connector/src/modules/github/issue/types/issue-row.ts new file mode 100644 index 0000000000..32df42ca55 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/issue/types/issue-row.ts @@ -0,0 +1,15 @@ +import type { LinksFieldValue } from 'src/modules/shared/types'; + +export type IssueRow = { + id: string; + title?: string | null; + githubNumber?: number | null; + uniqueIdentifier?: string | null; + githubUrl?: LinksFieldValue | null; + state?: string | null; + labels?: string[] | null; + githubCreatedAt?: string | null; + closedAt?: string | null; + repo?: string | null; + authorId?: string | null; +}; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/issue/views/all-issues.view.ts b/packages/twenty-apps/community/github-connector/src/modules/github/issue/views/all-issues.view.ts new file mode 100644 index 0000000000..ea429bda16 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/issue/views/all-issues.view.ts @@ -0,0 +1,87 @@ +import { + defineView, + ViewKey, + ViewSortDirection, + ViewType, +} from 'twenty-sdk/define'; +import { + ISSUE_UNIVERSAL_IDENTIFIER, + ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER, + ISSUE_GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER, + ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER, + ISSUE_LABELS_FIELD_UNIVERSAL_IDENTIFIER, + ISSUE_REPO_FIELD_UNIVERSAL_IDENTIFIER, + ISSUE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/modules/github/issue/objects/issue.object'; +import { ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/fields/author-on-issue.field'; + +export const ALL_ISSUES_VIEW_UNIVERSAL_IDENTIFIER = + 'b2c3d4e5-1111-4f6a-b7c8-d9e0f1a2b3c4'; + +export default defineView({ + universalIdentifier: ALL_ISSUES_VIEW_UNIVERSAL_IDENTIFIER, + name: 'All Issues', + objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER, + type: ViewType.TABLE, + icon: 'IconBug', + key: ViewKey.INDEX, + position: 0, + fields: [ + { + universalIdentifier: '5b115bd1-489c-44e5-ac1b-7d508e06f165', + fieldMetadataUniversalIdentifier: + ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER, + position: 0, + isVisible: true, + size: 300, + }, + { + universalIdentifier: '77ac1882-3a13-43e3-822e-c9f5f7d83a67', + fieldMetadataUniversalIdentifier: + ISSUE_GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER, + position: 1, + isVisible: true, + size: 100, + }, + { + universalIdentifier: '4f0d17f2-c3a6-4431-a928-3812273592e2', + fieldMetadataUniversalIdentifier: + ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER, + position: 2, + isVisible: true, + size: 100, + }, + { + universalIdentifier: '01673163-ccfa-4370-a1c3-48c33b6c4aa3', + fieldMetadataUniversalIdentifier: + ISSUE_LABELS_FIELD_UNIVERSAL_IDENTIFIER, + position: 3, + isVisible: true, + size: 200, + }, + { + universalIdentifier: '6d932c97-8f92-4d93-968f-29ca34b27ac0', + fieldMetadataUniversalIdentifier: + ISSUE_REPO_FIELD_UNIVERSAL_IDENTIFIER, + position: 4, + isVisible: true, + size: 180, + }, + { + universalIdentifier: 'dc293fd6-747c-4291-9426-5d7b53234936', + fieldMetadataUniversalIdentifier: + ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER, + position: 5, + isVisible: true, + size: 150, + }, + ], + sorts: [ + { + universalIdentifier: '8e972333-b919-4c1b-ad7b-4fab8f018253', + fieldMetadataUniversalIdentifier: + ISSUE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + direction: ViewSortDirection.DESC, + }, + ], +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/logic-functions/handle-webhook.logic-function.ts b/packages/twenty-apps/community/github-connector/src/modules/github/logic-functions/handle-webhook.logic-function.ts new file mode 100644 index 0000000000..2fd99f51df --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/logic-functions/handle-webhook.logic-function.ts @@ -0,0 +1,260 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; +import type { GitHubWebhookPayload } from 'src/modules/github/connector/webhook-payload'; +import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item'; +import { fetchProjectItemByNodeId } from 'src/modules/github/project-item/graphql/github/fetch-project-item-by-node-id'; +import { + getRawBodyForSignature, + verifyGitHubSignature, +} from 'src/modules/github/connector/webhook-signature'; +import { batchUpsertContributors } from 'src/modules/github/contributor/graphql/mutations/batch-upsert'; +import { batchUpsertPullRequests } from 'src/modules/github/pull-request/graphql/mutations/batch-upsert'; +import { batchUpsertReviewEvents } from 'src/modules/github/pull-request-review-event/graphql/mutations/batch-upsert'; +import { batchUpsertConsolidatedReviews } from 'src/modules/github/pull-request-review/graphql/mutations/batch-upsert'; +import { findReviewEventsForPair } from 'src/modules/github/pull-request-review-event/graphql/queries/find-events-for-pair'; +import { buildConsolidatedRow } from 'src/modules/github/pull-request-review/utils/build-consolidated-row'; +import type { ReviewEventState } from 'src/modules/github/pull-request-review/utils/consolidate-reviews'; +import { batchUpsertIssues } from 'src/modules/github/issue/graphql/mutations/batch-upsert'; +import { batchUpsertProjectItems } from 'src/modules/github/project-item/graphql/mutations/batch-upsert'; +import { dedupeContributors } from 'src/modules/github/contributor/normalizers'; +import { pullRequestFromWebhook } from 'src/modules/github/pull-request/normalizers'; +import { reviewEventFromWebhook } from 'src/modules/github/pull-request-review-event/normalizers'; +import { issueFromWebhook } from 'src/modules/github/issue/normalizers'; +import { projectItemFromGraphql } from 'src/modules/github/project-item/normalizers'; + +async function upsertContributorsByLogin( + users: Array<{ login: string; id?: number } | null | undefined>, +): Promise> { + const inputs = dedupeContributors(users); + if (inputs.length === 0) return new Map(); + const rows = await batchUpsertContributors(inputs); + const map = new Map(); + for (const r of rows) { + if (r.ghLogin) map.set(r.ghLogin, r.id); + } + return map; +} + +async function handlePullRequestEvent(payload: GitHubWebhookPayload) { + const pr = payload.pull_request; + const repository = payload.repository; + if (!pr || !repository) { + return { skipped: true, reason: 'missing pull_request or repository' }; + } + + const idByLogin = await upsertContributorsByLogin([pr.user, pr.merged_by]); + const authorId = idByLogin.get(pr.user.login) ?? null; + const mergerId = pr.merged_by ? (idByLogin.get(pr.merged_by.login) ?? null) : null; + + const canonical = pullRequestFromWebhook(pr, repository.full_name); + + const [record] = await batchUpsertPullRequests([ + { ...canonical, authorId, mergerId }, + ]); + + return { + processed: true, + pullRequestId: record?.id, + action: payload.action, + state: canonical.state, + }; +} + +async function handlePullRequestReviewEvent(payload: GitHubWebhookPayload) { + const review = payload.review; + const pr = payload.pull_request; + const repository = payload.repository; + if (!review || !pr || !repository) { + return { + skipped: true, + reason: 'missing review, pull_request, or repository', + }; + } + + const idByLogin = await upsertContributorsByLogin([ + pr.user, + pr.merged_by, + review.user, + ]); + + const prCanonical = pullRequestFromWebhook(pr, repository.full_name); + const [prRecord] = await batchUpsertPullRequests([ + { + ...prCanonical, + authorId: idByLogin.get(pr.user.login) ?? null, + mergerId: pr.merged_by ? (idByLogin.get(pr.merged_by.login) ?? null) : null, + }, + ]); + + const reviewCanonical = reviewEventFromWebhook(review); + const reviewerId = idByLogin.get(review.user.login) ?? null; + const pullRequestId = prRecord?.id ?? ''; + + const [eventRecord] = await batchUpsertReviewEvents([ + { + ...reviewCanonical, + reviewerId, + pullRequestId, + }, + ]); + + let consolidatedId: string | undefined; + if (pullRequestId) { + const events = await findReviewEventsForPair(pullRequestId, reviewerId); + if (events.length > 0) { + const consolidatedRow = buildConsolidatedRow({ + pullRequestId, + reviewerId, + prNumber: pr.number ?? null, + reviewerLogin: review.user.login, + events: events.map((e) => ({ + state: e.state as ReviewEventState, + submittedAt: e.submittedAt, + })), + }); + const [consolidatedRecord] = await batchUpsertConsolidatedReviews([ + consolidatedRow, + ]); + consolidatedId = consolidatedRecord?.id; + + if (consolidatedId && eventRecord?.id) { + await batchUpsertReviewEvents([ + { + ...reviewCanonical, + reviewerId, + pullRequestId, + reviewId: consolidatedId, + }, + ]); + } + } + } + + return { + processed: true, + reviewEventId: eventRecord?.id, + reviewId: consolidatedId, + pullRequestId: prRecord?.id, + action: payload.action, + }; +} + +async function handleIssueEvent(payload: GitHubWebhookPayload) { + const issue = payload.issue; + const repository = payload.repository; + if (!issue || !repository) { + return { skipped: true, reason: 'missing issue or repository' }; + } + + const idByLogin = await upsertContributorsByLogin([issue.user]); + const canonical = issueFromWebhook(issue, repository.full_name); + + const [record] = await batchUpsertIssues([ + { ...canonical, authorId: idByLogin.get(issue.user.login) ?? null }, + ]); + + return { + processed: true, + issueId: record?.id, + action: payload.action, + }; +} + +async function handleProjectV2ItemEvent( + payload: GitHubWebhookPayload, + testProjectItem?: ProjectV2Item, +) { + const item = payload.projects_v2_item; + if (!item?.node_id) { + return { skipped: true, reason: 'missing projects_v2_item.node_id' }; + } + + if (payload.action === 'deleted') { + console.log( + `[handle-webhook] projects_v2_item delete skipped (nodeId=${item.node_id})`, + ); + return { skipped: true, reason: 'delete not implemented' }; + } + + const node = testProjectItem ?? (await fetchProjectItemByNodeId(item.node_id)); + if (!node) { + return { skipped: true, reason: 'project item not found on GitHub' }; + } + + const canonical = await projectItemFromGraphql(node); + const [record] = await batchUpsertProjectItems([canonical]); + + return { + processed: true, + projectItemId: record?.id, + action: payload.action, + }; +} + +const handler = async ( + event: RoutePayload< + GitHubWebhookPayload & { __test_projectItem?: ProjectV2Item } + >, +) => { + const secret = process.env.GITHUB_WEBHOOK_SECRET; + if (secret) { + const signatureHeader = + event.headers?.['x-hub-signature-256'] ?? + event.headers?.['X-Hub-Signature-256']; + const rawBody = getRawBodyForSignature(event); + const verification = verifyGitHubSignature({ + rawBody, + signatureHeader, + secret, + }); + if (!verification.ok) { + const delivery = + event.headers?.['x-github-delivery'] ?? + event.headers?.['X-GitHub-Delivery']; + console.warn( + `[handle-webhook] signature verification failed (${verification.reason}) delivery=${delivery ?? 'unknown'}`, + ); + return { error: 'invalid signature', reason: verification.reason }; + } + } else { + console.warn( + '[handle-webhook] GITHUB_WEBHOOK_SECRET is not set; skipping signature verification (insecure)', + ); + } + + const payload = event.body; + if (!payload) return { error: 'empty body' }; + + if (payload.pull_request && payload.review) { + return handlePullRequestReviewEvent(payload); + } + if (payload.pull_request) { + return handlePullRequestEvent(payload); + } + if (payload.issue) { + return handleIssueEvent(payload); + } + if (payload.projects_v2_item) { + return handleProjectV2ItemEvent(payload, payload.__test_projectItem); + } + + return { skipped: true, reason: 'unhandled event' }; +}; + +export default defineLogicFunction({ + universalIdentifier: '22b199b3-2851-4a4f-99fd-4e79c188fe7d', + name: 'handle-github-webhook', + description: + 'Receives GitHub webhook events for PRs, reviews, issues, and project items; idempotent upserts via shared batch helpers', + timeoutSeconds: 30, + handler, + httpRouteTriggerSettings: { + path: '/github/webhook', + httpMethod: 'POST', + isAuthRequired: false, + forwardedRequestHeaders: [ + 'x-hub-signature-256', + 'x-github-event', + 'x-github-delivery', + ], + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/navigation-menu-items/contributors.navigation-menu-item.ts b/packages/twenty-apps/community/github-connector/src/modules/github/navigation-menu-items/contributors.navigation-menu-item.ts new file mode 100644 index 0000000000..437638a82c --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/navigation-menu-items/contributors.navigation-menu-item.ts @@ -0,0 +1,14 @@ +import { + defineNavigationMenuItem, + NavigationMenuItemType, +} from 'twenty-sdk/define'; +import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object'; +import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item'; + +export default defineNavigationMenuItem({ + universalIdentifier: 'd1c2b3a4-9e8f-4d3c-b2a1-0f9e8d7c6b5a', + position: 3, + type: NavigationMenuItemType.OBJECT, + targetObjectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER, + folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/navigation-menu-items/github-dashboard.navigation-menu-item.ts b/packages/twenty-apps/community/github-connector/src/modules/github/navigation-menu-items/github-dashboard.navigation-menu-item.ts new file mode 100644 index 0000000000..9a2db40645 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/navigation-menu-items/github-dashboard.navigation-menu-item.ts @@ -0,0 +1,17 @@ +import { + defineNavigationMenuItem, + NavigationMenuItemType, +} from 'twenty-sdk/define'; +import { GITHUB_DASHBOARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/page-layouts/github-dashboard.page-layout'; +import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item'; + +export default defineNavigationMenuItem({ + universalIdentifier: 'e8f9a1b2-c3d4-4567-89ab-cdef01234567', + name: 'GitHub Dashboard', + icon: 'IconBrandGithub', + position: 6, + type: NavigationMenuItemType.PAGE_LAYOUT, + pageLayoutUniversalIdentifier: + GITHUB_DASHBOARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER, + folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/navigation-menu-items/github-folder.navigation-menu-item.ts b/packages/twenty-apps/community/github-connector/src/modules/github/navigation-menu-items/github-folder.navigation-menu-item.ts new file mode 100644 index 0000000000..16658a3eec --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/navigation-menu-items/github-folder.navigation-menu-item.ts @@ -0,0 +1,13 @@ +import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define'; + +export const GITHUB_FOLDER_UNIVERSAL_IDENTIFIER = + 'a4f7d8b1-2c93-4e6f-8b1a-9d3e5c7f2a48'; + +export default defineNavigationMenuItem({ + universalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER, + name: 'GitHub', + icon: 'IconBrandGithub', + color: 'gray', + position: 0, + type: NavigationMenuItemType.FOLDER, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/page-layouts/github-dashboard.page-layout.ts b/packages/twenty-apps/community/github-connector/src/modules/github/page-layouts/github-dashboard.page-layout.ts new file mode 100644 index 0000000000..70831ca6e5 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/page-layouts/github-dashboard.page-layout.ts @@ -0,0 +1,281 @@ +import { + AggregateOperations, + definePageLayout, + ObjectRecordGroupByDateGranularity, + PageLayoutTabLayoutMode, +} from 'twenty-sdk/define'; +import { TOP_PR_AUTHORS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/front-components/top-pr-authors.front-component'; +import { TOP_REVIEWERS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/front-components/top-reviewers.front-component'; +import { + ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER, + ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER, + ISSUE_UNIVERSAL_IDENTIFIER, +} from 'src/modules/github/issue/objects/issue.object'; +import { + MERGED_AT_FIELD_UNIVERSAL_IDENTIFIER, + PR_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + PR_STATE_FIELD_UNIVERSAL_IDENTIFIER, + PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER, + PULL_REQUEST_UNIVERSAL_IDENTIFIER, +} from 'src/modules/github/pull-request/objects/pull-request.object'; +import { + PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER, + REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER, + REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/modules/github/pull-request-review/objects/pull-request-review.object'; + +export const GITHUB_DASHBOARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER = + 'a8d2f1c4-7b69-4e3a-8c5d-9f6b1a3e7c2d'; + +const THIS_WEEK_RELATIVE = 'THIS_1_WEEK;;UTC;;SUNDAY;;'; + +const COMMON = { + timezone: 'UTC', + firstDayOfTheWeek: 0, +}; + +const COL_1 = { column: 0, columnSpan: 3 } as const; +const COL_2 = { column: 3, columnSpan: 5 } as const; +const COL_3 = { column: 8, columnSpan: 4 } as const; + +export default definePageLayout({ + universalIdentifier: GITHUB_DASHBOARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER, + name: 'GitHub Dashboard', + type: 'STANDALONE_PAGE', + tabs: [ + { + universalIdentifier: 'b3e9c2a1-4d68-4f7b-9c25-1e3a5b7c9d11', + title: 'GitHub Dashboard', + position: 0, + icon: 'IconBrandGithub', + layoutMode: PageLayoutTabLayoutMode.GRID, + widgets: [ + { + universalIdentifier: 'f1a2b3c4-d5e6-4789-a0b1-c2d3e4f5a6b1', + title: 'PRs Merged This Week', + type: 'GRAPH', + objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER, + gridPosition: { row: 0, ...COL_1, rowSpan: 3 }, + configuration: { + configurationType: 'AGGREGATE_CHART', + aggregateFieldMetadataUniversalIdentifier: + PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER, + aggregateOperation: AggregateOperations.COUNT, + label: 'PRs Merged', + color: 'green', + displayDataLabel: false, + ...COMMON, + filter: { + recordFilters: [ + { + fieldMetadataUniversalIdentifier: + MERGED_AT_FIELD_UNIVERSAL_IDENTIFIER, + operand: 'IS_RELATIVE', + value: THIS_WEEK_RELATIVE, + type: 'DATE_TIME', + }, + ], + }, + }, + }, + { + universalIdentifier: 'f1a2b3c4-d5e6-4789-a0b1-c2d3e4f5a6b2', + title: 'PR Reviews This Week', + type: 'GRAPH', + objectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER, + gridPosition: { row: 3, ...COL_1, rowSpan: 3 }, + configuration: { + configurationType: 'AGGREGATE_CHART', + aggregateFieldMetadataUniversalIdentifier: + REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER, + aggregateOperation: AggregateOperations.COUNT, + label: 'Reviews', + color: 'blue', + displayDataLabel: false, + ...COMMON, + filter: { + recordFilters: [ + { + fieldMetadataUniversalIdentifier: + REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER, + operand: 'IS_RELATIVE', + value: THIS_WEEK_RELATIVE, + type: 'DATE_TIME', + }, + ], + }, + }, + }, + { + universalIdentifier: '8d4e9d09-29b0-4925-8fcc-aedf5bcecb37', + title: 'PRs Opened This Week', + type: 'GRAPH', + objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER, + gridPosition: { row: 6, ...COL_1, rowSpan: 3 }, + configuration: { + configurationType: 'AGGREGATE_CHART', + aggregateFieldMetadataUniversalIdentifier: + PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER, + aggregateOperation: AggregateOperations.COUNT, + label: 'PRs Opened', + color: 'purple', + displayDataLabel: false, + ...COMMON, + filter: { + recordFilters: [ + { + fieldMetadataUniversalIdentifier: + PR_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + operand: 'IS_RELATIVE', + value: THIS_WEEK_RELATIVE, + type: 'DATE_TIME', + }, + ], + }, + }, + }, + { + universalIdentifier: '3c321867-0227-4535-81db-c64a6f673d86', + title: 'Issues Opened This Week', + type: 'GRAPH', + objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER, + gridPosition: { row: 9, ...COL_1, rowSpan: 3 }, + configuration: { + configurationType: 'AGGREGATE_CHART', + aggregateFieldMetadataUniversalIdentifier: + ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER, + aggregateOperation: AggregateOperations.COUNT, + label: 'Issues Opened', + color: 'orange', + displayDataLabel: false, + ...COMMON, + filter: { + recordFilters: [ + { + fieldMetadataUniversalIdentifier: + ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + operand: 'IS_RELATIVE', + value: THIS_WEEK_RELATIVE, + type: 'DATE_TIME', + }, + ], + }, + }, + }, + { + universalIdentifier: '704209be-9174-43a8-ae51-6b11fff20e22', + title: 'PRs by State', + type: 'GRAPH', + objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER, + gridPosition: { row: 12, ...COL_1, rowSpan: 6 }, + configuration: { + configurationType: 'PIE_CHART', + aggregateFieldMetadataUniversalIdentifier: + PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER, + aggregateOperation: AggregateOperations.COUNT, + groupByFieldMetadataUniversalIdentifier: + PR_STATE_FIELD_UNIVERSAL_IDENTIFIER, + displayLegend: true, + showCenterMetric: true, + hideEmptyCategory: true, + ...COMMON, + }, + }, + { + universalIdentifier: 'c1f2a3b4-d5e6-4789-a0b1-c2d3e4f5a6b7', + title: 'PRs Merged per Week', + type: 'GRAPH', + objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER, + gridPosition: { row: 0, ...COL_2, rowSpan: 6 }, + configuration: { + configurationType: 'BAR_CHART', + aggregateFieldMetadataUniversalIdentifier: + PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER, + aggregateOperation: AggregateOperations.COUNT, + primaryAxisGroupByFieldMetadataUniversalIdentifier: + MERGED_AT_FIELD_UNIVERSAL_IDENTIFIER, + primaryAxisDateGranularity: + ObjectRecordGroupByDateGranularity.WEEK, + displayDataLabel: false, + displayLegend: false, + color: 'green', + layout: 'VERTICAL', + omitNullValues: true, + ...COMMON, + }, + }, + { + universalIdentifier: 'd2e3f4a5-b6c7-4890-a1b2-c3d4e5f6a7b8', + title: 'PR Reviews per Week', + type: 'GRAPH', + objectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER, + gridPosition: { row: 6, ...COL_2, rowSpan: 6 }, + configuration: { + configurationType: 'BAR_CHART', + aggregateFieldMetadataUniversalIdentifier: + REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER, + aggregateOperation: AggregateOperations.COUNT, + primaryAxisGroupByFieldMetadataUniversalIdentifier: + REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER, + primaryAxisDateGranularity: + ObjectRecordGroupByDateGranularity.WEEK, + displayDataLabel: false, + displayLegend: false, + color: 'blue', + layout: 'VERTICAL', + omitNullValues: true, + ...COMMON, + }, + }, + { + universalIdentifier: '6d03cbd1-ee03-4bf4-a3ac-143d03fa7fd9', + title: 'Issues per Week', + type: 'GRAPH', + objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER, + gridPosition: { row: 12, ...COL_2, rowSpan: 6 }, + configuration: { + configurationType: 'BAR_CHART', + aggregateFieldMetadataUniversalIdentifier: + ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER, + aggregateOperation: AggregateOperations.COUNT, + primaryAxisGroupByFieldMetadataUniversalIdentifier: + ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + primaryAxisDateGranularity: + ObjectRecordGroupByDateGranularity.WEEK, + secondaryAxisGroupByFieldMetadataUniversalIdentifier: + ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER, + displayDataLabel: false, + displayLegend: true, + color: 'orange', + layout: 'VERTICAL', + omitNullValues: true, + ...COMMON, + }, + }, + { + universalIdentifier: '7b3e9c4a-1d52-4f8b-ac76-3e5b8d2f1a9c', + title: 'Top PR Authors', + type: 'FRONT_COMPONENT', + gridPosition: { row: 0, ...COL_3, rowSpan: 9 }, + configuration: { + configurationType: 'FRONT_COMPONENT', + frontComponentUniversalIdentifier: + TOP_PR_AUTHORS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + }, + }, + { + universalIdentifier: '5e4a8c1d-7f93-4b2e-9d6c-3a8f1b5e7d4c', + title: 'Top Reviewers', + type: 'FRONT_COMPONENT', + gridPosition: { row: 9, ...COL_3, rowSpan: 9 }, + configuration: { + configurationType: 'FRONT_COMPONENT', + frontComponentUniversalIdentifier: + TOP_REVIEWERS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + }, + }, + ], + }, + ], +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/fields/linked-issue-on-project-item.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/fields/linked-issue-on-project-item.field.ts new file mode 100644 index 0000000000..f03e3ec74b --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/fields/linked-issue-on-project-item.field.ts @@ -0,0 +1,24 @@ +import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define'; +import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object'; +import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object'; +import { PROJECT_ITEMS_ON_ISSUE_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/fields/project-items-on-issue.field'; + +export const LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER = + '7a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d'; + +export default defineField({ + universalIdentifier: LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'linkedIssue', + label: 'Linked Issue', + icon: 'IconBug', + relationTargetObjectMetadataUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + PROJECT_ITEMS_ON_ISSUE_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'linkedIssueId', + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/fields/linked-pr-on-project-item.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/fields/linked-pr-on-project-item.field.ts new file mode 100644 index 0000000000..85fcea65c8 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/fields/linked-pr-on-project-item.field.ts @@ -0,0 +1,25 @@ +import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define'; +import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object'; +import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object'; +import { PROJECT_ITEMS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/project-items-on-pull-request.field'; + +export const LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER = + '9c4d5e6f-7a8b-4c9d-ae0f-1a2b3c4d5e6f'; + +export default defineField({ + universalIdentifier: LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'linkedPullRequest', + label: 'Linked PR', + icon: 'IconGitPullRequest', + relationTargetObjectMetadataUniversalIdentifier: + PULL_REQUEST_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + PROJECT_ITEMS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'linkedPullRequestId', + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/fields/main-assignee-on-project-item.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/fields/main-assignee-on-project-item.field.ts new file mode 100644 index 0000000000..03ddeb0ef4 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/fields/main-assignee-on-project-item.field.ts @@ -0,0 +1,25 @@ +import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define'; +import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object'; +import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object'; +import { ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/fields/assigned-project-items-on-contributor.field'; + +export const MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER = + '5bc9d7b3-ca5a-4006-bd74-0420d1f3df85'; + +export default defineField({ + universalIdentifier: MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'mainAssignee', + label: 'Main Assignee', + icon: 'IconUser', + relationTargetObjectMetadataUniversalIdentifier: + CONTRIBUTOR_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'mainAssigneeId', + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/front-components/fetch-project-items.front-component.tsx b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/front-components/fetch-project-items.front-component.tsx new file mode 100644 index 0000000000..407607747d --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/front-components/fetch-project-items.front-component.tsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from 'react'; +import { defineFrontComponent } from 'twenty-sdk/define'; +import { + enqueueSnackbar, + objectMetadataItem, + unmountFrontComponent, + updateProgress, +} from 'twenty-sdk/front-component'; +import { callAppRoute } from 'src/modules/shared/call-app-route'; + +type CountResponse = { + totalPages: number; + projects: Array<{ + owner: string; + number: number; + totalCount: number; + pages: number; + }>; +}; + +type FetchPageResponse = { + itemCount: number; + totalCount: number; + hasMore: boolean; + endCursor: string | null; +}; + +type SyncStatus = 'syncing' | 'done' | 'error'; + +const FetchProjectItems = () => { + const [status, setStatus] = useState('syncing'); + + useEffect(() => { + const run = async () => { + try { + const counts = (await callAppRoute( + '/github/count-project-items', + {}, + )) as CountResponse; + + if (counts.projects.length === 0) { + throw new Error( + 'No projects resolved. Set GITHUB_PROJECTS in the application variables (e.g. `twentyhq/24`).', + ); + } + + const totalPages = Math.max(counts.totalPages, 1); + let pagesProcessed = 0; + let totalItems = 0; + + for (const { owner, number } of counts.projects) { + let cursor: string | null = null; + let hasMore = true; + + while (hasMore) { + const data = (await callAppRoute('/github/fetch-project-items', { + owner, + number, + cursor, + })) as FetchPageResponse; + + totalItems += data.itemCount; + hasMore = data.hasMore && data.itemCount > 0; + cursor = data.endCursor; + pagesProcessed++; + + updateProgress(Math.min(Math.round((pagesProcessed / totalPages) * 100), 99)); + } + } + + updateProgress(100); + enqueueSnackbar({ + message: `Fetched ${totalItems} project items`, + variant: 'success', + }); + setStatus('done'); + } catch (err) { + const message = + err instanceof Error ? err.message : 'Failed to fetch project items'; + enqueueSnackbar({ message, variant: 'error' }); + setStatus('error'); + } finally { + unmountFrontComponent(); + } + }; + run(); + }, []); + + if (status === 'syncing') return
Fetching project items...
; + if (status === 'error') return
Failed to fetch project items.
; + return
Done
; +}; + +export default defineFrontComponent({ + universalIdentifier: '7c397b0c-8b19-4fac-924a-8f6aa1dece78', + name: 'Fetch Project Items', + description: 'Fetches project items from GitHub Projects V2', + isHeadless: true, + component: FetchProjectItems, + command: { + universalIdentifier: '719cfe1c-d570-4c8c-89e6-88671c6ba1ea', + label: 'Fetch Project Items', + icon: 'IconLayoutKanban', + isPinned: false, + conditionalAvailabilityExpression: + objectMetadataItem.nameSingular === 'projectItem', + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/github/count-project-items.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/github/count-project-items.ts new file mode 100644 index 0000000000..ae8e24c29c --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/github/count-project-items.ts @@ -0,0 +1,41 @@ +import { githubGraphqlOptional } from 'src/modules/github/connector/github-client'; + +const ORG_QUERY = ` +query($owner: String!, $number: Int!) { + organization(login: $owner) { + projectV2(number: $number) { + items { totalCount } + } + } +}`; + +const USER_QUERY = ORG_QUERY.replace( + 'organization(login: $owner)', + 'user(login: $owner)', +); + +type OrgResponse = { + organization: { projectV2: { items: { totalCount: number } } | null } | null; +}; + +type UserResponse = { + user: { projectV2: { items: { totalCount: number } } | null } | null; +}; + +export async function countProjectItems( + owner: string, + projectNumber: number, +): Promise { + const orgData = await githubGraphqlOptional(ORG_QUERY, { + owner, + number: projectNumber, + }); + const orgCount = orgData?.organization?.projectV2?.items.totalCount; + if (typeof orgCount === 'number') return orgCount; + + const userData = await githubGraphqlOptional(USER_QUERY, { + owner, + number: projectNumber, + }); + return userData?.user?.projectV2?.items.totalCount ?? 0; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/github/fetch-project-item-by-node-id.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/github/fetch-project-item-by-node-id.ts new file mode 100644 index 0000000000..caccd64cb6 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/github/fetch-project-item-by-node-id.ts @@ -0,0 +1,31 @@ +import { githubGraphql } from 'src/modules/github/connector/github-client'; +import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item'; +import { PROJECT_V2_ITEM_FRAGMENT } from 'src/modules/github/project-item/graphql/github/fragments'; + +const QUERY = ` +query($id: ID!) { + node(id: $id) { + ... on ProjectV2Item { + ${PROJECT_V2_ITEM_FRAGMENT} + } + } +}`; + +type Response = { + node: ProjectV2Item | null; +}; + +export async function fetchProjectItemByNodeId( + nodeId: string, +): Promise { + try { + const data = await githubGraphql(QUERY, { id: nodeId }); + return data.node; + } catch (err) { + const msg = err instanceof Error ? err.message : ''; + if (msg.includes('Could not resolve') || msg.includes('global id')) { + return null; + } + throw err; + } +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/github/fetch-project-items.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/github/fetch-project-items.ts new file mode 100644 index 0000000000..068af361e8 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/github/fetch-project-items.ts @@ -0,0 +1,78 @@ +import { + EMPTY_PAGE, + type GithubPage, + githubGraphqlOptional, +} from 'src/modules/github/connector/github-client'; +import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item'; +import { PROJECT_V2_ITEM_FRAGMENT } from 'src/modules/github/project-item/graphql/github/fragments'; + +const ORG_QUERY = ` +query($owner: String!, $number: Int!, $cursor: String) { + organization(login: $owner) { + projectV2(number: $number) { + items(first: 100, after: $cursor) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { ${PROJECT_V2_ITEM_FRAGMENT} } + } + } + } +}`; + +const USER_QUERY = ORG_QUERY.replace( + 'organization(login: $owner)', + 'user(login: $owner)', +); + +type ItemsConnection = GithubPage; + +type OrgResponse = { + organization: { projectV2: { items: ItemsConnection } | null } | null; +}; + +type UserResponse = { + user: { projectV2: { items: ItemsConnection } | null } | null; +}; + +export async function fetchProjectItems( + owner: string, + projectNumber: number, + cursor: string | null = null, +): Promise<{ + items: ProjectV2Item[]; + totalCount: number; + hasMore: boolean; + endCursor: string | null; +}> { + const orgData = await githubGraphqlOptional(ORG_QUERY, { + owner, + number: projectNumber, + cursor, + }); + + let conn: ItemsConnection | undefined = + orgData?.organization?.projectV2?.items; + + if (!conn) { + const userData = await githubGraphqlOptional(USER_QUERY, { + owner, + number: projectNumber, + cursor, + }); + conn = userData?.user?.projectV2?.items; + } + + if (!conn) { + console.warn( + `[github-gql] project ${owner}/${projectNumber} returned no items (project does not exist or the fine-grained PAT lacks Organization → Projects: Read for "${owner}", and may also need to be approved by an org admin).`, + ); + return { items: [], ...EMPTY_PAGE }; + } + + return { + items: conn.nodes, + totalCount: conn.totalCount, + hasMore: conn.pageInfo.hasNextPage, + endCursor: conn.pageInfo.endCursor, + }; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/github/fragments.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/github/fragments.ts new file mode 100644 index 0000000000..6d6c35832a --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/github/fragments.ts @@ -0,0 +1,46 @@ +export const PROJECT_V2_ITEM_FRAGMENT = ` + id + content { + __typename + ... on Issue { + title + number + url + repository { nameWithOwner } + } + ... on PullRequest { + title + number + url + repository { nameWithOwner } + } + ... on DraftIssue { + title + } + } + fieldValues(first: 20) { + nodes { + __typename + ... on ProjectV2ItemFieldSingleSelectValue { + name + field { ... on ProjectV2SingleSelectField { name } } + } + ... on ProjectV2ItemFieldIterationValue { + title + field { ... on ProjectV2IterationField { name } } + } + ... on ProjectV2ItemFieldTextValue { + text + field { ... on ProjectV2Field { name } } + } + ... on ProjectV2ItemFieldNumberValue { + number + field { ... on ProjectV2Field { name } } + } + ... on ProjectV2ItemFieldUserValue { + users(first: 10) { nodes { login } } + field { ... on ProjectV2Field { name } } + } + } + } +`; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/mutations/batch-upsert.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/mutations/batch-upsert.ts new file mode 100644 index 0000000000..0d2511effb --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/mutations/batch-upsert.ts @@ -0,0 +1,29 @@ +import { chunkedBatchCreate } from 'src/modules/shared/twenty-client'; +import type { ProjectItemRow } from 'src/modules/github/project-item/types/project-item-row'; + +export async function batchUpsertProjectItems( + items: Array<{ + name: string; + githubProjectItemId: string; + status: string; + sprint: string; + assignees: string; + priority: string | null; + mainAssigneeId: string | null; + linkedIssueId: string | null; + linkedPullRequestId: string | null; + githubUrl: { primaryLinkLabel: string; primaryLinkUrl: string; secondaryLinks: null } | null; + repo: string; + }>, +): Promise { + return chunkedBatchCreate('createProjectItems', items, { + id: true, + githubProjectItemId: true, + name: true, + status: true, + mainAssigneeId: true, + linkedIssueId: true, + linkedPullRequestId: true, + githubUrl: { primaryLinkLabel: true, primaryLinkUrl: true }, + }) as Promise; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/logic-functions/count-project-items.logic-function.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/logic-functions/count-project-items.logic-function.ts new file mode 100644 index 0000000000..1233032516 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/logic-functions/count-project-items.logic-function.ts @@ -0,0 +1,47 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; +import { + getGithubProjects, + type GithubProject, +} from 'src/modules/github/connector/config'; +import { countProjectItems } from 'src/modules/github/project-item/graphql/github/count-project-items'; + +const PAGE_SIZE = 100; + +type CountProjectItemsPayload = { + projects?: GithubProject[]; +}; + +const handler = async (event: RoutePayload) => { + const bodyProjects = event.body?.projects; + const projects = + bodyProjects && bodyProjects.length > 0 + ? bodyProjects + : getGithubProjects(); + + const results: Array = + []; + let totalPages = 0; + + for (const { owner, number } of projects) { + const totalCount = await countProjectItems(owner, number); + const pages = Math.max(Math.ceil(totalCount / PAGE_SIZE), 1); + results.push({ owner, number, totalCount, pages }); + totalPages += pages; + } + + return { totalPages, projects: results }; +}; + +export default defineLogicFunction({ + universalIdentifier: 'f7a3e1b2-5c4d-4e6f-8a9b-0d1c2e3f4a5b', + name: 'count-project-items', + description: + 'Counts total project item pages across configured projects using GraphQL totalCount', + timeoutSeconds: 30, + handler, + httpRouteTriggerSettings: { + path: '/github/count-project-items', + httpMethod: 'POST', + isAuthRequired: true, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/logic-functions/fetch-project-items.logic-function.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/logic-functions/fetch-project-items.logic-function.ts new file mode 100644 index 0000000000..712903c5a9 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/logic-functions/fetch-project-items.logic-function.ts @@ -0,0 +1,57 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; +import { fetchProjectItems } from 'src/modules/github/project-item/graphql/github/fetch-project-items'; +import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item'; +import { batchUpsertProjectItems } from 'src/modules/github/project-item/graphql/mutations/batch-upsert'; +import { projectItemFromGraphql } from 'src/modules/github/project-item/normalizers'; +import { isFixtureAllowed } from 'src/modules/shared/fixtures'; + +export type FetchProjectItemsFixturePage = { + items: ProjectV2Item[]; + totalCount: number; + hasMore: boolean; + endCursor: string | null; +}; + +type FetchProjectItemsPayload = { + owner: string; + number: number; + cursor?: string | null; + fixturePage?: FetchProjectItemsFixturePage; +}; + +const handler = async (event: RoutePayload) => { + const { owner, number, cursor = null, fixturePage } = event.body ?? {}; + if (!owner || !number) { + return { error: 'owner and number are required' }; + } + + const result = + fixturePage && isFixtureAllowed() + ? fixturePage + : await fetchProjectItems(owner, number, cursor); + const { items, totalCount, hasMore, endCursor } = result; + + if (items.length === 0) { + return { itemCount: 0, totalCount, hasMore: false, endCursor: null }; + } + + const itemData = await Promise.all(items.map(projectItemFromGraphql)); + + await batchUpsertProjectItems(itemData); + + return { itemCount: items.length, totalCount, hasMore, endCursor }; +}; + +export default defineLogicFunction({ + universalIdentifier: 'acb300d4-d4ec-491c-b314-3d4db90a49c5', + name: 'fetch-project-items', + description: + 'Fetches one page of project items from GitHub Projects V2 GraphQL API and batch upserts them', + timeoutSeconds: 300, + handler, + httpRouteTriggerSettings: { + path: '/github/fetch-project-items', + httpMethod: 'POST', + isAuthRequired: true, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/navigation-menu-items/project-items.navigation-menu-item.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/navigation-menu-items/project-items.navigation-menu-item.ts new file mode 100644 index 0000000000..6b9f762b5e --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/navigation-menu-items/project-items.navigation-menu-item.ts @@ -0,0 +1,11 @@ +import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define'; +import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object'; +import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item'; + +export default defineNavigationMenuItem({ + universalIdentifier: '25d3a916-9a70-478f-8d83-ef336e582fbc', + position: 2, + type: NavigationMenuItemType.OBJECT, + targetObjectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER, + folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/normalizers.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/normalizers.ts new file mode 100644 index 0000000000..e0bac19f10 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/normalizers.ts @@ -0,0 +1,106 @@ +import type { LinksFieldValue } from 'src/modules/shared/types'; +import { toLinksField } from 'src/modules/shared/types'; +import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item'; +import { + extractFieldValue, + extractAssigneeLogins, +} from 'src/modules/github/project-item/utils/extract-field-value'; +import { findIssueByNumberAndRepo } from 'src/modules/github/issue/graphql/queries/find-by-number-and-repo'; +import { + findPullRequestByGithubNumber, + findPullRequestByRepoAndNumber, +} from 'src/modules/github/pull-request/graphql/queries/find-by-github-number'; +import { findContributorByGhLogin } from 'src/modules/github/contributor/graphql/queries/find-by-gh-login'; + +const STATUS_MAP: Record = { + 'No Status': 'NO_STATUS', + Backlog: 'BACKLOG', + Todo: 'TODO', + 'In Progress': 'IN_PROGRESS', + 'In Review': 'IN_REVIEW', + Done: 'DONE', +}; + +const PRIORITY_MAP: Record = { + Low: 'LOW', + Medium: 'MEDIUM', + High: 'HIGH', + Critical: 'CRITICAL', +}; + +export type ProjectItemUpsertInput = { + name: string; + githubProjectItemId: string; + status: string; + sprint: string; + assignees: string; + priority: string | null; + mainAssigneeId: string | null; + linkedIssueId: string | null; + linkedPullRequestId: string | null; + githubUrl: LinksFieldValue | null; + repo: string; +}; + +export async function projectItemFromGraphql( + item: ProjectV2Item, +): Promise { + const title = + item.content?.title ?? + (extractFieldValue(item, 'Title') || 'Untitled'); + const rawStatus = extractFieldValue(item, 'Status'); + const status = STATUS_MAP[rawStatus] ?? 'NO_STATUS'; + const sprint = + extractFieldValue(item, 'Sprint') || + extractFieldValue(item, 'Iteration'); + const assigneeLogins = extractAssigneeLogins(item); + const assignees = assigneeLogins.join(', '); + const rawPriority = extractFieldValue(item, 'Priority'); + const priority = PRIORITY_MAP[rawPriority] ?? null; + + let mainAssigneeId: string | null = null; + if (assigneeLogins.length > 0) { + const contributor = await findContributorByGhLogin(assigneeLogins[0]); + mainAssigneeId = contributor?.id ?? null; + } + + let linkedIssueId: string | null = null; + let linkedPullRequestId: string | null = null; + let repo = ''; + let githubUrl: LinksFieldValue | null = null; + + if (item.content) { + const contentType = item.content.__typename; + repo = item.content.repository?.nameWithOwner ?? ''; + + if (contentType === 'Issue' && item.content.number) { + const issue = await findIssueByNumberAndRepo(item.content.number, repo); + linkedIssueId = issue?.id ?? null; + if (item.content.url) { + githubUrl = toLinksField(item.content.url, `#${item.content.number}`); + } + } else if (contentType === 'PullRequest' && item.content.number) { + const pr = repo + ? await findPullRequestByRepoAndNumber(repo, item.content.number) + : await findPullRequestByGithubNumber(item.content.number); + linkedPullRequestId = pr?.id ?? null; + if (item.content.url) { + githubUrl = toLinksField(item.content.url, `#${item.content.number}`); + } + } + } + + return { + name: title, + githubProjectItemId: item.id, + status, + sprint, + assignees, + priority, + mainAssigneeId, + linkedIssueId, + linkedPullRequestId, + githubUrl, + repo, + }; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/objects/project-item.object.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/objects/project-item.object.ts new file mode 100644 index 0000000000..b339f48913 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/objects/project-item.object.ts @@ -0,0 +1,181 @@ +import { defineObject, FieldType } from 'twenty-sdk/define'; + +export const PROJECT_ITEM_UNIVERSAL_IDENTIFIER = + '1c4c36a5-586c-4ead-9f3e-5e9718ba6231'; + +export const PROJECT_ITEM_NAME_FIELD_UNIVERSAL_IDENTIFIER = + '215cdbbd-8606-4c13-9025-3ebe912eaa32'; + +export const PROJECT_ITEM_GITHUB_ID_FIELD_UNIVERSAL_IDENTIFIER = + '2d3e4f5a-6b7c-4d8e-af9b-0c1d2e3f4a5b'; + +export const PROJECT_ITEM_SPRINT_FIELD_UNIVERSAL_IDENTIFIER = + 'e3708df4-6b6b-45f6-83fb-9d47c5bc5a25'; + +export const PROJECT_ITEM_ASSIGNEES_FIELD_UNIVERSAL_IDENTIFIER = + '3e4f5a6b-7c8d-4e9f-b0ac-1d2e3f4a5b6c'; + +export const PROJECT_ITEM_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER = + '2db040a6-f2af-4e7d-9b70-d453f3cd99b7'; + +export const PROJECT_ITEM_REPO_FIELD_UNIVERSAL_IDENTIFIER = + '589d6aeb-8384-4d52-9d71-d63b7fd94ec7'; + +enum ProjectItemStatus { + NO_STATUS = 'NO_STATUS', + BACKLOG = 'BACKLOG', + TODO = 'TODO', + IN_PROGRESS = 'IN_PROGRESS', + IN_REVIEW = 'IN_REVIEW', + DONE = 'DONE', +} + +export const PROJECT_ITEM_STATUS_FIELD_UNIVERSAL_IDENTIFIER = + '54034cfc-83d9-478d-849c-98c91a819342'; + +enum ProjectItemPriority { + LOW = 'LOW', + MEDIUM = 'MEDIUM', + HIGH = 'HIGH', + CRITICAL = 'CRITICAL', +} + +export const PROJECT_ITEM_PRIORITY_FIELD_UNIVERSAL_IDENTIFIER = + '952a601c-b2da-4b84-9c7b-a9b42fcb7d95'; + +export const PROJECT_ITEM_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER = + '597b4141-487f-5ad3-87c2-49f0a1679856'; + +export default defineObject({ + universalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER, + nameSingular: 'projectItem', + namePlural: 'projectItems', + labelSingular: 'Project Item', + labelPlural: 'Project Items', + icon: 'IconLayoutKanban', + labelIdentifierFieldMetadataUniversalIdentifier: + PROJECT_ITEM_NAME_FIELD_UNIVERSAL_IDENTIFIER, + fields: [ + { + universalIdentifier: PROJECT_ITEM_NAME_FIELD_UNIVERSAL_IDENTIFIER, + name: 'name', + type: FieldType.TEXT, + label: 'Name', + icon: 'IconTextCaption', + }, + { + universalIdentifier: PROJECT_ITEM_GITHUB_ID_FIELD_UNIVERSAL_IDENTIFIER, + name: 'githubProjectItemId', + type: FieldType.TEXT, + label: 'GitHub Item ID', + icon: 'IconHash', + isUnique: true, + }, + { + universalIdentifier: PROJECT_ITEM_STATUS_FIELD_UNIVERSAL_IDENTIFIER, + name: 'status', + type: FieldType.SELECT, + label: 'Status', + icon: 'IconCircleDot', + options: [ + { + value: ProjectItemStatus.NO_STATUS, + label: 'No Status', + position: 0, + color: 'gray', + }, + { + value: ProjectItemStatus.BACKLOG, + label: 'Backlog', + position: 1, + color: 'gray', + }, + { + value: ProjectItemStatus.TODO, + label: 'Todo', + position: 2, + color: 'blue', + }, + { + value: ProjectItemStatus.IN_PROGRESS, + label: 'In Progress', + position: 3, + color: 'yellow', + }, + { + value: ProjectItemStatus.IN_REVIEW, + label: 'In Review', + position: 4, + color: 'turquoise', + }, + { + value: ProjectItemStatus.DONE, + label: 'Done', + position: 5, + color: 'green', + }, + ], + }, + { + universalIdentifier: PROJECT_ITEM_SPRINT_FIELD_UNIVERSAL_IDENTIFIER, + name: 'sprint', + type: FieldType.TEXT, + label: 'Sprint', + icon: 'IconRun', + }, + { + universalIdentifier: PROJECT_ITEM_ASSIGNEES_FIELD_UNIVERSAL_IDENTIFIER, + name: 'assignees', + type: FieldType.TEXT, + label: 'Assignees', + icon: 'IconUsers', + }, + { + universalIdentifier: PROJECT_ITEM_PRIORITY_FIELD_UNIVERSAL_IDENTIFIER, + name: 'priority', + type: FieldType.SELECT, + label: 'Priority', + icon: 'IconFlag', + options: [ + { + value: ProjectItemPriority.LOW, + label: 'Low', + position: 0, + color: 'green', + }, + { + value: ProjectItemPriority.MEDIUM, + label: 'Medium', + position: 1, + color: 'turquoise', + }, + { + value: ProjectItemPriority.HIGH, + label: 'High', + position: 2, + color: 'red', + }, + { + value: ProjectItemPriority.CRITICAL, + label: 'Critical', + position: 3, + color: 'red', + }, + ], + }, + { + universalIdentifier: PROJECT_ITEM_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER, + name: 'githubUrl', + type: FieldType.LINKS, + label: 'GitHub URL', + icon: 'IconLink', + }, + { + universalIdentifier: PROJECT_ITEM_REPO_FIELD_UNIVERSAL_IDENTIFIER, + name: 'repo', + type: FieldType.TEXT, + label: 'Repository', + icon: 'IconFolder', + }, + ], +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/types/github-project-v2-item.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/types/github-project-v2-item.ts new file mode 100644 index 0000000000..c91adc6216 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/types/github-project-v2-item.ts @@ -0,0 +1,7 @@ +export type GitHubProjectV2Item = { + id: number; + node_id: string; + project_node_id?: string; + content_node_id?: string; + content_type?: 'Issue' | 'PullRequest' | 'DraftIssue'; +}; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/types/project-item-row.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/types/project-item-row.ts new file mode 100644 index 0000000000..8445526743 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/types/project-item-row.ts @@ -0,0 +1,16 @@ +import type { LinksFieldValue } from 'src/modules/shared/types'; + +export type ProjectItemRow = { + id: string; + name?: string | null; + githubProjectItemId?: string | null; + status?: string | null; + sprint?: string | null; + assignees?: string | null; + priority?: string | null; + mainAssigneeId?: string | null; + linkedIssueId?: string | null; + linkedPullRequestId?: string | null; + githubUrl?: LinksFieldValue | null; + repo?: string | null; +}; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/types/project-v2-item.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/types/project-v2-item.ts new file mode 100644 index 0000000000..91fb168578 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/types/project-v2-item.ts @@ -0,0 +1,42 @@ +type FieldRef = { name: string }; + +export type ProjectV2FieldValue = + | { + __typename: 'ProjectV2ItemFieldSingleSelectValue'; + name: string; + field: FieldRef; + } + | { + __typename: 'ProjectV2ItemFieldIterationValue'; + title: string; + field: FieldRef; + } + | { + __typename: 'ProjectV2ItemFieldTextValue'; + text: string; + field: FieldRef; + } + | { + __typename: 'ProjectV2ItemFieldNumberValue'; + number: number; + field: FieldRef; + } + | { + __typename: 'ProjectV2ItemFieldUserValue'; + users: { nodes: Array<{ login: string }> }; + field: FieldRef; + }; + +export type ProjectV2Item = { + id: string; + content: { + __typename: string; + title?: string; + number?: number; + url?: string; + repository?: { nameWithOwner: string }; + } | null; + fieldValues: { + nodes: Array; + }; +}; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/utils/extract-field-value.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/utils/extract-field-value.ts new file mode 100644 index 0000000000..c09a393472 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/utils/extract-field-value.ts @@ -0,0 +1,34 @@ +import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item'; + +export function extractAssigneeLogins(item: ProjectV2Item): string[] { + for (const fv of item.fieldValues.nodes) { + if (fv.field?.name !== 'Assignees') continue; + if (fv.__typename === 'ProjectV2ItemFieldUserValue') { + return fv.users.nodes.map((u) => u.login); + } + } + return []; +} + +export function extractFieldValue( + item: ProjectV2Item, + fieldName: string, +): string { + for (const fv of item.fieldValues.nodes) { + if (fv.field?.name !== fieldName) continue; + + switch (fv.__typename) { + case 'ProjectV2ItemFieldSingleSelectValue': + return fv.name; + case 'ProjectV2ItemFieldIterationValue': + return fv.title; + case 'ProjectV2ItemFieldTextValue': + return fv.text; + case 'ProjectV2ItemFieldNumberValue': + return String(fv.number); + case 'ProjectV2ItemFieldUserValue': + return fv.users.nodes.map((u) => u.login).join(', '); + } + } + return ''; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/project-item/views/all-project-items.view.ts b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/views/all-project-items.view.ts new file mode 100644 index 0000000000..1b8a8971e2 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/project-item/views/all-project-items.view.ts @@ -0,0 +1,123 @@ +import { + defineView, + ViewKey, + ViewSortDirection, + ViewType, +} from 'twenty-sdk/define'; +import { + PROJECT_ITEM_UNIVERSAL_IDENTIFIER, + PROJECT_ITEM_NAME_FIELD_UNIVERSAL_IDENTIFIER, + PROJECT_ITEM_STATUS_FIELD_UNIVERSAL_IDENTIFIER, + PROJECT_ITEM_SPRINT_FIELD_UNIVERSAL_IDENTIFIER, + PROJECT_ITEM_ASSIGNEES_FIELD_UNIVERSAL_IDENTIFIER, + PROJECT_ITEM_PRIORITY_FIELD_UNIVERSAL_IDENTIFIER, + PROJECT_ITEM_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER, + PROJECT_ITEM_REPO_FIELD_UNIVERSAL_IDENTIFIER, + PROJECT_ITEM_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/modules/github/project-item/objects/project-item.object'; +import { MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/main-assignee-on-project-item.field'; +import { LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/linked-issue-on-project-item.field'; +import { LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/linked-pr-on-project-item.field'; + +export const ALL_PROJECT_ITEMS_VIEW_UNIVERSAL_IDENTIFIER = + '9bb6d69e-9411-4195-9042-8df2e4b72a11'; + +export default defineView({ + universalIdentifier: ALL_PROJECT_ITEMS_VIEW_UNIVERSAL_IDENTIFIER, + name: 'All Project Items', + objectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER, + type: ViewType.TABLE, + icon: 'IconLayoutKanban', + key: ViewKey.INDEX, + position: 0, + fields: [ + { + universalIdentifier: '19064693-70e0-4653-b6f6-572c3adfcc78', + fieldMetadataUniversalIdentifier: + PROJECT_ITEM_NAME_FIELD_UNIVERSAL_IDENTIFIER, + position: 0, + isVisible: true, + size: 300, + }, + { + universalIdentifier: 'f7a1de3d-3989-4cb9-8e8e-0edd1b4d15e7', + fieldMetadataUniversalIdentifier: + PROJECT_ITEM_STATUS_FIELD_UNIVERSAL_IDENTIFIER, + position: 1, + isVisible: true, + size: 130, + }, + { + universalIdentifier: 'f4fae1f9-8480-4cfd-b493-9dbac84c6643', + fieldMetadataUniversalIdentifier: + PROJECT_ITEM_PRIORITY_FIELD_UNIVERSAL_IDENTIFIER, + position: 2, + isVisible: true, + size: 130, + }, + { + universalIdentifier: '5969adcf-1e89-4561-a052-0296058623f3', + fieldMetadataUniversalIdentifier: + MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER, + position: 3, + isVisible: true, + size: 180, + }, + { + universalIdentifier: '21868cca-dae2-45e7-aa92-e667703e8cfe', + fieldMetadataUniversalIdentifier: + PROJECT_ITEM_SPRINT_FIELD_UNIVERSAL_IDENTIFIER, + position: 4, + isVisible: true, + size: 150, + }, + { + universalIdentifier: 'bf877bd3-a788-4db5-89cd-db6fb74fe49d', + fieldMetadataUniversalIdentifier: + PROJECT_ITEM_ASSIGNEES_FIELD_UNIVERSAL_IDENTIFIER, + position: 5, + isVisible: true, + size: 200, + }, + { + universalIdentifier: '4c063477-ffe8-4799-bc50-bb9b2483cbab', + fieldMetadataUniversalIdentifier: + LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER, + position: 6, + isVisible: true, + size: 180, + }, + { + universalIdentifier: 'acba5c9a-fd97-42b6-b0f5-e8e3fb7d49c5', + fieldMetadataUniversalIdentifier: + LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER, + position: 7, + isVisible: true, + size: 180, + }, + { + universalIdentifier: '931fac89-e99d-46dd-b316-85ff04356811', + fieldMetadataUniversalIdentifier: + PROJECT_ITEM_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER, + position: 8, + isVisible: true, + size: 200, + }, + { + universalIdentifier: '11c446af-470a-40d4-96e8-9e91879accf4', + fieldMetadataUniversalIdentifier: + PROJECT_ITEM_REPO_FIELD_UNIVERSAL_IDENTIFIER, + position: 9, + isVisible: true, + size: 180, + }, + ], + sorts: [ + { + universalIdentifier: 'a7c181f1-2961-45b0-a700-dd9489b3420c', + fieldMetadataUniversalIdentifier: + PROJECT_ITEM_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + direction: ViewSortDirection.DESC, + }, + ], +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/fields/pull-request-on-review-event.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/fields/pull-request-on-review-event.field.ts new file mode 100644 index 0000000000..60f0ba5915 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/fields/pull-request-on-review-event.field.ts @@ -0,0 +1,30 @@ +import { + defineField, + FieldType, + RelationType, + OnDeleteAction, +} from 'twenty-sdk/define'; +import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object'; +import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object'; +import { REVIEW_EVENTS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/review-events-on-pull-request.field'; + +export const PULL_REQUEST_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER = + '8bd96f9d-fa6b-431a-9264-000097b580f8'; + +export default defineField({ + universalIdentifier: PULL_REQUEST_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'pullRequest', + label: 'Pull Request', + icon: 'IconGitPullRequest', + relationTargetObjectMetadataUniversalIdentifier: + PULL_REQUEST_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + REVIEW_EVENTS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.CASCADE, + joinColumnName: 'pullRequestId', + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/fields/review-on-review-event.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/fields/review-on-review-event.field.ts new file mode 100644 index 0000000000..2c1916498c --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/fields/review-on-review-event.field.ts @@ -0,0 +1,30 @@ +import { + defineField, + FieldType, + RelationType, + OnDeleteAction, +} from 'twenty-sdk/define'; +import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object'; +import { PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/objects/pull-request-review.object'; +import { EVENTS_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/fields/events-on-review.field'; + +export const REVIEW_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER = + '1bc4170d-e998-4aac-bfea-4a6145d7d707'; + +export default defineField({ + universalIdentifier: REVIEW_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'review', + label: 'Review', + icon: 'IconEye', + relationTargetObjectMetadataUniversalIdentifier: + PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + EVENTS_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'reviewId', + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/fields/reviewer-on-review-event.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/fields/reviewer-on-review-event.field.ts new file mode 100644 index 0000000000..1ee48f85b2 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/fields/reviewer-on-review-event.field.ts @@ -0,0 +1,30 @@ +import { + defineField, + FieldType, + RelationType, + OnDeleteAction, +} from 'twenty-sdk/define'; +import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object'; +import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object'; +import { REVIEW_EVENTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/fields/review-events-on-contributor.field'; + +export const REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER = + 'f5a6b7c8-9d0e-4f1a-b2c3-d4e5f6a7b8c9'; + +export default defineField({ + universalIdentifier: REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'reviewer', + label: 'Reviewer', + icon: 'IconUser', + relationTargetObjectMetadataUniversalIdentifier: + CONTRIBUTOR_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + REVIEW_EVENTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'reviewerId', + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/graphql/mutations/batch-upsert.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/graphql/mutations/batch-upsert.ts new file mode 100644 index 0000000000..0276fb8f2f --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/graphql/mutations/batch-upsert.ts @@ -0,0 +1,25 @@ +import { chunkedBatchCreate } from 'src/modules/shared/twenty-client'; +import type { PullRequestReviewEventRow } from 'src/modules/github/pull-request-review-event/types/pull-request-review-event-row'; + +export async function batchUpsertReviewEvents( + items: Array<{ + githubReviewId: number; + title: string; + state: string; + submittedAt: string | null; + reviewerId: string | null; + pullRequestId: string; + reviewId?: string | null; + }>, +): Promise { + return chunkedBatchCreate('createPullRequestReviewEvents', items, { + id: true, + githubReviewId: true, + title: true, + state: true, + submittedAt: true, + reviewerId: true, + pullRequestId: true, + reviewId: true, + }) as Promise; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/graphql/queries/find-events-for-pair.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/graphql/queries/find-events-for-pair.ts new file mode 100644 index 0000000000..48074fc7cc --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/graphql/queries/find-events-for-pair.ts @@ -0,0 +1,61 @@ +import { getClient } from 'src/modules/shared/twenty-client'; + +export type ReviewEventForPair = { + id: string; + state: string; + submittedAt: string | null; +}; + +type Edge = { node: T }; +type Connection = { + edges: Edge[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; +}; + +const PAGE_SIZE = 100; + +export async function findReviewEventsForPair( + pullRequestId: string, + reviewerId: string | null, +): Promise { + const client = getClient(); + const out: ReviewEventForPair[] = []; + let cursor: string | null = null; + + const reviewerFilter = + reviewerId === null + ? { reviewerId: { is: 'NULL' } } + : { reviewerId: { eq: reviewerId } }; + + for (;;) { + const res = await client.query({ + pullRequestReviewEvents: { + __args: { + filter: { + and: [{ pullRequestId: { eq: pullRequestId } }, reviewerFilter], + }, + orderBy: [{ submittedAt: 'AscNullsLast' }], + first: PAGE_SIZE, + after: cursor, + }, + edges: { + node: { id: true, state: true, submittedAt: true }, + }, + pageInfo: { hasNextPage: true, endCursor: true }, + }, + }); + + const conn = + (res.pullRequestReviewEvents as Connection) ?? { + edges: [], + pageInfo: { hasNextPage: false, endCursor: null }, + }; + + for (const edge of conn.edges) out.push(edge.node); + + if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor) break; + cursor = conn.pageInfo.endCursor; + } + + return out; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/navigation-menu-items/pull-request-review-events.navigation-menu-item.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/navigation-menu-items/pull-request-review-events.navigation-menu-item.ts new file mode 100644 index 0000000000..72be66c037 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/navigation-menu-items/pull-request-review-events.navigation-menu-item.ts @@ -0,0 +1,15 @@ +import { + defineNavigationMenuItem, + NavigationMenuItemType, +} from 'twenty-sdk/define'; +import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item'; +import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object'; + +export default defineNavigationMenuItem({ + universalIdentifier: '2a3b4c5d-6e7f-4890-9abc-def012345679', + position: 5, + type: NavigationMenuItemType.OBJECT, + targetObjectUniversalIdentifier: + PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER, + folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/normalizers.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/normalizers.ts new file mode 100644 index 0000000000..4e7c94e908 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/normalizers.ts @@ -0,0 +1,48 @@ +import type { GitHubReview } from 'src/modules/github/pull-request-review-event/types/github-review'; + +export type ReviewEventState = + | 'APPROVED' + | 'CHANGES_REQUESTED' + | 'COMMENTED' + | 'DISMISSED'; + +export type ReviewEventCanonical = { + githubReviewId: number; + title: string; + state: ReviewEventState; + submittedAt: string | null; +}; + +export type GqlReviewLike = { + databaseId: number; + state: ReviewEventState; + submittedAt: string | null; + author: { login: string } | null; +}; + +export function buildReviewEventTitle(login: string, state: string): string { + return `${login} \u2014 ${state.toLowerCase()}`; +} + +export function reviewEventFromWebhook( + review: GitHubReview, +): ReviewEventCanonical { + return { + githubReviewId: review.id, + title: buildReviewEventTitle(review.user.login, review.state), + state: review.state, + submittedAt: review.submitted_at, + }; +} + +export function reviewEventFromGraphql( + review: GqlReviewLike, +): ReviewEventCanonical { + const login = review.author?.login ?? 'unknown'; + return { + githubReviewId: review.databaseId, + title: buildReviewEventTitle(login, review.state), + state: review.state, + submittedAt: review.submittedAt, + }; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/objects/pull-request-review-event.object.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/objects/pull-request-review-event.object.ts new file mode 100644 index 0000000000..a137486947 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/objects/pull-request-review-event.object.ts @@ -0,0 +1,97 @@ +import { defineObject, FieldType } from 'twenty-sdk/define'; + +export const PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER = + '7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d'; + +export const REVIEW_EVENT_TITLE_FIELD_UNIVERSAL_IDENTIFIER = + '8b9c0d1e-2f3a-4b4c-9d5e-6f7a8b9c0d1e'; + +export const REVIEW_EVENT_GITHUB_REVIEW_ID_FIELD_UNIVERSAL_IDENTIFIER = + '9c0d1e2f-3a4b-4c5d-ae6f-7a8b9c0d1e2f'; + +export const REVIEW_EVENT_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER = + '0d1e2f3a-4b5c-4d6e-bf7a-8b9c0d1e2f3a'; + +enum ReviewEventState { + APPROVED = 'APPROVED', + CHANGES_REQUESTED = 'CHANGES_REQUESTED', + COMMENTED = 'COMMENTED', + DISMISSED = 'DISMISSED', +} + +export const REVIEW_EVENT_STATE_FIELD_UNIVERSAL_IDENTIFIER = + 'e5eeeaab-4ea9-4f9f-873d-9c1c1473ddb3'; + +export const PULL_REQUEST_REVIEW_EVENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER = + 'fc577b22-c927-5b0e-9d6d-532dd41cf6d2'; + +export default defineObject({ + universalIdentifier: PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER, + nameSingular: 'pullRequestReviewEvent', + namePlural: 'pullRequestReviewEvents', + labelSingular: 'Pull Request Review Event', + labelPlural: 'Pull Request Review Events', + icon: 'IconEye', + labelIdentifierFieldMetadataUniversalIdentifier: + REVIEW_EVENT_TITLE_FIELD_UNIVERSAL_IDENTIFIER, + fields: [ + { + universalIdentifier: REVIEW_EVENT_TITLE_FIELD_UNIVERSAL_IDENTIFIER, + name: 'title', + type: FieldType.TEXT, + label: 'Title', + icon: 'IconTextCaption', + }, + { + universalIdentifier: + REVIEW_EVENT_GITHUB_REVIEW_ID_FIELD_UNIVERSAL_IDENTIFIER, + name: 'githubReviewId', + type: FieldType.NUMBER, + label: 'GitHub Review ID', + icon: 'IconHash', + isUnique: true, + }, + { + universalIdentifier: REVIEW_EVENT_STATE_FIELD_UNIVERSAL_IDENTIFIER, + name: 'state', + type: FieldType.SELECT, + label: 'State', + icon: 'IconCircleDot', + options: [ + { + value: ReviewEventState.APPROVED, + label: 'Approved', + position: 0, + color: 'green', + }, + { + value: ReviewEventState.CHANGES_REQUESTED, + label: 'Changes Requested', + position: 1, + color: 'red', + }, + { + value: ReviewEventState.COMMENTED, + label: 'Commented', + position: 2, + color: 'blue', + }, + { + value: ReviewEventState.DISMISSED, + label: 'Dismissed', + position: 3, + color: 'gray', + }, + ], + }, + { + universalIdentifier: REVIEW_EVENT_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER, + name: 'submittedAt', + type: FieldType.DATE_TIME, + label: 'Submitted At', + icon: 'IconCalendar', + isNullable: true, + defaultValue: null, + }, + ], +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/types/github-review.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/types/github-review.ts new file mode 100644 index 0000000000..ef65ca870c --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/types/github-review.ts @@ -0,0 +1,9 @@ +import type { GitHubUser } from 'src/modules/github/connector/github-user'; + +export type GitHubReview = { + id: number; + user: GitHubUser; + state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'DISMISSED'; + submitted_at: string; + body: string | null; +}; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/types/pull-request-review-event-row.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/types/pull-request-review-event-row.ts new file mode 100644 index 0000000000..bf1aabb656 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/types/pull-request-review-event-row.ts @@ -0,0 +1,10 @@ +export type PullRequestReviewEventRow = { + id: string; + title?: string | null; + githubReviewId?: number | null; + state?: string | null; + submittedAt?: string | null; + reviewerId?: string | null; + pullRequestId?: string | null; + reviewId?: string | null; +}; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/views/all-pull-request-review-events.view.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/views/all-pull-request-review-events.view.ts new file mode 100644 index 0000000000..e25d0900b1 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/views/all-pull-request-review-events.view.ts @@ -0,0 +1,78 @@ +import { + defineView, + ViewKey, + ViewSortDirection, + ViewType, +} from 'twenty-sdk/define'; +import { + PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER, + REVIEW_EVENT_TITLE_FIELD_UNIVERSAL_IDENTIFIER, + REVIEW_EVENT_STATE_FIELD_UNIVERSAL_IDENTIFIER, + REVIEW_EVENT_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER, + PULL_REQUEST_REVIEW_EVENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object'; +import { REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/fields/reviewer-on-review-event.field'; +import { PULL_REQUEST_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/fields/pull-request-on-review-event.field'; + +export const ALL_REVIEW_EVENTS_VIEW_UNIVERSAL_IDENTIFIER = + 'a1b2c3d4-0000-4e5f-a6b7-c8d9e0f1a2b3'; + +export default defineView({ + universalIdentifier: ALL_REVIEW_EVENTS_VIEW_UNIVERSAL_IDENTIFIER, + name: 'All Review Events', + objectUniversalIdentifier: PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER, + type: ViewType.TABLE, + icon: 'IconEye', + key: ViewKey.INDEX, + position: 0, + fields: [ + { + universalIdentifier: '69e69714-05a5-48cd-98b9-13e79ba602c5', + fieldMetadataUniversalIdentifier: + REVIEW_EVENT_TITLE_FIELD_UNIVERSAL_IDENTIFIER, + position: 0, + isVisible: true, + size: 250, + }, + { + universalIdentifier: 'ca794df5-74b0-4fc7-a2a7-05a72380e080', + fieldMetadataUniversalIdentifier: + REVIEW_EVENT_STATE_FIELD_UNIVERSAL_IDENTIFIER, + position: 1, + isVisible: true, + size: 150, + }, + { + universalIdentifier: '624b139e-46de-416d-97b2-527c07b3a738', + fieldMetadataUniversalIdentifier: + REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + position: 2, + isVisible: true, + size: 160, + }, + { + universalIdentifier: 'bcf9d3ac-d68c-410b-99f4-7e36f992f767', + fieldMetadataUniversalIdentifier: + PULL_REQUEST_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + position: 3, + isVisible: true, + size: 200, + }, + { + universalIdentifier: '798ec4a2-9e72-4d48-8010-fe5dcaa7fbed', + fieldMetadataUniversalIdentifier: + REVIEW_EVENT_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER, + position: 4, + isVisible: true, + size: 180, + }, + ], + sorts: [ + { + universalIdentifier: '0c8f9d6f-1a2d-4b14-a35a-8ec65a0683e3', + fieldMetadataUniversalIdentifier: + PULL_REQUEST_REVIEW_EVENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + direction: ViewSortDirection.DESC, + }, + ], +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/fields/events-on-review.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/fields/events-on-review.field.ts new file mode 100644 index 0000000000..e09fadfa0a --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/fields/events-on-review.field.ts @@ -0,0 +1,23 @@ +import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; +import { PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/objects/pull-request-review.object'; +import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object'; +import { REVIEW_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/fields/review-on-review-event.field'; + +export const EVENTS_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER = + '846f70d2-5a2b-41e1-b10c-628f874837f2'; + +export default defineField({ + universalIdentifier: EVENTS_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'reviewEvents', + label: 'Review Events', + icon: 'IconHistory', + relationTargetObjectMetadataUniversalIdentifier: + PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + REVIEW_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/fields/pull-request-on-review.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/fields/pull-request-on-review.field.ts new file mode 100644 index 0000000000..687b40ef2d --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/fields/pull-request-on-review.field.ts @@ -0,0 +1,30 @@ +import { + defineField, + FieldType, + RelationType, + OnDeleteAction, +} from 'twenty-sdk/define'; +import { PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/objects/pull-request-review.object'; +import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object'; +import { REVIEWS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/reviews-on-pull-request.field'; + +export const PULL_REQUEST_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER = + '5cc6fd4f-400f-421b-8c24-ffbf08077dd8'; + +export default defineField({ + universalIdentifier: PULL_REQUEST_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'pullRequest', + label: 'Pull Request', + icon: 'IconGitPullRequest', + relationTargetObjectMetadataUniversalIdentifier: + PULL_REQUEST_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + REVIEWS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.CASCADE, + joinColumnName: 'pullRequestId', + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/fields/reviewer-on-review.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/fields/reviewer-on-review.field.ts new file mode 100644 index 0000000000..e3225ccf1a --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/fields/reviewer-on-review.field.ts @@ -0,0 +1,30 @@ +import { + defineField, + FieldType, + RelationType, + OnDeleteAction, +} from 'twenty-sdk/define'; +import { PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/objects/pull-request-review.object'; +import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object'; +import { REVIEWS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/fields/reviews-on-contributor.field'; + +export const REVIEWER_FIELD_UNIVERSAL_IDENTIFIER = + '2272ecc4-90e1-4c66-96bd-a7ac20280943'; + +export default defineField({ + universalIdentifier: REVIEWER_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'reviewer', + label: 'Reviewer', + icon: 'IconUser', + relationTargetObjectMetadataUniversalIdentifier: + CONTRIBUTOR_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + REVIEWS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'reviewerId', + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/graphql/mutations/batch-upsert.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/graphql/mutations/batch-upsert.ts new file mode 100644 index 0000000000..63351688a4 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/graphql/mutations/batch-upsert.ts @@ -0,0 +1,19 @@ +import { chunkedBatchCreate } from 'src/modules/shared/twenty-client'; +import type { PullRequestReviewRow } from 'src/modules/github/pull-request-review/types/pull-request-review-row'; +import type { ConsolidatedReviewUpsertInput } from 'src/modules/github/pull-request-review/utils/build-consolidated-row'; + +export async function batchUpsertConsolidatedReviews( + items: ConsolidatedReviewUpsertInput[], +): Promise { + return chunkedBatchCreate('createPullRequestReviews', items, { + id: true, + reviewKey: true, + title: true, + state: true, + firstSubmittedAt: true, + lastSubmittedAt: true, + eventCount: true, + reviewerId: true, + pullRequestId: true, + }) as Promise; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/logic-functions/recompute-pull-request-reviews.logic-function.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/logic-functions/recompute-pull-request-reviews.logic-function.ts new file mode 100644 index 0000000000..277a98879f --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/logic-functions/recompute-pull-request-reviews.logic-function.ts @@ -0,0 +1,164 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; +import { getClient } from 'src/modules/shared/twenty-client'; +import { timed } from 'src/modules/shared/timing'; +import { batchUpsertConsolidatedReviews } from 'src/modules/github/pull-request-review/graphql/mutations/batch-upsert'; +import { + buildConsolidatedRow, + type ConsolidatedReviewUpsertInput, +} from 'src/modules/github/pull-request-review/utils/build-consolidated-row'; +import type { ReviewEventState } from 'src/modules/github/pull-request-review/utils/consolidate-reviews'; + +const PAGE_SIZE = 100; + +type Edge = { node: T }; +type Connection = { + edges: Edge[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; +}; + +type ReviewEventNode = { + state: ReviewEventState; + submittedAt: string | null; + reviewerId: string | null; + pullRequestId: string | null; + reviewer: { ghLogin: string | null } | null; + pullRequest: { githubNumber: number | null } | null; +}; + +type GroupContext = { + pullRequestId: string; + reviewerId: string | null; + prNumber: number | null; + reviewerLogin: string | null; + events: { state: ReviewEventState; submittedAt: string | null }[]; +}; + +async function* paginate( + fetchPage: (cursor: string | null) => Promise>, +): AsyncGenerator { + let cursor: string | null = null; + let hasMore = true; + while (hasMore) { + const conn = await fetchPage(cursor); + yield conn.edges.map((e) => e.node); + hasMore = conn.pageInfo.hasNextPage; + cursor = conn.pageInfo.endCursor; + if (!cursor) break; + } +} + +const handler = async (_event: RoutePayload) => { + const handlerStart = Date.now(); + const client = getClient(); + + const groups = new Map(); + let eventCount = 0; + let skippedNoPr = 0; + + await timed('recompute-reviews:scan-events', async () => { + for await (const batch of paginate(async (cursor) => { + const res = await client.query({ + pullRequestReviewEvents: { + __args: { first: PAGE_SIZE, after: cursor }, + edges: { + node: { + state: true, + submittedAt: true, + reviewerId: true, + pullRequestId: true, + reviewer: { ghLogin: true }, + pullRequest: { githubNumber: true }, + }, + }, + pageInfo: { hasNextPage: true, endCursor: true }, + }, + }); + return ( + (res.pullRequestReviewEvents as Connection) ?? { + edges: [], + pageInfo: { hasNextPage: false, endCursor: null }, + } + ); + })) { + for (const node of batch) { + eventCount++; + if (!node.pullRequestId) { + skippedNoPr++; + continue; + } + const key = `${node.pullRequestId}:${node.reviewerId ?? 'unknown'}`; + let group = groups.get(key); + if (!group) { + group = { + pullRequestId: node.pullRequestId, + reviewerId: node.reviewerId, + prNumber: node.pullRequest?.githubNumber ?? null, + reviewerLogin: node.reviewer?.ghLogin ?? null, + events: [], + }; + groups.set(key, group); + } + if (!group.reviewerLogin && node.reviewer?.ghLogin) { + group.reviewerLogin = node.reviewer.ghLogin; + } + if (group.prNumber === null && node.pullRequest?.githubNumber != null) { + group.prNumber = node.pullRequest.githubNumber; + } + group.events.push({ + state: node.state, + submittedAt: node.submittedAt, + }); + } + } + }); + + const rows: ConsolidatedReviewUpsertInput[] = []; + for (const group of groups.values()) { + if (group.events.length === 0) continue; + rows.push( + buildConsolidatedRow({ + pullRequestId: group.pullRequestId, + reviewerId: group.reviewerId, + prNumber: group.prNumber, + reviewerLogin: group.reviewerLogin, + events: group.events, + }), + ); + } + + let upsertedCount = 0; + if (rows.length > 0) { + const recs = await timed( + `recompute-reviews:upsert (${rows.length})`, + () => batchUpsertConsolidatedReviews(rows), + ); + upsertedCount = recs.length; + } + + const totalMs = Date.now() - handlerStart; + console.log( + `[recompute-reviews] done in ${totalMs}ms (events=${eventCount}, groups=${groups.size}, upserted=${upsertedCount}, skippedNoPr=${skippedNoPr})`, + ); + + return { + eventCount, + groupCount: groups.size, + upsertedCount, + skippedNoPr, + durationMs: totalMs, + }; +}; + +export default defineLogicFunction({ + universalIdentifier: 'fd2cbfa6-89a2-40f4-9164-0f658525cf07', + name: 'recompute-pull-request-reviews', + description: + 'Backfill/repeat-safe: scans every PullRequestReviewEvent, groups by (pullRequestId, reviewerId), and upserts the consolidated PullRequestReview row using the "first substantive wins" rule.', + timeoutSeconds: 300, + handler, + httpRouteTriggerSettings: { + path: '/pull-request-reviews/recompute', + httpMethod: 'POST', + isAuthRequired: true, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/navigation-menu-items/pull-request-reviews.navigation-menu-item.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/navigation-menu-items/pull-request-reviews.navigation-menu-item.ts new file mode 100644 index 0000000000..ef843310a2 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/navigation-menu-items/pull-request-reviews.navigation-menu-item.ts @@ -0,0 +1,14 @@ +import { + defineNavigationMenuItem, + NavigationMenuItemType, +} from 'twenty-sdk/define'; +import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item'; +import { PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/objects/pull-request-review.object'; + +export default defineNavigationMenuItem({ + universalIdentifier: '1f2e3d4c-5b6a-4798-8abc-def012345678', + position: 4, + type: NavigationMenuItemType.OBJECT, + targetObjectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER, + folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/objects/pull-request-review.object.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/objects/pull-request-review.object.ts new file mode 100644 index 0000000000..e4c056cb25 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/objects/pull-request-review.object.ts @@ -0,0 +1,120 @@ +import { defineObject, FieldType } from 'twenty-sdk/define'; + +export const PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER = + '3c9b62b5-88a6-4a05-8e62-7637987cc8bb'; + +export const REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER = + '0d0f01f1-bdb2-4dc9-a915-a2eacecdedce'; + +export const REVIEW_KEY_FIELD_UNIVERSAL_IDENTIFIER = + 'f96c80a8-d42f-414e-8eab-5c5684c0b948'; + +export const REVIEW_STATE_FIELD_UNIVERSAL_IDENTIFIER = + 'b93fabcc-67ea-4532-847c-2b015fe90ee5'; + +export const REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER = + 'd7157b19-b173-41ca-96a5-98b7cc4f379e'; + +export const REVIEW_LAST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER = + 'ea73e23e-6010-4cb4-b9e0-9dfe572e4b69'; + +export const REVIEW_EVENT_COUNT_FIELD_UNIVERSAL_IDENTIFIER = + '5bca3165-8033-41da-96bb-bf381e6db45f'; + +export const PULL_REQUEST_REVIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER = + 'b4d61187-1b78-5d6e-9752-79f994ff6d55'; + +enum ReviewState { + APPROVED = 'APPROVED', + CHANGES_REQUESTED = 'CHANGES_REQUESTED', + COMMENTED = 'COMMENTED', + DISMISSED = 'DISMISSED', +} + +export default defineObject({ + universalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER, + nameSingular: 'pullRequestReview', + namePlural: 'pullRequestReviews', + labelSingular: 'Pull Request Review', + labelPlural: 'Pull Request Reviews', + icon: 'IconEye', + labelIdentifierFieldMetadataUniversalIdentifier: + REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER, + fields: [ + { + universalIdentifier: REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER, + name: 'title', + type: FieldType.TEXT, + label: 'Title', + icon: 'IconTextCaption', + }, + { + universalIdentifier: REVIEW_KEY_FIELD_UNIVERSAL_IDENTIFIER, + name: 'reviewKey', + type: FieldType.TEXT, + label: 'Review Key', + icon: 'IconKey', + isUnique: true, + }, + { + universalIdentifier: REVIEW_STATE_FIELD_UNIVERSAL_IDENTIFIER, + name: 'state', + type: FieldType.SELECT, + label: 'State', + icon: 'IconCircleDot', + options: [ + { + value: ReviewState.APPROVED, + label: 'Approved', + position: 0, + color: 'green', + }, + { + value: ReviewState.CHANGES_REQUESTED, + label: 'Changes Requested', + position: 1, + color: 'red', + }, + { + value: ReviewState.COMMENTED, + label: 'Commented', + position: 2, + color: 'blue', + }, + { + value: ReviewState.DISMISSED, + label: 'Dismissed', + position: 3, + color: 'gray', + }, + ], + }, + { + universalIdentifier: + REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER, + name: 'firstSubmittedAt', + type: FieldType.DATE_TIME, + label: 'First Submitted At', + icon: 'IconCalendar', + isNullable: true, + defaultValue: null, + }, + { + universalIdentifier: REVIEW_LAST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER, + name: 'lastSubmittedAt', + type: FieldType.DATE_TIME, + label: 'Last Submitted At', + icon: 'IconCalendarTime', + isNullable: true, + defaultValue: null, + }, + { + universalIdentifier: REVIEW_EVENT_COUNT_FIELD_UNIVERSAL_IDENTIFIER, + name: 'eventCount', + type: FieldType.NUMBER, + label: 'Event Count', + icon: 'IconHash', + defaultValue: 0, + }, + ], +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/types/pull-request-review-row.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/types/pull-request-review-row.ts new file mode 100644 index 0000000000..b71f66cd24 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/types/pull-request-review-row.ts @@ -0,0 +1,11 @@ +export type PullRequestReviewRow = { + id: string; + title?: string | null; + reviewKey?: string | null; + state?: string | null; + firstSubmittedAt?: string | null; + lastSubmittedAt?: string | null; + eventCount?: number | null; + reviewerId?: string | null; + pullRequestId?: string | null; +}; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/utils/build-consolidated-row.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/utils/build-consolidated-row.ts new file mode 100644 index 0000000000..f0856f2ee4 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/utils/build-consolidated-row.ts @@ -0,0 +1,53 @@ +import { + consolidateReviews, + type ReviewEventForConsolidation, +} from 'src/modules/github/pull-request-review/utils/consolidate-reviews'; + +export type ConsolidatedReviewUpsertInput = { + reviewKey: string; + title: string; + state: string; + firstSubmittedAt: string | null; + lastSubmittedAt: string | null; + eventCount: number; + reviewerId: string | null; + pullRequestId: string; +}; + +export type BuildConsolidatedRowParams = { + pullRequestId: string; + reviewerId: string | null; + prNumber: number | null; + reviewerLogin: string | null; + events: ReviewEventForConsolidation[]; +}; + +export const buildReviewKey = ( + pullRequestId: string, + reviewerId: string | null, +): string => `${pullRequestId}:${reviewerId ?? 'unknown'}`; + +export const buildConsolidatedTitle = ( + reviewerLogin: string | null, + prNumber: number | null, +): string => { + const login = reviewerLogin ?? 'unknown'; + const number = typeof prNumber === 'number' ? prNumber : '?'; + return `${login} on PR #${number}`; +}; + +export const buildConsolidatedRow = ( + params: BuildConsolidatedRowParams, +): ConsolidatedReviewUpsertInput => { + const verdict = consolidateReviews(params.events); + return { + reviewKey: buildReviewKey(params.pullRequestId, params.reviewerId), + title: buildConsolidatedTitle(params.reviewerLogin, params.prNumber), + state: verdict.state, + firstSubmittedAt: verdict.firstSubmittedAt, + lastSubmittedAt: verdict.lastSubmittedAt, + eventCount: verdict.eventCount, + reviewerId: params.reviewerId, + pullRequestId: params.pullRequestId, + }; +}; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/utils/consolidate-reviews.integration-test.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/utils/consolidate-reviews.integration-test.ts new file mode 100644 index 0000000000..e516da0a8a --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/utils/consolidate-reviews.integration-test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; + +import { + consolidateReviews, + type ReviewEventForConsolidation, +} from 'src/modules/github/pull-request-review/utils/consolidate-reviews'; +import { + buildConsolidatedRow, + buildReviewKey, + buildConsolidatedTitle, +} from 'src/modules/github/pull-request-review/utils/build-consolidated-row'; + +const evt = ( + state: ReviewEventForConsolidation['state'], + submittedAt: string | null, +): ReviewEventForConsolidation => ({ state, submittedAt }); + +describe('consolidateReviews', () => { + it('throws on empty input', () => { + expect(() => consolidateReviews([])).toThrow(); + }); + + it('returns a verdict for a single COMMENTED event', () => { + const verdict = consolidateReviews([evt('COMMENTED', '2025-04-01T10:00:00Z')]); + expect(verdict).toEqual({ + state: 'COMMENTED', + firstSubmittedAt: '2025-04-01T10:00:00Z', + lastSubmittedAt: '2025-04-01T10:00:00Z', + eventCount: 1, + }); + }); + + it('picks the earliest substantive event when COMMENTED → APPROVED → COMMENTED', () => { + const verdict = consolidateReviews([ + evt('COMMENTED', '2025-04-01T10:00:00Z'), + evt('APPROVED', '2025-04-02T10:00:00Z'), + evt('COMMENTED', '2025-04-03T10:00:00Z'), + ]); + expect(verdict).toEqual({ + state: 'APPROVED', + firstSubmittedAt: '2025-04-02T10:00:00Z', + lastSubmittedAt: '2025-04-03T10:00:00Z', + eventCount: 3, + }); + }); + + it('keeps CHANGES_REQUESTED when it precedes APPROVED', () => { + const verdict = consolidateReviews([ + evt('CHANGES_REQUESTED', '2025-04-01T08:00:00Z'), + evt('APPROVED', '2025-04-05T08:00:00Z'), + ]); + expect(verdict.state).toBe('CHANGES_REQUESTED'); + expect(verdict.firstSubmittedAt).toBe('2025-04-01T08:00:00Z'); + expect(verdict.lastSubmittedAt).toBe('2025-04-05T08:00:00Z'); + expect(verdict.eventCount).toBe(2); + }); + + it('falls back to first event when only DISMISSED entries exist', () => { + const verdict = consolidateReviews([ + evt('DISMISSED', '2025-04-02T08:00:00Z'), + evt('DISMISSED', '2025-04-03T08:00:00Z'), + ]); + expect(verdict.state).toBe('DISMISSED'); + expect(verdict.firstSubmittedAt).toBe('2025-04-02T08:00:00Z'); + expect(verdict.lastSubmittedAt).toBe('2025-04-03T08:00:00Z'); + expect(verdict.eventCount).toBe(2); + }); + + it('handles only COMMENTED entries', () => { + const verdict = consolidateReviews([ + evt('COMMENTED', '2025-04-01T10:00:00Z'), + evt('COMMENTED', '2025-04-02T10:00:00Z'), + ]); + expect(verdict.state).toBe('COMMENTED'); + expect(verdict.firstSubmittedAt).toBe('2025-04-01T10:00:00Z'); + expect(verdict.lastSubmittedAt).toBe('2025-04-02T10:00:00Z'); + }); + + it('is order-independent', () => { + const a = consolidateReviews([ + evt('COMMENTED', '2025-04-01T10:00:00Z'), + evt('APPROVED', '2025-04-02T10:00:00Z'), + ]); + const b = consolidateReviews([ + evt('APPROVED', '2025-04-02T10:00:00Z'), + evt('COMMENTED', '2025-04-01T10:00:00Z'), + ]); + expect(a).toEqual(b); + }); + + it('handles null submittedAt by treating it as the earliest', () => { + const verdict = consolidateReviews([ + evt('APPROVED', '2025-04-01T10:00:00Z'), + evt('COMMENTED', null), + ]); + expect(verdict.eventCount).toBe(2); + expect(verdict.state).toBe('APPROVED'); + }); +}); + +describe('buildReviewKey', () => { + it('joins pullRequestId and reviewerId with a colon', () => { + expect(buildReviewKey('pr-1', 'rev-2')).toBe('pr-1:rev-2'); + }); + + it('uses "unknown" when reviewerId is null', () => { + expect(buildReviewKey('pr-1', null)).toBe('pr-1:unknown'); + }); +}); + +describe('buildConsolidatedTitle', () => { + it('formats reviewer login + PR number', () => { + expect(buildConsolidatedTitle('alice', 42)).toBe('alice on PR #42'); + }); + + it('falls back gracefully on missing data', () => { + expect(buildConsolidatedTitle(null, null)).toBe('unknown on PR #?'); + }); +}); + +describe('buildConsolidatedRow', () => { + it('produces the expected upsert payload', () => { + const row = buildConsolidatedRow({ + pullRequestId: 'pr-1', + reviewerId: 'rev-2', + prNumber: 7, + reviewerLogin: 'alice', + events: [ + evt('COMMENTED', '2025-04-01T10:00:00Z'), + evt('APPROVED', '2025-04-02T10:00:00Z'), + evt('COMMENTED', '2025-04-03T10:00:00Z'), + ], + }); + expect(row).toEqual({ + reviewKey: 'pr-1:rev-2', + title: 'alice on PR #7', + state: 'APPROVED', + firstSubmittedAt: '2025-04-02T10:00:00Z', + lastSubmittedAt: '2025-04-03T10:00:00Z', + eventCount: 3, + reviewerId: 'rev-2', + pullRequestId: 'pr-1', + }); + }); +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/utils/consolidate-reviews.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/utils/consolidate-reviews.ts new file mode 100644 index 0000000000..c27f8ce421 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/utils/consolidate-reviews.ts @@ -0,0 +1,54 @@ +export type ReviewEventState = + | 'APPROVED' + | 'CHANGES_REQUESTED' + | 'COMMENTED' + | 'DISMISSED'; + +export type ReviewEventForConsolidation = { + state: ReviewEventState; + submittedAt: string | null; +}; + +export type ConsolidatedVerdict = { + state: ReviewEventState; + firstSubmittedAt: string | null; + lastSubmittedAt: string | null; + eventCount: number; +}; + +const SUBSTANTIVE_STATES: ReadonlyArray = [ + 'APPROVED', + 'CHANGES_REQUESTED', +]; + +const isSubstantive = (state: ReviewEventState): boolean => + SUBSTANTIVE_STATES.includes(state); + +const compareSubmittedAt = ( + a: ReviewEventForConsolidation, + b: ReviewEventForConsolidation, +): number => { + const av = a.submittedAt ?? ''; + const bv = b.submittedAt ?? ''; + if (av === bv) return 0; + return av < bv ? -1 : 1; +}; + +export const consolidateReviews = ( + events: ReviewEventForConsolidation[], +): ConsolidatedVerdict => { + if (events.length === 0) { + throw new Error('consolidateReviews called with empty events'); + } + + const sorted = [...events].sort(compareSubmittedAt); + const substantive = sorted.find((e) => isSubstantive(e.state)); + const chosen = substantive ?? sorted[0]; + + return { + state: chosen.state, + firstSubmittedAt: chosen.submittedAt, + lastSubmittedAt: sorted[sorted.length - 1].submittedAt, + eventCount: sorted.length, + }; +}; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/views/all-pull-request-reviews.view.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/views/all-pull-request-reviews.view.ts new file mode 100644 index 0000000000..71a05c4f0e --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/views/all-pull-request-reviews.view.ts @@ -0,0 +1,95 @@ +import { + defineView, + ViewKey, + ViewSortDirection, + ViewType, +} from 'twenty-sdk/define'; +import { + PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER, + REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER, + REVIEW_STATE_FIELD_UNIVERSAL_IDENTIFIER, + REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER, + REVIEW_LAST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER, + REVIEW_EVENT_COUNT_FIELD_UNIVERSAL_IDENTIFIER, + PULL_REQUEST_REVIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/modules/github/pull-request-review/objects/pull-request-review.object'; +import { REVIEWER_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/fields/reviewer-on-review.field'; +import { PULL_REQUEST_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/fields/pull-request-on-review.field'; + +export const ALL_REVIEWS_VIEW_UNIVERSAL_IDENTIFIER = + '3559d020-bdf4-424b-9b6a-bc3cbb3d4502'; + +export default defineView({ + universalIdentifier: ALL_REVIEWS_VIEW_UNIVERSAL_IDENTIFIER, + name: 'All Reviews', + objectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER, + type: ViewType.TABLE, + icon: 'IconEye', + key: ViewKey.INDEX, + position: 0, + fields: [ + { + universalIdentifier: '63010877-3fd3-43f0-a105-66168a0c9782', + fieldMetadataUniversalIdentifier: + REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER, + position: 0, + isVisible: true, + size: 240, + }, + { + universalIdentifier: 'c7f3ad93-e4bc-47d6-a3c3-664d9975bb60', + fieldMetadataUniversalIdentifier: + REVIEW_STATE_FIELD_UNIVERSAL_IDENTIFIER, + position: 1, + isVisible: true, + size: 150, + }, + { + universalIdentifier: 'ab0cd954-95e6-4e29-8a73-2ad614dc6b1f', + fieldMetadataUniversalIdentifier: REVIEWER_FIELD_UNIVERSAL_IDENTIFIER, + position: 2, + isVisible: true, + size: 160, + }, + { + universalIdentifier: '43440edf-ef04-413a-a39c-3ae70b839166', + fieldMetadataUniversalIdentifier: + PULL_REQUEST_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER, + position: 3, + isVisible: true, + size: 200, + }, + { + universalIdentifier: '20ddfe2f-0310-4c01-af85-b7c75d801e26', + fieldMetadataUniversalIdentifier: + REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER, + position: 4, + isVisible: true, + size: 170, + }, + { + universalIdentifier: '4e274558-49e9-465a-98c3-44177c6138c3', + fieldMetadataUniversalIdentifier: + REVIEW_LAST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER, + position: 5, + isVisible: true, + size: 170, + }, + { + universalIdentifier: 'd2964ab2-7e05-4ee0-9219-b88cfef78230', + fieldMetadataUniversalIdentifier: + REVIEW_EVENT_COUNT_FIELD_UNIVERSAL_IDENTIFIER, + position: 6, + isVisible: true, + size: 110, + }, + ], + sorts: [ + { + universalIdentifier: 'add26365-5060-411e-b0a7-1ce32155d4f1', + fieldMetadataUniversalIdentifier: + PULL_REQUEST_REVIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + direction: ViewSortDirection.DESC, + }, + ], +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/author-on-pull-request.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/author-on-pull-request.field.ts new file mode 100644 index 0000000000..7b9c97c9e3 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/author-on-pull-request.field.ts @@ -0,0 +1,25 @@ +import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define'; +import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object'; +import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object'; +import { AUTHORED_PRS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/fields/authored-prs-on-contributor.field'; + +export const AUTHOR_FIELD_UNIVERSAL_IDENTIFIER = + 'b2c3d4e5-1a2b-4c3d-8e5f-6a7b8c9d0e1f'; + +export default defineField({ + universalIdentifier: AUTHOR_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'author', + label: 'Author', + icon: 'IconUser', + relationTargetObjectMetadataUniversalIdentifier: + CONTRIBUTOR_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + AUTHORED_PRS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'authorId', + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/merger-on-pull-request.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/merger-on-pull-request.field.ts new file mode 100644 index 0000000000..0bc08d9232 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/merger-on-pull-request.field.ts @@ -0,0 +1,25 @@ +import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define'; +import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object'; +import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object'; +import { MERGED_PULL_REQUESTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/fields/merged-pull-requests-on-contributor.field'; + +export const MERGER_FIELD_UNIVERSAL_IDENTIFIER = + '3a4a6af0-a6a7-4cb6-a91d-273a2842449e'; + +export default defineField({ + universalIdentifier: MERGER_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'merger', + label: 'Merger', + icon: 'IconUser', + relationTargetObjectMetadataUniversalIdentifier: + CONTRIBUTOR_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + MERGED_PULL_REQUESTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'mergerId', + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/project-items-on-pull-request.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/project-items-on-pull-request.field.ts new file mode 100644 index 0000000000..01af097177 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/project-items-on-pull-request.field.ts @@ -0,0 +1,23 @@ +import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; +import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object'; +import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object'; +import { LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/linked-pr-on-project-item.field'; + +export const PROJECT_ITEMS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER = + 'ad5e6f7a-8b9c-4d0e-bf1a-2b3c4d5e6f7a'; + +export default defineField({ + universalIdentifier: PROJECT_ITEMS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'projectItems', + label: 'Project Items', + icon: 'IconLayoutKanban', + relationTargetObjectMetadataUniversalIdentifier: + PROJECT_ITEM_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/review-events-on-pull-request.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/review-events-on-pull-request.field.ts new file mode 100644 index 0000000000..2517afd737 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/review-events-on-pull-request.field.ts @@ -0,0 +1,23 @@ +import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; +import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object'; +import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object'; +import { PULL_REQUEST_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/fields/pull-request-on-review-event.field'; + +export const REVIEW_EVENTS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER = + '299b3ef8-cba0-4f16-a785-81728df09c46'; + +export default defineField({ + universalIdentifier: REVIEW_EVENTS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'reviewEvents', + label: 'Review Events', + icon: 'IconEye', + relationTargetObjectMetadataUniversalIdentifier: + PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + PULL_REQUEST_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/reviews-on-pull-request.field.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/reviews-on-pull-request.field.ts new file mode 100644 index 0000000000..c38cee3654 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/reviews-on-pull-request.field.ts @@ -0,0 +1,23 @@ +import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; +import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object'; +import { PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/objects/pull-request-review.object'; +import { PULL_REQUEST_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/fields/pull-request-on-review.field'; + +export const REVIEWS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER = + '9a089f03-76fe-40ba-9a4d-39b84ba11d03'; + +export default defineField({ + universalIdentifier: REVIEWS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'reviews', + label: 'Reviews', + icon: 'IconEye', + relationTargetObjectMetadataUniversalIdentifier: + PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + PULL_REQUEST_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/front-components/fetch-prs.front-component.tsx b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/front-components/fetch-prs.front-component.tsx new file mode 100644 index 0000000000..055cd9ac3c --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/front-components/fetch-prs.front-component.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from 'react'; +import { defineFrontComponent } from 'twenty-sdk/define'; +import { + enqueueSnackbar, + objectMetadataItem, + unmountFrontComponent, + updateProgress, +} from 'twenty-sdk/front-component'; +import { callAppRoute } from 'src/modules/shared/call-app-route'; +import { retry } from 'src/modules/shared/retry'; + +type CountResponse = { + totalPages: number; + repos: Array<{ owner: string; repo: string; totalCount: number; pages: number }>; +}; + +type FetchPageResponse = { + prCount: number; + reviewCount: number; + totalCount: number; + hasMore: boolean; + endCursor: string | null; +}; + +type SyncStatus = 'syncing' | 'done' | 'error'; + +const FetchPrs = () => { + const [status, setStatus] = useState('syncing'); + + useEffect(() => { + const run = async () => { + try { + const counts = (await callAppRoute( + '/github/count-prs', + {}, + )) as CountResponse; + + if (counts.repos.length === 0) { + throw new Error( + 'No repos resolved. Set GITHUB_REPOS in the application variables.', + ); + } + + const totalPages = Math.max(counts.totalPages, 1); + let pagesProcessed = 0; + let totalPrs = 0; + let totalReviews = 0; + + for (const { owner, repo } of counts.repos) { + let cursor: string | null = null; + let hasMore = true; + + while (hasMore) { + const cursorTag = cursor ? `@${cursor.slice(0, 8)}` : ''; + const data = (await retry( + `fetch-prs ${owner}/${repo}${cursorTag}`, + () => + callAppRoute('/github/fetch-prs', { + owner, + repo, + cursor, + }), + )) as FetchPageResponse; + + totalPrs += data.prCount; + totalReviews += data.reviewCount; + hasMore = data.hasMore && data.prCount > 0; + cursor = data.endCursor; + pagesProcessed++; + + updateProgress(Math.min(Math.round((pagesProcessed / totalPages) * 100), 99)); + } + } + + updateProgress(100); + enqueueSnackbar({ + message: `Fetched ${totalPrs} PRs and ${totalReviews} reviews`, + variant: 'success', + }); + setStatus('done'); + } catch (err) { + const message = + err instanceof Error ? err.message : 'Failed to fetch pull requests'; + enqueueSnackbar({ message, variant: 'error' }); + setStatus('error'); + } finally { + unmountFrontComponent(); + } + }; + run(); + }, []); + + if (status === 'syncing') return
Fetching pull requests...
; + if (status === 'error') return
Failed to fetch pull requests.
; + return
Done
; +}; + +export default defineFrontComponent({ + universalIdentifier: '5e2ba8df-1db5-4964-94d3-44cccfd791a0', + name: 'Fetch Pull Requests', + description: 'Fetches pull requests and reviews from GitHub repos', + isHeadless: true, + component: FetchPrs, + command: { + universalIdentifier: '0b24e6d6-da0c-4c0e-8d88-5bfd7f3cd75a', + label: 'Fetch Pull Requests', + icon: 'IconGitPullRequest', + isPinned: false, + conditionalAvailabilityExpression: + objectMetadataItem.nameSingular === 'pullRequest', + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/github/count-pull-requests.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/github/count-pull-requests.ts new file mode 100644 index 0000000000..a167242ef3 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/github/count-pull-requests.ts @@ -0,0 +1,26 @@ +import { githubGraphql } from 'src/modules/github/connector/github-client'; + +const QUERY = ` +query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + pullRequests(states: [OPEN, CLOSED, MERGED]) { + totalCount + } + } +}`; + +type Response = { + repository: { pullRequests: { totalCount: number } } | null; +}; + +export async function countPullRequests( + owner: string, + name: string, +): Promise { + try { + const data = await githubGraphql(QUERY, { owner, name }); + return data.repository?.pullRequests.totalCount ?? 0; + } catch { + return 0; + } +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/github/fetch-pull-requests.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/github/fetch-pull-requests.ts new file mode 100644 index 0000000000..622fd74f4f --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/github/fetch-pull-requests.ts @@ -0,0 +1,82 @@ +import { + EMPTY_PAGE, + type GithubPage, + githubGraphql, +} from 'src/modules/github/connector/github-client'; + +const QUERY = ` +query($owner: String!, $name: String!, $cursor: String) { + repository(owner: $owner, name: $name) { + pullRequests(first: 100, states: [OPEN, CLOSED, MERGED], orderBy: {field: CREATED_AT, direction: DESC}, after: $cursor) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { + number + title + url + state + merged + mergedAt + closedAt + createdAt + author { login avatarUrl ... on User { databaseId } } + mergedBy { login avatarUrl ... on User { databaseId } } + reviews(first: 100) { + nodes { + databaseId + state + submittedAt + author { login avatarUrl ... on User { databaseId } } + } + } + } + } + } +}`; + +export type GqlPullRequest = { + number: number; + title: string; + url: string; + state: 'OPEN' | 'CLOSED' | 'MERGED'; + merged: boolean; + mergedAt: string | null; + closedAt: string | null; + createdAt: string; + author: { login: string; avatarUrl?: string | null; databaseId?: number } | null; + mergedBy: { login: string; avatarUrl?: string | null; databaseId?: number } | null; + reviews: { + nodes: Array<{ + databaseId: number; + state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'DISMISSED'; + submittedAt: string | null; + author: { login: string; avatarUrl?: string | null; databaseId?: number } | null; + }>; + }; +}; + +type Response = { + repository: { pullRequests: GithubPage } | null; +}; + +export async function fetchPullRequests( + owner: string, + name: string, + cursor: string | null = null, +): Promise<{ + prs: GqlPullRequest[]; + totalCount: number; + hasMore: boolean; + endCursor: string | null; +}> { + const data = await githubGraphql(QUERY, { owner, name, cursor }); + const conn = data.repository?.pullRequests; + if (!conn) return { prs: [], ...EMPTY_PAGE }; + + return { + prs: conn.nodes, + totalCount: conn.totalCount, + hasMore: conn.pageInfo.hasNextPage, + endCursor: conn.pageInfo.endCursor, + }; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/mutations/batch-upsert.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/mutations/batch-upsert.ts new file mode 100644 index 0000000000..92d4241c4f --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/mutations/batch-upsert.ts @@ -0,0 +1,26 @@ +import { chunkedBatchCreate } from 'src/modules/shared/twenty-client'; +import type { LinksFieldValue } from 'src/modules/shared/types'; +import type { PullRequestRow } from 'src/modules/github/pull-request/types/pull-request-row'; + +export async function batchUpsertPullRequests( + items: Array<{ + githubNumber: number; + uniqueIdentifier: string; + name: string; + url: LinksFieldValue; + state: string; + mergedAt: string | null; + closedAt: string | null; + githubCreatedAt: string | null; + authorId: string | null; + mergerId: string | null; + }>, +): Promise { + return chunkedBatchCreate('createPullRequests', items, { + id: true, + githubNumber: true, + uniqueIdentifier: true, + name: true, + state: true, + }) as Promise; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/mutations/update-pull-request.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/mutations/update-pull-request.ts new file mode 100644 index 0000000000..d9db8d7304 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/mutations/update-pull-request.ts @@ -0,0 +1,14 @@ +import { getClient } from 'src/modules/shared/twenty-client'; + +export async function updatePullRequest( + id: string, + data: Record, +): Promise { + const client = getClient(); + await client.mutation({ + updatePullRequest: { + __args: { id, data }, + id: true, + }, + }); +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/queries/find-by-github-number.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/queries/find-by-github-number.ts new file mode 100644 index 0000000000..f58fedf1b5 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/queries/find-by-github-number.ts @@ -0,0 +1,55 @@ +import { getClient } from 'src/modules/shared/twenty-client'; +import type { PullRequestRow } from 'src/modules/github/pull-request/types/pull-request-row'; +import { buildPrUniqueIdentifier } from 'src/modules/github/pull-request/normalizers'; + +const PR_NODE_SELECTION = { + id: true, + name: true, + githubNumber: true, + uniqueIdentifier: true, + url: { primaryLinkLabel: true, primaryLinkUrl: true }, + state: true, + mergedAt: true, + closedAt: true, + githubCreatedAt: true, + authorId: true, + mergerId: true, +} as const; + +export async function findPullRequestByRepoAndNumber( + repo: string, + githubNumber: number, +): Promise { + const client = getClient(); + const uniqueIdentifier = buildPrUniqueIdentifier(repo, githubNumber); + const res = await client.query({ + pullRequests: { + __args: { + filter: { uniqueIdentifier: { eq: uniqueIdentifier } }, + first: 1, + }, + edges: { node: PR_NODE_SELECTION }, + }, + }); + + const node = res.pullRequests?.edges?.[0]?.node; + return (node as PullRequestRow | undefined) ?? null; +} + +export async function findPullRequestByGithubNumber( + githubNumber: number, +): Promise { + const client = getClient(); + const res = await client.query({ + pullRequests: { + __args: { + filter: { githubNumber: { eq: githubNumber } }, + first: 1, + }, + edges: { node: PR_NODE_SELECTION }, + }, + }); + + const node = res.pullRequests?.edges?.[0]?.node; + return (node as PullRequestRow | undefined) ?? null; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/logic-functions/count-prs.logic-function.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/logic-functions/count-prs.logic-function.ts new file mode 100644 index 0000000000..c8fb6424e2 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/logic-functions/count-prs.logic-function.ts @@ -0,0 +1,24 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; +import { countAcrossRepos } from 'src/modules/github/connector/count-across-repos'; +import { countPullRequests } from 'src/modules/github/pull-request/graphql/github/count-pull-requests'; + +type CountPrsPayload = { + repos?: string[]; +}; + +const handler = async (event: RoutePayload) => + countAcrossRepos(event.body?.repos, countPullRequests, 'count-prs'); + +export default defineLogicFunction({ + universalIdentifier: '082227ae-2acc-4320-8d31-62ad6c443da6', + name: 'count-prs', + description: + 'Counts total PR pages across configured repos using GraphQL totalCount', + timeoutSeconds: 30, + handler, + httpRouteTriggerSettings: { + path: '/github/count-prs', + httpMethod: 'POST', + isAuthRequired: true, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/logic-functions/fetch-prs.logic-function.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/logic-functions/fetch-prs.logic-function.ts new file mode 100644 index 0000000000..0d80f96e4e --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/logic-functions/fetch-prs.logic-function.ts @@ -0,0 +1,230 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; +import { + fetchPullRequests, + type GqlPullRequest, +} from 'src/modules/github/pull-request/graphql/github/fetch-pull-requests'; +import { batchUpsertContributors } from 'src/modules/github/contributor/graphql/mutations/batch-upsert'; +import { batchUpsertPullRequests } from 'src/modules/github/pull-request/graphql/mutations/batch-upsert'; +import { batchUpsertReviewEvents } from 'src/modules/github/pull-request-review-event/graphql/mutations/batch-upsert'; +import { batchUpsertConsolidatedReviews } from 'src/modules/github/pull-request-review/graphql/mutations/batch-upsert'; +import { buildConsolidatedRow } from 'src/modules/github/pull-request-review/utils/build-consolidated-row'; +import { dedupeContributors } from 'src/modules/github/contributor/normalizers'; +import { pullRequestFromGraphql } from 'src/modules/github/pull-request/normalizers'; +import { + reviewEventFromGraphql, + type ReviewEventState, +} from 'src/modules/github/pull-request-review-event/normalizers'; +import { timed } from 'src/modules/shared/timing'; +import { isFixtureAllowed } from 'src/modules/shared/fixtures'; + +export type FetchPrsFixturePage = { + prs: GqlPullRequest[]; + totalCount: number; + hasMore: boolean; + endCursor: string | null; +}; + +type FetchPrsPayload = { + owner: string; + repo: string; + cursor?: string | null; + fixturePage?: FetchPrsFixturePage; +}; + +const handler = async (event: RoutePayload) => { + const handlerStart = Date.now(); + const { owner, repo, cursor = null, fixturePage } = event.body ?? {}; + if (!owner || !repo) { + return { error: 'owner and repo are required' }; + } + + const tag = `${owner}/${repo}${cursor ? `@${cursor.slice(0, 8)}` : ''}`; + console.log(`[fetch-prs] start ${tag}${fixturePage ? ' (fixture)' : ''}`); + + const result = + fixturePage && isFixtureAllowed() + ? fixturePage + : await timed(`fetch-prs:github ${tag}`, () => + fetchPullRequests(owner, repo, cursor), + ); + const { prs, totalCount, hasMore, endCursor } = result; + + if (prs.length === 0) { + console.log(`[fetch-prs] empty page for ${tag}`); + return { prCount: 0, reviewCount: 0, totalCount, hasMore: false, endCursor: null }; + } + + const allUsers = prs.flatMap((pr) => [ + pr.author, + pr.mergedBy, + ...pr.reviews.nodes.map((r) => r.author), + ]); + const contributorInputs = dedupeContributors(allUsers); + const contributors = await timed( + `fetch-prs:upsertContributors ${tag} (${contributorInputs.length})`, + () => batchUpsertContributors(contributorInputs), + ); + + const idByLogin = new Map(); + for (const c of contributors) { + if (c.ghLogin) idByLogin.set(c.ghLogin, c.id); + } + + const fullRepo = `${owner}/${repo}`; + + const prData = prs.map((pr) => ({ + ...pullRequestFromGraphql(pr, fullRepo), + authorId: pr.author ? (idByLogin.get(pr.author.login) ?? null) : null, + mergerId: pr.mergedBy ? (idByLogin.get(pr.mergedBy.login) ?? null) : null, + })); + + const prRecords = await timed( + `fetch-prs:upsertPullRequests ${tag} (${prData.length})`, + () => batchUpsertPullRequests(prData), + ); + + const prIdByNumber = new Map(); + for (const rec of prRecords) { + if (rec.githubNumber) prIdByNumber.set(rec.githubNumber, rec.id); + } + + type ReviewEventInput = { + githubReviewId: number; + title: string; + state: string; + submittedAt: string | null; + reviewerId: string | null; + pullRequestId: string; + }; + + type GroupKey = string; + type GroupContext = { + pullRequestId: string; + reviewerId: string | null; + prNumber: number; + reviewerLogin: string | null; + events: { state: ReviewEventState; submittedAt: string | null }[]; + }; + + let skippedReviews = 0; + const reviewEventData: ReviewEventInput[] = []; + const groups = new Map(); + + for (const pr of prs) { + const pullRequestId = prIdByNumber.get(pr.number); + if (!pullRequestId) { + skippedReviews += pr.reviews.nodes.length; + continue; + } + for (const review of pr.reviews.nodes) { + const reviewerId = review.author + ? (idByLogin.get(review.author.login) ?? null) + : null; + const canonical = reviewEventFromGraphql(review); + reviewEventData.push({ + ...canonical, + reviewerId, + pullRequestId, + }); + const key = `${pullRequestId}:${reviewerId ?? 'unknown'}`; + let group = groups.get(key); + if (!group) { + group = { + pullRequestId, + reviewerId, + prNumber: pr.number, + reviewerLogin: review.author?.login ?? null, + events: [], + }; + groups.set(key, group); + } + if (!group.reviewerLogin && review.author?.login) { + group.reviewerLogin = review.author.login; + } + group.events.push({ + state: canonical.state, + submittedAt: canonical.submittedAt, + }); + } + } + + if (skippedReviews > 0) { + console.log( + `[fetch-prs] skipped ${skippedReviews} reviews with no matching PR for ${tag}`, + ); + } + + if (reviewEventData.length > 0) { + await timed( + `fetch-prs:upsertReviewEvents ${tag} (${reviewEventData.length})`, + () => batchUpsertReviewEvents(reviewEventData), + ); + } + + let consolidatedCount = 0; + if (groups.size > 0) { + const consolidatedRows = Array.from(groups.values()).map((group) => + buildConsolidatedRow({ + pullRequestId: group.pullRequestId, + reviewerId: group.reviewerId, + prNumber: group.prNumber, + reviewerLogin: group.reviewerLogin, + events: group.events, + }), + ); + const consolidatedRecords = await timed( + `fetch-prs:upsertConsolidatedReviews ${tag} (${consolidatedRows.length})`, + () => batchUpsertConsolidatedReviews(consolidatedRows), + ); + consolidatedCount = consolidatedRecords.length; + + const reviewIdByKey = new Map(); + for (const rec of consolidatedRecords) { + if (rec.reviewKey && rec.id) reviewIdByKey.set(rec.reviewKey, rec.id); + } + + const eventBackfill: ReviewEventInput[] = reviewEventData + .map((evt) => { + const key = `${evt.pullRequestId}:${evt.reviewerId ?? 'unknown'}`; + const reviewId = reviewIdByKey.get(key); + if (!reviewId) return null; + return { ...evt, reviewId }; + }) + .filter((x): x is ReviewEventInput & { reviewId: string } => x !== null); + + if (eventBackfill.length > 0) { + await timed( + `fetch-prs:backfillReviewIds ${tag} (${eventBackfill.length})`, + () => batchUpsertReviewEvents(eventBackfill), + ); + } + } + + const totalMs = Date.now() - handlerStart; + console.log( + `[fetch-prs] done ${tag} in ${totalMs}ms (prs=${prs.length}, reviewEvents=${reviewEventData.length}, consolidatedReviews=${consolidatedCount}, hasMore=${hasMore})`, + ); + + return { + prCount: prs.length, + reviewCount: reviewEventData.length, + consolidatedReviewCount: consolidatedCount, + totalCount, + hasMore, + endCursor, + }; +}; + +export default defineLogicFunction({ + universalIdentifier: 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', + name: 'fetch-prs', + description: + 'Fetches one page of pull requests with reviews via GitHub GraphQL API and batch upserts them', + timeoutSeconds: 300, + handler, + httpRouteTriggerSettings: { + path: '/github/fetch-prs', + httpMethod: 'POST', + isAuthRequired: true, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/navigation-menu-items/pull-requests.navigation-menu-item.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/navigation-menu-items/pull-requests.navigation-menu-item.ts new file mode 100644 index 0000000000..3c83dbf892 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/navigation-menu-items/pull-requests.navigation-menu-item.ts @@ -0,0 +1,11 @@ +import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define'; +import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object'; +import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item'; + +export default defineNavigationMenuItem({ + universalIdentifier: 'bafa56a5-fd74-4c6f-ab44-d933877edb93', + position: 0, + type: NavigationMenuItemType.OBJECT, + targetObjectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER, + folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER, +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/normalizers.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/normalizers.ts new file mode 100644 index 0000000000..9c7a20c50d --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/normalizers.ts @@ -0,0 +1,64 @@ +import type { LinksFieldValue } from 'src/modules/shared/types'; +import { toLinksField } from 'src/modules/shared/types'; +import type { GitHubPullRequest } from 'src/modules/github/pull-request/types/github-pull-request'; +import type { GqlPullRequest } from 'src/modules/github/pull-request/graphql/github/fetch-pull-requests'; + +export type PullRequestState = 'OPEN' | 'CLOSED' | 'MERGED'; + +export type PullRequestCanonical = { + githubNumber: number; + uniqueIdentifier: string; + name: string; + url: LinksFieldValue; + state: PullRequestState; + mergedAt: string | null; + closedAt: string | null; + githubCreatedAt: string | null; +}; + +export function derivePrState( + merged: boolean | null | undefined, + state: string, +): PullRequestState { + if (merged) return 'MERGED'; + return state.toUpperCase() === 'CLOSED' ? 'CLOSED' : 'OPEN'; +} + +export function buildPrUniqueIdentifier( + repoFullName: string, + number: number, +): string { + return `${repoFullName}#${number}`; +} + +export function pullRequestFromWebhook( + pr: GitHubPullRequest, + repoFullName: string, +): PullRequestCanonical { + return { + githubNumber: pr.number, + uniqueIdentifier: buildPrUniqueIdentifier(repoFullName, pr.number), + name: pr.title, + url: toLinksField(pr.html_url, pr.title), + state: derivePrState(pr.merged, pr.state), + mergedAt: pr.merged_at, + closedAt: pr.closed_at, + githubCreatedAt: pr.created_at, + }; +} + +export function pullRequestFromGraphql( + pr: GqlPullRequest, + repoFullName: string, +): PullRequestCanonical { + return { + githubNumber: pr.number, + uniqueIdentifier: buildPrUniqueIdentifier(repoFullName, pr.number), + name: pr.title, + url: toLinksField(pr.url, pr.title), + state: derivePrState(pr.merged, pr.state), + mergedAt: pr.mergedAt, + closedAt: pr.closedAt, + githubCreatedAt: pr.createdAt, + }; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/objects/pull-request.object.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/objects/pull-request.object.ts new file mode 100644 index 0000000000..452b36d2f1 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/objects/pull-request.object.ts @@ -0,0 +1,133 @@ +import { defineObject, FieldType } from 'twenty-sdk/define'; + +export const PULL_REQUEST_UNIVERSAL_IDENTIFIER = + 'bf539871-f118-4f35-b953-fa2ba528aea3'; + +export const PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER = + '5b394c0a-14db-4582-9513-6671661d64a5'; + +export const MERGED_AT_FIELD_UNIVERSAL_IDENTIFIER = + '064b38ec-ae2e-4d1b-bae9-d8d18329c8cd'; + +export const GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER = + 'a4b5c6d7-8e9f-4a0b-b1c2-d3e4f5a6b7c8'; + +export const URL_FIELD_UNIVERSAL_IDENTIFIER = + 'd4c3b2a1-6f5e-4d3c-9b8a-7f6e5d4c3b2a'; + +export const PR_UNIQUE_IDENTIFIER_FIELD_UNIVERSAL_IDENTIFIER = + '23b446f8-199a-4be4-881a-a475570b9590'; + +enum PullRequestState { + OPEN = 'OPEN', + CLOSED = 'CLOSED', + MERGED = 'MERGED', +} + +export const PR_STATE_FIELD_UNIVERSAL_IDENTIFIER = + 'e4f5a6b7-0c1d-4e2f-a3b4-c5d6e7f8a9b0'; + +export const PR_CLOSED_AT_FIELD_UNIVERSAL_IDENTIFIER = + 'f5a6b7c8-1d2e-4f3a-b4c5-d6e7f8a9b0c1'; + +export const PR_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER = + 'e38deee8-c57f-4b40-8c35-2b5d1fee759e'; + +export const PULL_REQUEST_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER = + '78d142cf-7033-565d-b802-860bde5b0078'; + +export default defineObject({ + universalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER, + nameSingular: 'pullRequest', + namePlural: 'pullRequests', + labelSingular: 'Pull Request', + labelPlural: 'Pull Requests', + icon: 'IconTank', + labelIdentifierFieldMetadataUniversalIdentifier: + PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER, + fields: [ + { + universalIdentifier: PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER, + name: 'name', + type: FieldType.TEXT, + label: 'Name', + icon: 'IconGitPullRequest', + }, + { + universalIdentifier: GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER, + name: 'githubNumber', + type: FieldType.NUMBER, + label: 'PR Number', + icon: 'IconHash', + }, + { + universalIdentifier: URL_FIELD_UNIVERSAL_IDENTIFIER, + name: 'url', + type: FieldType.LINKS, + label: 'URL', + icon: 'IconLink', + }, + { + universalIdentifier: PR_STATE_FIELD_UNIVERSAL_IDENTIFIER, + name: 'state', + type: FieldType.SELECT, + label: 'State', + icon: 'IconCircleDot', + options: [ + { + value: PullRequestState.OPEN, + label: 'Open', + position: 0, + color: 'green', + }, + { + value: PullRequestState.CLOSED, + label: 'Closed', + position: 1, + color: 'red', + }, + { + value: PullRequestState.MERGED, + label: 'Merged', + position: 2, + color: 'purple', + }, + ], + }, + { + universalIdentifier: MERGED_AT_FIELD_UNIVERSAL_IDENTIFIER, + name: 'mergedAt', + type: FieldType.DATE_TIME, + label: 'Merged At', + icon: 'IconCalendarCheck', + isNullable: true, + defaultValue: null, + }, + { + universalIdentifier: PR_CLOSED_AT_FIELD_UNIVERSAL_IDENTIFIER, + name: 'closedAt', + type: FieldType.DATE_TIME, + label: 'Closed At', + icon: 'IconCalendarOff', + isNullable: true, + defaultValue: null, + }, + { + universalIdentifier: PR_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + name: 'githubCreatedAt', + type: FieldType.DATE_TIME, + label: 'GitHub Created At', + icon: 'IconCalendarPlus', + isNullable: true, + defaultValue: null, + }, + { + universalIdentifier: PR_UNIQUE_IDENTIFIER_FIELD_UNIVERSAL_IDENTIFIER, + name: 'uniqueIdentifier', + type: FieldType.TEXT, + label: 'Unique Identifier', + icon: 'IconFingerprint', + isUnique: true, + }, + ], +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/types/github-pull-request.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/types/github-pull-request.ts new file mode 100644 index 0000000000..2a8e84dcd7 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/types/github-pull-request.ts @@ -0,0 +1,14 @@ +import type { GitHubUser } from 'src/modules/github/connector/github-user'; + +export type GitHubPullRequest = { + number: number; + title: string; + html_url: string; + state: 'open' | 'closed'; + merged: boolean | null; + merged_at: string | null; + closed_at: string | null; + created_at: string; + user: GitHubUser; + merged_by: GitHubUser | null; +}; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/types/pull-request-row.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/types/pull-request-row.ts new file mode 100644 index 0000000000..446481e0f7 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/types/pull-request-row.ts @@ -0,0 +1,15 @@ +import type { LinksFieldValue } from 'src/modules/shared/types'; + +export type PullRequestRow = { + id: string; + name?: string | null; + githubNumber?: number | null; + uniqueIdentifier?: string | null; + url?: LinksFieldValue | null; + state?: string | null; + mergedAt?: string | null; + closedAt?: string | null; + githubCreatedAt?: string | null; + authorId?: string | null; + mergerId?: string | null; +}; diff --git a/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/views/all-pull-requests.view.ts b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/views/all-pull-requests.view.ts new file mode 100644 index 0000000000..a37ae5dfee --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/github/pull-request/views/all-pull-requests.view.ts @@ -0,0 +1,84 @@ +import { + defineView, + ViewKey, + ViewSortDirection, + ViewType, +} from 'twenty-sdk/define'; +import { + PULL_REQUEST_UNIVERSAL_IDENTIFIER, + PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER, + GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER, + PR_STATE_FIELD_UNIVERSAL_IDENTIFIER, + MERGED_AT_FIELD_UNIVERSAL_IDENTIFIER, + PULL_REQUEST_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/modules/github/pull-request/objects/pull-request.object'; +import { AUTHOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/author-on-pull-request.field'; +import { MERGER_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/merger-on-pull-request.field'; + +export const ALL_PULL_REQUESTS_VIEW_UNIVERSAL_IDENTIFIER = + 'e6a0bd60-08be-4973-81de-0e15ce8f9bf8'; + +export default defineView({ + universalIdentifier: ALL_PULL_REQUESTS_VIEW_UNIVERSAL_IDENTIFIER, + name: 'All Pull Requests', + objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER, + type: ViewType.TABLE, + icon: 'IconTank', + key: ViewKey.INDEX, + position: 0, + fields: [ + { + universalIdentifier: 'fd4fc905-f099-4553-8043-f677d9fbe0e0', + fieldMetadataUniversalIdentifier: + PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER, + position: 0, + isVisible: true, + size: 250, + }, + { + universalIdentifier: '8848460a-5cb3-455d-91a3-d1916293d5f0', + fieldMetadataUniversalIdentifier: + GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER, + position: 1, + isVisible: true, + size: 100, + }, + { + universalIdentifier: 'c05e00b7-a18a-44fb-970e-27b0b6ecdc7f', + fieldMetadataUniversalIdentifier: + PR_STATE_FIELD_UNIVERSAL_IDENTIFIER, + position: 2, + isVisible: true, + size: 120, + }, + { + universalIdentifier: 'f2834200-06c5-4702-b44d-ac04a292444c', + fieldMetadataUniversalIdentifier: AUTHOR_FIELD_UNIVERSAL_IDENTIFIER, + position: 3, + isVisible: true, + size: 160, + }, + { + universalIdentifier: '93d10703-194f-4e38-bdeb-813097f19d50', + fieldMetadataUniversalIdentifier: MERGER_FIELD_UNIVERSAL_IDENTIFIER, + position: 4, + isVisible: true, + size: 160, + }, + { + universalIdentifier: 'f90a284f-034a-42e8-9a08-38d22f5da983', + fieldMetadataUniversalIdentifier: MERGED_AT_FIELD_UNIVERSAL_IDENTIFIER, + position: 5, + isVisible: true, + size: 180, + }, + ], + sorts: [ + { + universalIdentifier: '7e7497fd-5bcd-43f8-bece-36e82f9f8f3f', + fieldMetadataUniversalIdentifier: + PULL_REQUEST_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER, + direction: ViewSortDirection.DESC, + }, + ], +}); diff --git a/packages/twenty-apps/community/github-connector/src/modules/shared/call-app-route.ts b/packages/twenty-apps/community/github-connector/src/modules/shared/call-app-route.ts new file mode 100644 index 0000000000..814b05074e --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/shared/call-app-route.ts @@ -0,0 +1,50 @@ +const getEnv = (key: string): string | undefined => { + if (typeof process === 'undefined') return undefined; + return process.env?.[key]; +}; + +type ErrorResponse = { + messages?: string[]; + message?: string; + error?: string; + statusCode?: number; +}; + +function extractErrorMessage(text: string, status: number): string { + try { + const json = JSON.parse(text) as ErrorResponse; + if (json.messages?.length) return json.messages[0]; + return json.message ?? json.error ?? `Server error (${status})`; + } catch { + return text || `Server error (${status})`; + } +} + +export async function callAppRoute( + path: string, + body: Record, +): Promise { + const apiUrl = getEnv('TWENTY_API_URL') ?? ''; + const token = getEnv('TWENTY_APP_ACCESS_TOKEN'); + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const res = await fetch(`${apiUrl}/s${path}`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(extractErrorMessage(text, res.status)); + } + + return res.json(); +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/shared/fixtures.ts b/packages/twenty-apps/community/github-connector/src/modules/shared/fixtures.ts new file mode 100644 index 0000000000..a848b3f491 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/shared/fixtures.ts @@ -0,0 +1,3 @@ +export function isFixtureAllowed(): boolean { + return process.env.NODE_ENV !== 'production'; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/shared/retry.ts b/packages/twenty-apps/community/github-connector/src/modules/shared/retry.ts new file mode 100644 index 0000000000..c3fa2e58d8 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/shared/retry.ts @@ -0,0 +1,49 @@ +export type RetryOptions = { + retries?: number; + baseDelayMs?: number; + maxDelayMs?: number; + shouldRetry?: (err: unknown) => boolean; +}; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export async function retry( + label: string, + fn: () => Promise, + options: RetryOptions = {}, +): Promise { + const requestedRetries = options.retries ?? 3; + const retries = Math.max( + Math.floor(Number.isFinite(requestedRetries) ? requestedRetries : 1), + 1, + ); + const baseDelayMs = options.baseDelayMs ?? 500; + const maxDelayMs = options.maxDelayMs ?? 5_000; + const shouldRetry = options.shouldRetry ?? (() => true); + + let lastErr: unknown; + for (let attempt = 1; attempt <= retries; attempt++) { + try { + return await fn(); + } catch (err) { + lastErr = err; + const msg = err instanceof Error ? err.message : String(err); + + if (attempt === retries || !shouldRetry(err)) { + console.log( + `[retry] ${label} attempt ${attempt}/${retries} failed (giving up): ${msg}`, + ); + throw err; + } + + const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1)); + const jittered = delay + Math.floor(Math.random() * baseDelayMs); + console.log( + `[retry] ${label} attempt ${attempt}/${retries} failed, retrying in ${jittered}ms: ${msg}`, + ); + await sleep(jittered); + } + } + + throw lastErr; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/shared/timing.ts b/packages/twenty-apps/community/github-connector/src/modules/shared/timing.ts new file mode 100644 index 0000000000..f24b3c9d5c --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/shared/timing.ts @@ -0,0 +1,17 @@ +export async function timed( + label: string, + fn: () => Promise, +): Promise { + const start = Date.now(); + try { + const result = await fn(); + const ms = Date.now() - start; + console.log(`[timing] ${label} ok in ${ms}ms`); + return result; + } catch (err) { + const ms = Date.now() - start; + const msg = err instanceof Error ? err.message : String(err); + console.log(`[timing] ${label} FAILED in ${ms}ms: ${msg}`); + throw err; + } +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/shared/twenty-client.ts b/packages/twenty-apps/community/github-connector/src/modules/shared/twenty-client.ts new file mode 100644 index 0000000000..20c4309a81 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/shared/twenty-client.ts @@ -0,0 +1,48 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; + +export const getClient = () => new CoreApiClient(); + +const BATCH_CHUNK_SIZE = 50; + +export async function chunkedBatchCreate>( + mutationName: string, + items: T[], + returnFields: Record>, +): Promise[]> { + if (items.length === 0) return []; + + const client = getClient(); + const results: Record[] = []; + const totalChunks = Math.ceil(items.length / BATCH_CHUNK_SIZE); + const overallStart = Date.now(); + + for (let i = 0; i < items.length; i += BATCH_CHUNK_SIZE) { + const chunk = items.slice(i, i + BATCH_CHUNK_SIZE); + const chunkIndex = Math.floor(i / BATCH_CHUNK_SIZE) + 1; + const chunkStart = Date.now(); + + const res = await client.mutation({ + [mutationName]: { + __args: { data: chunk, upsert: true }, + ...returnFields, + }, + }); + + const chunkMs = Date.now() - chunkStart; + console.log( + `[timing] ${mutationName} chunk ${chunkIndex}/${totalChunks} (${chunk.length} rows) in ${chunkMs}ms`, + ); + + const rows = res[mutationName]; + if (Array.isArray(rows)) { + results.push(...(rows as Record[])); + } + } + + const totalMs = Date.now() - overallStart; + console.log( + `[timing] ${mutationName} total ${items.length} rows in ${totalMs}ms (${totalChunks} chunk(s))`, + ); + + return results; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/shared/types.ts b/packages/twenty-apps/community/github-connector/src/modules/shared/types.ts new file mode 100644 index 0000000000..46ed72233a --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/shared/types.ts @@ -0,0 +1,9 @@ +export type LinksFieldValue = { + primaryLinkLabel: string; + primaryLinkUrl: string; + secondaryLinks: null; +}; + +export function toLinksField(url: string, label = ''): LinksFieldValue { + return { primaryLinkLabel: label, primaryLinkUrl: url, secondaryLinks: null }; +} diff --git a/packages/twenty-apps/community/github-connector/src/modules/shared/universal-identifiers.ts b/packages/twenty-apps/community/github-connector/src/modules/shared/universal-identifiers.ts new file mode 100644 index 0000000000..cf4da82f3f --- /dev/null +++ b/packages/twenty-apps/community/github-connector/src/modules/shared/universal-identifiers.ts @@ -0,0 +1,30 @@ +export const APP_DISPLAY_NAME = 'GitHub Connector'; +export const APP_DESCRIPTION = + 'Sync pull requests, reviews, issues, contributors and Projects (v2) items from GitHub into Twenty.'; +export const APP_ABOUT_DESCRIPTION = [ + 'Bring your GitHub activity into Twenty as native records you can view, filter, group and report on alongside the rest of your CRM data.', + '', + '**What gets synced**', + '- Pull requests, pull-request reviews and review events', + '- Issues', + '- Contributors (with avatars, profile links and bot detection)', + '- Projects (v2) items, including custom field values', + '', + '**How it stays in sync**', + '- Real-time updates via GitHub webhooks', + '- On-demand backfills via one-click commands on every record and view', + '- Works with any number of repositories and organizations', + '', + '**Authentication**', + '- GitHub App with private-key JWT (recommended for orgs)', + '- Fine-grained Personal Access Token (great for quick setups)', + '', + '**Out of the box**', + '- A GitHub Dashboard with PR / review counters, weekly histograms and top-contributor leaderboards', + '- A Contributor Stats panel on every contributor record (PRs authored, merged and reviewed over time)', + '- Pre-built views for pull requests, reviews, issues and project items', +].join('\n'); +export const APPLICATION_UNIVERSAL_IDENTIFIER = + '0c5b6bf2-2b41-4d5e-9f8a-3c4e6d7a8b9c'; +export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = + '4e7d8a5f-1b32-4f6a-bc91-2d9c8b7a6e54'; diff --git a/packages/twenty-apps/community/github-connector/tsconfig.json b/packages/twenty-apps/community/github-connector/tsconfig.json new file mode 100644 index 0000000000..d574c8c810 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/tsconfig.json @@ -0,0 +1,42 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "sourceMap": true, + "declaration": true, + "outDir": "./dist", + "rootDir": ".", + "jsx": "react-jsx", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "importHelpers": true, + "allowUnreachableCode": false, + "strict": true, + "alwaysStrict": true, + "noImplicitAny": true, + "strictBindCallApply": false, + "target": "es2018", + "module": "esnext", + "lib": ["es2020", "dom"], + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "resolveJsonModule": true, + "paths": { + "src/*": ["./src/*"], + "~/*": ["./*"] + } + }, + "exclude": [ + "node_modules", + "dist", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.integration-test.ts" + ], + "references": [ + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/twenty-apps/community/github-connector/tsconfig.spec.json b/packages/twenty-apps/community/github-connector/tsconfig.spec.json new file mode 100644 index 0000000000..ea69a6c010 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "types": ["vitest/globals", "node"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/twenty-apps/community/github-connector/vitest.config.ts b/packages/twenty-apps/community/github-connector/vitest.config.ts new file mode 100644 index 0000000000..ae8883e69f --- /dev/null +++ b/packages/twenty-apps/community/github-connector/vitest.config.ts @@ -0,0 +1,30 @@ +import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from 'vitest/config'; + +const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2021'; +const TWENTY_API_KEY = + process.env.TWENTY_API_KEY ?? + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc'; + +process.env.TWENTY_API_URL = TWENTY_API_URL; +process.env.TWENTY_API_KEY = TWENTY_API_KEY; + +export default defineConfig({ + plugins: [ + tsconfigPaths({ + projects: ['tsconfig.spec.json'], + ignoreConfigErrors: true, + }), + ], + test: { + testTimeout: 120_000, + hookTimeout: 120_000, + fileParallelism: false, + include: ['src/**/*.integration-test.ts'], + globalSetup: ['src/__tests__/global-setup.ts'], + env: { + TWENTY_API_URL, + TWENTY_API_KEY, + }, + }, +}); diff --git a/packages/twenty-apps/community/github-connector/yarn.lock b/packages/twenty-apps/community/github-connector/yarn.lock new file mode 100644 index 0000000000..2b54dfa600 --- /dev/null +++ b/packages/twenty-apps/community/github-connector/yarn.lock @@ -0,0 +1,4292 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"@alcalzone/ansi-tokenize@npm:^0.2.4": + version: 0.2.5 + resolution: "@alcalzone/ansi-tokenize@npm:0.2.5" + dependencies: + ansi-styles: "npm:^6.2.1" + is-fullwidth-code-point: "npm:^5.0.0" + checksum: 10c0/dd8622288426b5b7dbf8b68d51d4b930cea591d0e3b9dbc3f523131464d78ac922c165fcb7ba5307f133d35dbcaa0e6648a1c3fb6a0dc2725546d6f867b70af4 + languageName: node + linkType: hard + +"@esbuild/aix-ppc64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/aix-ppc64@npm:0.25.12" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/aix-ppc64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/aix-ppc64@npm:0.27.7" + conditions: os=aix & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/android-arm64@npm:0.25.12" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/android-arm64@npm:0.27.7" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/android-arm@npm:0.25.12" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-arm@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/android-arm@npm:0.27.7" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/android-x64@npm:0.25.12" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/android-x64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/android-x64@npm:0.27.7" + conditions: os=android & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/darwin-arm64@npm:0.25.12" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-arm64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/darwin-arm64@npm:0.27.7" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/darwin-x64@npm:0.25.12" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/darwin-x64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/darwin-x64@npm:0.27.7" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/freebsd-arm64@npm:0.25.12" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-arm64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/freebsd-arm64@npm:0.27.7" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/freebsd-x64@npm:0.25.12" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/freebsd-x64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/freebsd-x64@npm:0.27.7" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-arm64@npm:0.25.12" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-arm64@npm:0.27.7" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-arm@npm:0.25.12" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-arm@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-arm@npm:0.27.7" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-ia32@npm:0.25.12" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-ia32@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-ia32@npm:0.27.7" + conditions: os=linux & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-loong64@npm:0.25.12" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-loong64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-loong64@npm:0.27.7" + conditions: os=linux & cpu=loong64 + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-mips64el@npm:0.25.12" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-mips64el@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-mips64el@npm:0.27.7" + conditions: os=linux & cpu=mips64el + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-ppc64@npm:0.25.12" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-ppc64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-ppc64@npm:0.27.7" + conditions: os=linux & cpu=ppc64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-riscv64@npm:0.25.12" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-riscv64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-riscv64@npm:0.27.7" + conditions: os=linux & cpu=riscv64 + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-s390x@npm:0.25.12" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-s390x@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-s390x@npm:0.27.7" + conditions: os=linux & cpu=s390x + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/linux-x64@npm:0.25.12" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/linux-x64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/linux-x64@npm:0.27.7" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/netbsd-arm64@npm:0.25.12" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/netbsd-arm64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/netbsd-arm64@npm:0.27.7" + conditions: os=netbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/netbsd-x64@npm:0.25.12" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/netbsd-x64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/netbsd-x64@npm:0.27.7" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/openbsd-arm64@npm:0.25.12" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openbsd-arm64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/openbsd-arm64@npm:0.27.7" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/openbsd-x64@npm:0.25.12" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openbsd-x64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/openbsd-x64@npm:0.27.7" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/openharmony-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/openharmony-arm64@npm:0.25.12" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/openharmony-arm64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/openharmony-arm64@npm:0.27.7" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/sunos-x64@npm:0.25.12" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/sunos-x64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/sunos-x64@npm:0.27.7" + conditions: os=sunos & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/win32-arm64@npm:0.25.12" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-arm64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/win32-arm64@npm:0.27.7" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/win32-ia32@npm:0.25.12" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-ia32@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/win32-ia32@npm:0.27.7" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.25.12": + version: 0.25.12 + resolution: "@esbuild/win32-x64@npm:0.25.12" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@esbuild/win32-x64@npm:0.27.7": + version: 0.27.7 + resolution: "@esbuild/win32-x64@npm:0.27.7" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@fastify/busboy@npm:^2.0.0": + version: 2.1.1 + resolution: "@fastify/busboy@npm:2.1.1" + checksum: 10c0/6f8027a8cba7f8f7b736718b013f5a38c0476eea67034c94a0d3c375e2b114366ad4419e6a6fa7ffc2ef9c6d3e0435d76dd584a7a1cbac23962fda7650b579e3 + languageName: node + linkType: hard + +"@genql/cli@npm:^3.0.3": + version: 3.0.5 + resolution: "@genql/cli@npm:3.0.5" + dependencies: + "@graphql-tools/graphql-file-loader": "npm:^7.5.11" + "@graphql-tools/load": "npm:^7.8.6" + fs-extra: "npm:^10.1.0" + graphql: "npm:^16.6.0" + kleur: "npm:^4.1.5" + listr: "npm:^0.14.3" + lodash: "npm:^4.17.21" + mkdirp: "npm:^0.5.1" + native-fetch: "npm:^4.0.2" + prettier: "npm:^2.8.0" + qs: "npm:^6.11.0" + rimraf: "npm:^2.6.3" + undici: "npm:^5.18.0" + yargs: "npm:^15.3.1" + bin: + genql: dist/cli.js + checksum: 10c0/646d4da8986741a8f1b610bd8cfb5bc26cd9a856de671461e4ca87fb05443f37dd0edfb3b30ceaa34cd049077873afa07febfd4714dd2e0681fb07484e19a080 + languageName: node + linkType: hard + +"@genql/runtime@npm:^2.10.0": + version: 2.10.0 + resolution: "@genql/runtime@npm:2.10.0" + dependencies: + "@types/qs": "npm:^6.9.0" + "@types/ws": "npm:^6.0.1" + graphql-query-batcher: "npm:^1.0.1" + isomorphic-unfetch: "npm:^3.0.0" + lodash: "npm:^4.17.20" + subscriptions-transport-ws: "npm:^0.9.16" + tslib: "npm:^2.0.0" + utility-types: "npm:^3.10.0" + ws: "npm:^6.1.4" + zen-observable-ts: "npm:^0.8.21" + peerDependencies: + graphql: "*" + checksum: 10c0/e2a886c2469c933681e2b0ddd6a5b7f4cb12932251ba460e3cf2db4246817da79313ea4ba9769ec7cbe53ab9c1cb81ad8fcce6a969cd241185b79398d2a4f3c6 + languageName: node + linkType: hard + +"@graphql-tools/graphql-file-loader@npm:^7.5.11": + version: 7.5.17 + resolution: "@graphql-tools/graphql-file-loader@npm:7.5.17" + dependencies: + "@graphql-tools/import": "npm:6.7.18" + "@graphql-tools/utils": "npm:^9.2.1" + globby: "npm:^11.0.3" + tslib: "npm:^2.4.0" + unixify: "npm:^1.0.0" + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + checksum: 10c0/f737f14357731ad01da57755e1cf26ce375b475209d6ab7e4b656b56191a8979d2ab7dd5d1c54a1f11e04374f7a373fa95ea5ec6a001d0cef913ea208fadc65b + languageName: node + linkType: hard + +"@graphql-tools/import@npm:6.7.18": + version: 6.7.18 + resolution: "@graphql-tools/import@npm:6.7.18" + dependencies: + "@graphql-tools/utils": "npm:^9.2.1" + resolve-from: "npm:5.0.0" + tslib: "npm:^2.4.0" + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + checksum: 10c0/d33e37a1879dd43ac2851c9bac2f2873c58bb3687f1c06e159760dbb5e540ef074d688df70cc6dbd3ee5de48d437878df8f65a7c65ae80bd025bf98f2d615732 + languageName: node + linkType: hard + +"@graphql-tools/load@npm:^7.8.6": + version: 7.8.14 + resolution: "@graphql-tools/load@npm:7.8.14" + dependencies: + "@graphql-tools/schema": "npm:^9.0.18" + "@graphql-tools/utils": "npm:^9.2.1" + p-limit: "npm:3.1.0" + tslib: "npm:^2.4.0" + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + checksum: 10c0/1fa036ac596ccf48f350aa545d108c173184d9b53247f9e21c0d4ba96c5cba4a0b44281f9154f122e1e8e9d9d6eab93a5b2618ca8a797969bde1e75c1d45e786 + languageName: node + linkType: hard + +"@graphql-tools/merge@npm:^8.4.1": + version: 8.4.2 + resolution: "@graphql-tools/merge@npm:8.4.2" + dependencies: + "@graphql-tools/utils": "npm:^9.2.1" + tslib: "npm:^2.4.0" + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + checksum: 10c0/2df55222b48e010e683572f456cf265aabae5748c59f7c1260c66dec9794b7a076d3706f04da969b77f0a32c7ccb4551fee30125931d3fe9c98a8806aae9a3f4 + languageName: node + linkType: hard + +"@graphql-tools/schema@npm:^9.0.18": + version: 9.0.19 + resolution: "@graphql-tools/schema@npm:9.0.19" + dependencies: + "@graphql-tools/merge": "npm:^8.4.1" + "@graphql-tools/utils": "npm:^9.2.1" + tslib: "npm:^2.4.0" + value-or-promise: "npm:^1.0.12" + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + checksum: 10c0/42fd8ca8d3c8d60b583077c201980518482ff0cd5ed0c1f14bd9b835a2689ad41d02cbd3478f7d7dea7aec1227f7639fd5deb5e6360852a2e542b96b44bfb7a4 + languageName: node + linkType: hard + +"@graphql-tools/utils@npm:^9.2.1": + version: 9.2.1 + resolution: "@graphql-tools/utils@npm:9.2.1" + dependencies: + "@graphql-typed-document-node/core": "npm:^3.1.1" + tslib: "npm:^2.4.0" + peerDependencies: + graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + checksum: 10c0/37a7bd7e14d28ff1bacc007dca84bc6cef2d7d7af9a547b5dbe52fcd134afddd6d4a7b2148cfbaff5ddba91a868453d597da77bd0457fb0be15928f916901606 + languageName: node + linkType: hard + +"@graphql-typed-document-node/core@npm:^3.1.1": + version: 3.2.0 + resolution: "@graphql-typed-document-node/core@npm:3.2.0" + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + checksum: 10c0/94e9d75c1f178bbae8d874f5a9361708a3350c8def7eaeb6920f2c820e82403b7d4f55b3735856d68e145e86c85cbfe2adc444fdc25519cd51f108697e99346c + languageName: node + linkType: hard + +"@inquirer/checkbox@npm:^2.5.0": + version: 2.5.0 + resolution: "@inquirer/checkbox@npm:2.5.0" + dependencies: + "@inquirer/core": "npm:^9.1.0" + "@inquirer/figures": "npm:^1.0.5" + "@inquirer/type": "npm:^1.5.3" + ansi-escapes: "npm:^4.3.2" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/679d17ffe3aef0825593f3bc8d193b6c37b860c6cf6e0e9a10d4e60cc254a2dfc5da4a982bf5b9b5147018e456fffcb0b0dadf93ee1914b9d600b0c814284e22 + languageName: node + linkType: hard + +"@inquirer/confirm@npm:^3.2.0": + version: 3.2.0 + resolution: "@inquirer/confirm@npm:3.2.0" + dependencies: + "@inquirer/core": "npm:^9.1.0" + "@inquirer/type": "npm:^1.5.3" + checksum: 10c0/a2cbfc8ae9c880bba4cce1993f5c399fb0d12741fdd574917c87fceb40ece62ffa60e35aaadf4e62d7c114f54008e45aee5d6d90497bb62d493996c02725d243 + languageName: node + linkType: hard + +"@inquirer/core@npm:^9.1.0": + version: 9.2.1 + resolution: "@inquirer/core@npm:9.2.1" + dependencies: + "@inquirer/figures": "npm:^1.0.6" + "@inquirer/type": "npm:^2.0.0" + "@types/mute-stream": "npm:^0.0.4" + "@types/node": "npm:^22.5.5" + "@types/wrap-ansi": "npm:^3.0.0" + ansi-escapes: "npm:^4.3.2" + cli-width: "npm:^4.1.0" + mute-stream: "npm:^1.0.0" + signal-exit: "npm:^4.1.0" + strip-ansi: "npm:^6.0.1" + wrap-ansi: "npm:^6.2.0" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/11c14be77a9fa85831de799a585721b0a49ab2f3b7d8fd1780c48ea2b29229c6bdc94e7892419086d0f7734136c2ba87b6a32e0782571eae5bbd655b1afad453 + languageName: node + linkType: hard + +"@inquirer/editor@npm:^2.2.0": + version: 2.2.0 + resolution: "@inquirer/editor@npm:2.2.0" + dependencies: + "@inquirer/core": "npm:^9.1.0" + "@inquirer/type": "npm:^1.5.3" + external-editor: "npm:^3.1.0" + checksum: 10c0/b8afc0790a7a5d82998bdfe469cbaa83b0cd0700be432cf95256c548e2a6a494997b5e93d65cbf94979c17b510758cf8494d85559f6b9508eb15d239a7f22aee + languageName: node + linkType: hard + +"@inquirer/expand@npm:^2.3.0": + version: 2.3.0 + resolution: "@inquirer/expand@npm:2.3.0" + dependencies: + "@inquirer/core": "npm:^9.1.0" + "@inquirer/type": "npm:^1.5.3" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/f2030cb482a715e4d5153c19b3f0fd8bf47c16cdc16e1c669e90985386edf4f7b0f3b0e97e2990bb228878b93716228eb067d94fc557c25d3c5ee58747c0a995 + languageName: node + linkType: hard + +"@inquirer/figures@npm:^1.0.5, @inquirer/figures@npm:^1.0.6": + version: 1.0.15 + resolution: "@inquirer/figures@npm:1.0.15" + checksum: 10c0/6e39a040d260ae234ae220180b7994ff852673e20be925f8aa95e78c7934d732b018cbb4d0ec39e600a410461bcb93dca771e7de23caa10630d255692e440f69 + languageName: node + linkType: hard + +"@inquirer/input@npm:^2.3.0": + version: 2.3.0 + resolution: "@inquirer/input@npm:2.3.0" + dependencies: + "@inquirer/core": "npm:^9.1.0" + "@inquirer/type": "npm:^1.5.3" + checksum: 10c0/44c8cea38c9192f528cae556f38709135a00230132deab3b9bb9a925375fce0513fecf4e8c1df7c4319e1ed7aa31fb4dd2c4956c8bc9dd39af087aafff5b6f1f + languageName: node + linkType: hard + +"@inquirer/number@npm:^1.1.0": + version: 1.1.0 + resolution: "@inquirer/number@npm:1.1.0" + dependencies: + "@inquirer/core": "npm:^9.1.0" + "@inquirer/type": "npm:^1.5.3" + checksum: 10c0/db472dab57c951c4a083b2a749ce58262b1efd9889e7603de6e9c3f9af7d8dce8fbdfa3859f65402d3587470e0397a076e5fb4ed775db33310f17a42c9faeb20 + languageName: node + linkType: hard + +"@inquirer/password@npm:^2.2.0": + version: 2.2.0 + resolution: "@inquirer/password@npm:2.2.0" + dependencies: + "@inquirer/core": "npm:^9.1.0" + "@inquirer/type": "npm:^1.5.3" + ansi-escapes: "npm:^4.3.2" + checksum: 10c0/fa4b335164b2c9c3304d29a7214ef93bac8d3da6788146603ea3d0485b8d811151e49bf66cb0dcc729a9dc21406c3a8c2718c5beec572a91d07026d22842c13f + languageName: node + linkType: hard + +"@inquirer/prompts@npm:^5.5.0": + version: 5.5.0 + resolution: "@inquirer/prompts@npm:5.5.0" + dependencies: + "@inquirer/checkbox": "npm:^2.5.0" + "@inquirer/confirm": "npm:^3.2.0" + "@inquirer/editor": "npm:^2.2.0" + "@inquirer/expand": "npm:^2.3.0" + "@inquirer/input": "npm:^2.3.0" + "@inquirer/number": "npm:^1.1.0" + "@inquirer/password": "npm:^2.2.0" + "@inquirer/rawlist": "npm:^2.3.0" + "@inquirer/search": "npm:^1.1.0" + "@inquirer/select": "npm:^2.5.0" + checksum: 10c0/2d62b50ca761b2bd2d5759f48c03758f1af0665ac602c26ae1ae257ac512cf5c27fd82cde108ee0c6371ec39187adc6f45637f31ca79adf5bf96579f23e77143 + languageName: node + linkType: hard + +"@inquirer/rawlist@npm:^2.3.0": + version: 2.3.0 + resolution: "@inquirer/rawlist@npm:2.3.0" + dependencies: + "@inquirer/core": "npm:^9.1.0" + "@inquirer/type": "npm:^1.5.3" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/d49d5e12b7a54394c140b27c8d8748ba1ab855c67c01fa72b5a63810f12865df3bf4d5ae929f54fad77b5fc2f7431a332ae1e5fe4babb335380c28917002f364 + languageName: node + linkType: hard + +"@inquirer/search@npm:^1.1.0": + version: 1.1.0 + resolution: "@inquirer/search@npm:1.1.0" + dependencies: + "@inquirer/core": "npm:^9.1.0" + "@inquirer/figures": "npm:^1.0.5" + "@inquirer/type": "npm:^1.5.3" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/20d7e910266b9e3f0dc8eef8f3007f487e6149fa8421d293eaf7c11a1e35c3d82aa30af118b3a6e35eed1048a27d7d806f45722abb10005db5d099ea64b00b17 + languageName: node + linkType: hard + +"@inquirer/select@npm:^2.5.0": + version: 2.5.0 + resolution: "@inquirer/select@npm:2.5.0" + dependencies: + "@inquirer/core": "npm:^9.1.0" + "@inquirer/figures": "npm:^1.0.5" + "@inquirer/type": "npm:^1.5.3" + ansi-escapes: "npm:^4.3.2" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 10c0/280fa700187ff29da0ad4bf32aa11db776261584ddf5cc1ceac5caebb242a4ac0c5944af522a2579d78b6ec7d6e8b1b9f6564872101abd8dcc69929b4e33fc4c + languageName: node + linkType: hard + +"@inquirer/type@npm:^1.5.3": + version: 1.5.5 + resolution: "@inquirer/type@npm:1.5.5" + dependencies: + mute-stream: "npm:^1.0.0" + checksum: 10c0/4c41736c09ba9426b5a9e44993bdd54e8f532e791518802e33866f233a2a6126a25c1c82c19d1abbf1df627e57b1b957dd3f8318ea96073d8bfc32193943bcb3 + languageName: node + linkType: hard + +"@inquirer/type@npm:^2.0.0": + version: 2.0.0 + resolution: "@inquirer/type@npm:2.0.0" + dependencies: + mute-stream: "npm:^1.0.0" + checksum: 10c0/8c663d52beb2b89a896d3c3d5cc3d6d024fa149e565555bcb42fa640cbe23fba7ff2c51445342cef1fe6e46305e2d16c1590fa1d11ad0ddf93a67b655ef41f0a + languageName: node + linkType: hard + +"@isaacs/fs-minipass@npm:^4.0.0": + version: 4.0.1 + resolution: "@isaacs/fs-minipass@npm:4.0.1" + dependencies: + minipass: "npm:^7.0.4" + checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2 + languageName: node + linkType: hard + +"@jridgewell/sourcemap-codec@npm:^1.5.5": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 + languageName: node + linkType: hard + +"@nodelib/fs.scandir@npm:2.1.5": + version: 2.1.5 + resolution: "@nodelib/fs.scandir@npm:2.1.5" + dependencies: + "@nodelib/fs.stat": "npm:2.0.5" + run-parallel: "npm:^1.1.9" + checksum: 10c0/732c3b6d1b1e967440e65f284bd06e5821fedf10a1bea9ed2bb75956ea1f30e08c44d3def9d6a230666574edbaf136f8cfd319c14fd1f87c66e6a44449afb2eb + languageName: node + linkType: hard + +"@nodelib/fs.stat@npm:2.0.5, @nodelib/fs.stat@npm:^2.0.2": + version: 2.0.5 + resolution: "@nodelib/fs.stat@npm:2.0.5" + checksum: 10c0/88dafe5e3e29a388b07264680dc996c17f4bda48d163a9d4f5c1112979f0ce8ec72aa7116122c350b4e7976bc5566dc3ddb579be1ceaacc727872eb4ed93926d + languageName: node + linkType: hard + +"@nodelib/fs.walk@npm:^1.2.3": + version: 1.2.8 + resolution: "@nodelib/fs.walk@npm:1.2.8" + dependencies: + "@nodelib/fs.scandir": "npm:2.1.5" + fastq: "npm:^1.6.0" + checksum: 10c0/db9de047c3bb9b51f9335a7bb46f4fcfb6829fb628318c12115fbaf7d369bfce71c15b103d1fc3b464812d936220ee9bc1c8f762d032c9f6be9acc99249095b1 + languageName: node + linkType: hard + +"@oxlint/darwin-arm64@npm:0.16.12": + version: 0.16.12 + resolution: "@oxlint/darwin-arm64@npm:0.16.12" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/darwin-x64@npm:0.16.12": + version: 0.16.12 + resolution: "@oxlint/darwin-x64@npm:0.16.12" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@oxlint/linux-arm64-gnu@npm:0.16.12": + version: 0.16.12 + resolution: "@oxlint/linux-arm64-gnu@npm:0.16.12" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/linux-arm64-musl@npm:0.16.12": + version: 0.16.12 + resolution: "@oxlint/linux-arm64-musl@npm:0.16.12" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@oxlint/linux-x64-gnu@npm:0.16.12": + version: 0.16.12 + resolution: "@oxlint/linux-x64-gnu@npm:0.16.12" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/linux-x64-musl@npm:0.16.12": + version: 0.16.12 + resolution: "@oxlint/linux-x64-musl@npm:0.16.12" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@oxlint/win32-arm64@npm:0.16.12": + version: 0.16.12 + resolution: "@oxlint/win32-arm64@npm:0.16.12" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/win32-x64@npm:0.16.12": + version: 0.16.12 + resolution: "@oxlint/win32-x64@npm:0.16.12" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-android-arm-eabi@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.60.2" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@rollup/rollup-android-arm64@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-android-arm64@npm:4.60.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-arm64@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-darwin-arm64@npm:4.60.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-darwin-x64@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-darwin-x64@npm:4.60.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-arm64@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.60.2" + conditions: os=freebsd & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-freebsd-x64@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-freebsd-x64@npm:4.60.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-gnueabihf@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.60.2" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm-musleabihf@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.60.2" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-gnu@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.60.2" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-arm64-musl@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.60.2" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-loong64-gnu@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.60.2" + conditions: os=linux & cpu=loong64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-loong64-musl@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-linux-loong64-musl@npm:4.60.2" + conditions: os=linux & cpu=loong64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-gnu@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.60.2" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-ppc64-musl@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-linux-ppc64-musl@npm:4.60.2" + conditions: os=linux & cpu=ppc64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-gnu@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.60.2" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-riscv64-musl@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.60.2" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-linux-s390x-gnu@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.60.2" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-x64-gnu@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.60.2" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rollup/rollup-linux-x64-musl@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.60.2" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rollup/rollup-openbsd-x64@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-openbsd-x64@npm:4.60.2" + conditions: os=openbsd & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-openharmony-arm64@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-openharmony-arm64@npm:4.60.2" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-arm64-msvc@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.60.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-ia32-msvc@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.60.2" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-gnu@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-win32-x64-gnu@npm:4.60.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rollup/rollup-win32-x64-msvc@npm:4.60.2": + version: 4.60.2 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.60.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@samverschueren/stream-to-observable@npm:^0.3.0": + version: 0.3.1 + resolution: "@samverschueren/stream-to-observable@npm:0.3.1" + dependencies: + any-observable: "npm:^0.3.0" + peerDependenciesMeta: + rxjs: + optional: true + zen-observable: + optional: true + checksum: 10c0/0d874453f6bc2460d71783292291f52feb36c2a75314b1072a6ffe6206562f33e9d664a554348d565a6b54da9041d75070371052545bc329caaa52f64216987f + languageName: node + linkType: hard + +"@sniptt/guards@npm:^0.2.0": + version: 0.2.0 + resolution: "@sniptt/guards@npm:0.2.0" + checksum: 10c0/749bb0f550d1ddd4abdb23dc1076cba26e977659922b73d000bceeb253c09270aaced0d18e92f09d4ce2fdaae33e55f60f2142f5359bc90ba505422af0873526 + languageName: node + linkType: hard + +"@types/chai@npm:^5.2.2": + version: 5.2.3 + resolution: "@types/chai@npm:5.2.3" + dependencies: + "@types/deep-eql": "npm:*" + assertion-error: "npm:^2.0.1" + checksum: 10c0/e0ef1de3b6f8045a5e473e867c8565788c444271409d155588504840ad1a53611011f85072188c2833941189400228c1745d78323dac13fcede9c2b28bacfb2f + languageName: node + linkType: hard + +"@types/deep-eql@npm:*": + version: 4.0.2 + resolution: "@types/deep-eql@npm:4.0.2" + checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844 + languageName: node + linkType: hard + +"@types/estree@npm:1.0.8, @types/estree@npm:^1.0.0": + version: 1.0.8 + resolution: "@types/estree@npm:1.0.8" + checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5 + languageName: node + linkType: hard + +"@types/mute-stream@npm:^0.0.4": + version: 0.0.4 + resolution: "@types/mute-stream@npm:0.0.4" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/944730fd7b398c5078de3c3d4d0afeec8584283bc694da1803fdfca14149ea385e18b1b774326f1601baf53898ce6d121a952c51eb62d188ef6fcc41f725c0dc + languageName: node + linkType: hard + +"@types/node@npm:*": + version: 25.6.0 + resolution: "@types/node@npm:25.6.0" + dependencies: + undici-types: "npm:~7.19.0" + checksum: 10c0/d2d2015630ff098a201407f55f5077a20270ae4f465c739b40865cd9933b91b9c5d2b85568eadaf3db0801b91e267333ca7eb39f007428b173d1cdab4b339ac5 + languageName: node + linkType: hard + +"@types/node@npm:^22.5.5": + version: 22.19.17 + resolution: "@types/node@npm:22.19.17" + dependencies: + undici-types: "npm:~6.21.0" + checksum: 10c0/b66c484c0a9f6d88b1ef360b0f487717234ee1a482cb2551ff73d9f3c43a42a777daf4c8a5eee970960728f8fe1f3877d3d8c6ffabcbca74cb401a59db700fa4 + languageName: node + linkType: hard + +"@types/node@npm:^24.7.2": + version: 24.12.2 + resolution: "@types/node@npm:24.12.2" + dependencies: + undici-types: "npm:~7.16.0" + checksum: 10c0/710050c42f89075c4479e4e1e4c2532486b0c41b1e2a8a13ad88641c88b88cdaea87414e19224f30028719737bd70e327edcaa184d50e86b9418941edd7eb02b + languageName: node + linkType: hard + +"@types/qs@npm:^6.9.0": + version: 6.15.0 + resolution: "@types/qs@npm:6.15.0" + checksum: 10c0/1b104cac50e655fc41d7fc1de2c2aba2908c4cf833a555b6808fb4c96752662b439238f2392a15d2590a7a6ca75dbd40e42d9378ac2be0d548ee484954363688 + languageName: node + linkType: hard + +"@types/react@npm:^19.0.0": + version: 19.2.14 + resolution: "@types/react@npm:19.2.14" + dependencies: + csstype: "npm:^3.2.2" + checksum: 10c0/7d25bf41b57719452d86d2ac0570b659210402707313a36ee612666bf11275a1c69824f8c3ee1fdca077ccfe15452f6da8f1224529b917050eb2d861e52b59b7 + languageName: node + linkType: hard + +"@types/wrap-ansi@npm:^3.0.0": + version: 3.0.0 + resolution: "@types/wrap-ansi@npm:3.0.0" + checksum: 10c0/8d8f53363f360f38135301a06b596c295433ad01debd082078c33c6ed98b05a5c8fe8853a88265432126096084f4a135ec1564e3daad631b83296905509f90b3 + languageName: node + linkType: hard + +"@types/ws@npm:^6.0.1": + version: 6.0.4 + resolution: "@types/ws@npm:6.0.4" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/fa958e64596ca9487c3ed6012834de70b47f25d971f1950cfb8e6a99cb77ff340ae82ac7627744e01b58010674ef8ede07d5a2ac29ca9ad0d67a430fcc69ae14 + languageName: node + linkType: hard + +"@vitest/expect@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/expect@npm:3.2.4" + dependencies: + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:3.2.4" + "@vitest/utils": "npm:3.2.4" + chai: "npm:^5.2.0" + tinyrainbow: "npm:^2.0.0" + checksum: 10c0/7586104e3fd31dbe1e6ecaafb9a70131e4197dce2940f727b6a84131eee3decac7b10f9c7c72fa5edbdb68b6f854353bd4c0fa84779e274207fb7379563b10db + languageName: node + linkType: hard + +"@vitest/mocker@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/mocker@npm:3.2.4" + dependencies: + "@vitest/spy": "npm:3.2.4" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.17" + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/f7a4aea19bbbf8f15905847ee9143b6298b2c110f8b64789224cb0ffdc2e96f9802876aa2ca83f1ec1b6e1ff45e822abb34f0054c24d57b29ab18add06536ccd + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:3.2.4, @vitest/pretty-format@npm:^3.2.4": + version: 3.2.4 + resolution: "@vitest/pretty-format@npm:3.2.4" + dependencies: + tinyrainbow: "npm:^2.0.0" + checksum: 10c0/5ad7d4278e067390d7d633e307fee8103958806a419ca380aec0e33fae71b44a64415f7a9b4bc11635d3c13d4a9186111c581d3cef9c65cc317e68f077456887 + languageName: node + linkType: hard + +"@vitest/runner@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/runner@npm:3.2.4" + dependencies: + "@vitest/utils": "npm:3.2.4" + pathe: "npm:^2.0.3" + strip-literal: "npm:^3.0.0" + checksum: 10c0/e8be51666c72b3668ae3ea348b0196656a4a5adb836cb5e270720885d9517421815b0d6c98bfdf1795ed02b994b7bfb2b21566ee356a40021f5bf4f6ed4e418a + languageName: node + linkType: hard + +"@vitest/snapshot@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/snapshot@npm:3.2.4" + dependencies: + "@vitest/pretty-format": "npm:3.2.4" + magic-string: "npm:^0.30.17" + pathe: "npm:^2.0.3" + checksum: 10c0/f8301a3d7d1559fd3d59ed51176dd52e1ed5c2d23aa6d8d6aa18787ef46e295056bc726a021698d8454c16ed825ecba163362f42fa90258bb4a98cfd2c9424fc + languageName: node + linkType: hard + +"@vitest/spy@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/spy@npm:3.2.4" + dependencies: + tinyspy: "npm:^4.0.3" + checksum: 10c0/6ebf0b4697dc238476d6b6a60c76ba9eb1dd8167a307e30f08f64149612fd50227682b876420e4c2e09a76334e73f72e3ebf0e350714dc22474258292e202024 + languageName: node + linkType: hard + +"@vitest/utils@npm:3.2.4": + version: 3.2.4 + resolution: "@vitest/utils@npm:3.2.4" + dependencies: + "@vitest/pretty-format": "npm:3.2.4" + loupe: "npm:^3.1.4" + tinyrainbow: "npm:^2.0.0" + checksum: 10c0/024a9b8c8bcc12cf40183c246c244b52ecff861c6deb3477cbf487ac8781ad44c68a9c5fd69f8c1361878e55b97c10d99d511f2597f1f7244b5e5101d028ba64 + languageName: node + linkType: hard + +"abbrev@npm:^4.0.0": + version: 4.0.0 + resolution: "abbrev@npm:4.0.0" + checksum: 10c0/b4cc16935235e80702fc90192e349e32f8ef0ed151ef506aa78c81a7c455ec18375c4125414b99f84b2e055199d66383e787675f0bcd87da7a4dbd59f9eac1d5 + languageName: node + linkType: hard + +"ansi-escapes@npm:^3.0.0": + version: 3.2.0 + resolution: "ansi-escapes@npm:3.2.0" + checksum: 10c0/084e1ce38139ad2406f18a8e7efe2b850ddd06ce3c00f633392d1ce67756dab44fe290e573d09ef3c9a0cb13c12881e0e35a8f77a017d39a0a4ab85ae2fae04f + languageName: node + linkType: hard + +"ansi-escapes@npm:^4.3.2": + version: 4.3.2 + resolution: "ansi-escapes@npm:4.3.2" + dependencies: + type-fest: "npm:^0.21.3" + checksum: 10c0/da917be01871525a3dfcf925ae2977bc59e8c513d4423368645634bf5d4ceba5401574eb705c1e92b79f7292af5a656f78c5725a4b0e1cec97c4b413705c1d50 + languageName: node + linkType: hard + +"ansi-escapes@npm:^7.3.0": + version: 7.3.0 + resolution: "ansi-escapes@npm:7.3.0" + dependencies: + environment: "npm:^1.0.0" + checksum: 10c0/068961d99f0ef28b661a4a9f84a5d645df93ccf3b9b93816cc7d46bbe1913321d4cdf156bb842a4e1e4583b7375c631fa963efb43001c4eb7ff9ab8f78fc0679 + languageName: node + linkType: hard + +"ansi-regex@npm:^2.0.0": + version: 2.1.1 + resolution: "ansi-regex@npm:2.1.1" + checksum: 10c0/78cebaf50bce2cb96341a7230adf28d804611da3ce6bf338efa7b72f06cc6ff648e29f80cd95e582617ba58d5fdbec38abfeed3500a98bce8381a9daec7c548b + languageName: node + linkType: hard + +"ansi-regex@npm:^3.0.0": + version: 3.0.1 + resolution: "ansi-regex@npm:3.0.1" + checksum: 10c0/d108a7498b8568caf4a46eea4f1784ab4e0dfb2e3f3938c697dee21443d622d765c958f2b7e2b9f6b9e55e2e2af0584eaa9915d51782b89a841c28e744e7a167 + languageName: node + linkType: hard + +"ansi-regex@npm:^5.0.1": + version: 5.0.1 + resolution: "ansi-regex@npm:5.0.1" + checksum: 10c0/9a64bb8627b434ba9327b60c027742e5d17ac69277960d041898596271d992d4d52ba7267a63ca10232e29f6107fc8a835f6ce8d719b88c5f8493f8254813737 + languageName: node + linkType: hard + +"ansi-regex@npm:^6.2.2": + version: 6.2.2 + resolution: "ansi-regex@npm:6.2.2" + checksum: 10c0/05d4acb1d2f59ab2cf4b794339c7b168890d44dda4bf0ce01152a8da0213aca207802f930442ce8cd22d7a92f44907664aac6508904e75e038fa944d2601b30f + languageName: node + linkType: hard + +"ansi-styles@npm:^2.2.1": + version: 2.2.1 + resolution: "ansi-styles@npm:2.2.1" + checksum: 10c0/7c68aed4f1857389e7a12f85537ea5b40d832656babbf511cc7ecd9efc52889b9c3e5653a71a6aade783c3c5e0aa223ad4ff8e83c27ac8a666514e6c79068cab + languageName: node + linkType: hard + +"ansi-styles@npm:^3.2.1": + version: 3.2.1 + resolution: "ansi-styles@npm:3.2.1" + dependencies: + color-convert: "npm:^1.9.0" + checksum: 10c0/ece5a8ef069fcc5298f67e3f4771a663129abd174ea2dfa87923a2be2abf6cd367ef72ac87942da00ce85bd1d651d4cd8595aebdb1b385889b89b205860e977b + languageName: node + linkType: hard + +"ansi-styles@npm:^4.0.0": + version: 4.3.0 + resolution: "ansi-styles@npm:4.3.0" + dependencies: + color-convert: "npm:^2.0.1" + checksum: 10c0/895a23929da416f2bd3de7e9cb4eabd340949328ab85ddd6e484a637d8f6820d485f53933446f5291c3b760cbc488beb8e88573dd0f9c7daf83dccc8fe81b041 + languageName: node + linkType: hard + +"ansi-styles@npm:^6.2.1, ansi-styles@npm:^6.2.3": + version: 6.2.3 + resolution: "ansi-styles@npm:6.2.3" + checksum: 10c0/23b8a4ce14e18fb854693b95351e286b771d23d8844057ed2e7d083cd3e708376c3323707ec6a24365f7d7eda3ca00327fe04092e29e551499ec4c8b7bfac868 + languageName: node + linkType: hard + +"any-observable@npm:^0.3.0": + version: 0.3.0 + resolution: "any-observable@npm:0.3.0" + checksum: 10c0/104c2b79c2ac7e6c75b35f8fd62babf73015668f22bd25336c6f848350d91f9e7daf2fddbf1c1b76fe795e89fbc91b49f70a2aec5c69f1acf0562c344f36042b + languageName: node + linkType: hard + +"array-union@npm:^2.1.0": + version: 2.1.0 + resolution: "array-union@npm:2.1.0" + checksum: 10c0/429897e68110374f39b771ec47a7161fc6a8fc33e196857c0a396dc75df0b5f65e4d046674db764330b6bb66b39ef48dd7c53b6a2ee75cfb0681e0c1a7033962 + languageName: node + linkType: hard + +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 + languageName: node + linkType: hard + +"async-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-function@npm:1.0.0" + checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73 + languageName: node + linkType: hard + +"async-generator-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-generator-function@npm:1.0.0" + checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186 + languageName: node + linkType: hard + +"async-limiter@npm:~1.0.0": + version: 1.0.1 + resolution: "async-limiter@npm:1.0.1" + checksum: 10c0/0693d378cfe86842a70d4c849595a0bb50dc44c11649640ca982fa90cbfc74e3cc4753b5a0847e51933f2e9c65ce8e05576e75e5e1fd963a086e673735b35969 + languageName: node + linkType: hard + +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d + languageName: node + linkType: hard + +"auto-bind@npm:^5.0.1": + version: 5.0.1 + resolution: "auto-bind@npm:5.0.1" + checksum: 10c0/a703375350ea7b6e92405d8e6bcc6dbfb84b0d7c7172b33e5788a7593929a18227999ff9aa9c32436741d06d021e6672457b1cec73287efe3fab95cff6627eaf + languageName: node + linkType: hard + +"axios@npm:^1.13.5": + version: 1.15.2 + resolution: "axios@npm:1.15.2" + dependencies: + follow-redirects: "npm:^1.15.11" + form-data: "npm:^4.0.5" + proxy-from-env: "npm:^2.1.0" + checksum: 10c0/4eeae0feeaa7fdc1ef24f81f8b378fdadedf4aebdd6bf224484675160f8744cf17b9b0d1c215279979940f7e8ce463beffa2f713099612e428eac238515c81d5 + languageName: node + linkType: hard + +"backo2@npm:^1.0.2": + version: 1.0.2 + resolution: "backo2@npm:1.0.2" + checksum: 10c0/a9e825a6a38a6d1c4a94476eabc13d6127dfaafb0967baf104affbb67806ae26abbb58dab8d572d2cd21ef06634ff57c3ad48dff14b904e18de1474cc2f22bf3 + languageName: node + linkType: hard + +"balanced-match@npm:^1.0.0": + version: 1.0.2 + resolution: "balanced-match@npm:1.0.2" + checksum: 10c0/9308baf0a7e4838a82bbfd11e01b1cb0f0cf2893bc1676c27c2a8c0e70cbae1c59120c3268517a8ae7fb6376b4639ef81ca22582611dbee4ed28df945134aaee + languageName: node + linkType: hard + +"brace-expansion@npm:^1.1.7": + version: 1.1.14 + resolution: "brace-expansion@npm:1.1.14" + dependencies: + balanced-match: "npm:^1.0.0" + concat-map: "npm:0.0.1" + checksum: 10c0/b6fdac832bc4e36a753658c9ed052c2e1a2be221763b002df25d1efbf7d21724334e726a6cd5eadc72a4b19ec3efb632d629cc003bc9c62f7af7a7915ffa4385 + languageName: node + linkType: hard + +"braces@npm:^3.0.3": + version: 3.0.3 + resolution: "braces@npm:3.0.3" + dependencies: + fill-range: "npm:^7.1.1" + checksum: 10c0/7c6dfd30c338d2997ba77500539227b9d1f85e388a5f43220865201e407e076783d0881f2d297b9f80951b4c957fcf0b51c1d2d24227631643c3f7c284b0aa04 + languageName: node + linkType: hard + +"cac@npm:^6.7.14": + version: 6.7.14 + resolution: "cac@npm:6.7.14" + checksum: 10c0/4ee06aaa7bab8981f0d54e5f5f9d4adcd64058e9697563ce336d8a3878ed018ee18ebe5359b2430eceae87e0758e62ea2019c3f52ae6e211b1bd2e133856cd10 + languageName: node + linkType: hard + +"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" + dependencies: + es-errors: "npm:^1.3.0" + function-bind: "npm:^1.1.2" + checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938 + languageName: node + linkType: hard + +"call-bound@npm:^1.0.2": + version: 1.0.4 + resolution: "call-bound@npm:1.0.4" + dependencies: + call-bind-apply-helpers: "npm:^1.0.2" + get-intrinsic: "npm:^1.3.0" + checksum: 10c0/f4796a6a0941e71c766aea672f63b72bc61234c4f4964dc6d7606e3664c307e7d77845328a8f3359ce39ddb377fed67318f9ee203dea1d47e46165dcf2917644 + languageName: node + linkType: hard + +"camelcase@npm:^5.0.0": + version: 5.3.1 + resolution: "camelcase@npm:5.3.1" + checksum: 10c0/92ff9b443bfe8abb15f2b1513ca182d16126359ad4f955ebc83dc4ddcc4ef3fdd2c078bc223f2673dc223488e75c99b16cc4d056624374b799e6a1555cf61b23 + languageName: node + linkType: hard + +"chai@npm:^5.2.0": + version: 5.3.3 + resolution: "chai@npm:5.3.3" + dependencies: + assertion-error: "npm:^2.0.1" + check-error: "npm:^2.1.1" + deep-eql: "npm:^5.0.1" + loupe: "npm:^3.1.0" + pathval: "npm:^2.0.0" + checksum: 10c0/b360fd4d38861622e5010c2f709736988b05c7f31042305fa3f4e9911f6adb80ccfb4e302068bf8ed10e835c2e2520cba0f5edc13d878b886987e5aa62483f53 + languageName: node + linkType: hard + +"chalk@npm:^1.0.0, chalk@npm:^1.1.3": + version: 1.1.3 + resolution: "chalk@npm:1.1.3" + dependencies: + ansi-styles: "npm:^2.2.1" + escape-string-regexp: "npm:^1.0.2" + has-ansi: "npm:^2.0.0" + strip-ansi: "npm:^3.0.0" + supports-color: "npm:^2.0.0" + checksum: 10c0/28c3e399ec286bb3a7111fd4225ebedb0d7b813aef38a37bca7c498d032459c265ef43404201d5fbb8d888d29090899c95335b4c0cda13e8b126ff15c541cef8 + languageName: node + linkType: hard + +"chalk@npm:^2.4.1": + version: 2.4.2 + resolution: "chalk@npm:2.4.2" + dependencies: + ansi-styles: "npm:^3.2.1" + escape-string-regexp: "npm:^1.0.5" + supports-color: "npm:^5.3.0" + checksum: 10c0/e6543f02ec877732e3a2d1c3c3323ddb4d39fbab687c23f526e25bd4c6a9bf3b83a696e8c769d078e04e5754921648f7821b2a2acfd16c550435fd630026e073 + languageName: node + linkType: hard + +"chalk@npm:^5.3.0, chalk@npm:^5.6.0": + version: 5.6.2 + resolution: "chalk@npm:5.6.2" + checksum: 10c0/99a4b0f0e7991796b1e7e3f52dceb9137cae2a9dfc8fc0784a550dc4c558e15ab32ed70b14b21b52beb2679b4892b41a0aa44249bcb996f01e125d58477c6976 + languageName: node + linkType: hard + +"chardet@npm:^0.7.0": + version: 0.7.0 + resolution: "chardet@npm:0.7.0" + checksum: 10c0/96e4731b9ec8050cbb56ab684e8c48d6c33f7826b755802d14e3ebfdc51c57afeece3ea39bc6b09acc359e4363525388b915e16640c1378053820f5e70d0f27d + languageName: node + linkType: hard + +"check-error@npm:^2.1.1": + version: 2.1.3 + resolution: "check-error@npm:2.1.3" + checksum: 10c0/878e99038fb6476316b74668cd6a498c7e66df3efe48158fa40db80a06ba4258742ac3ee2229c4a2a98c5e73f5dff84eb3e50ceb6b65bbd8f831eafc8338607d + languageName: node + linkType: hard + +"chokidar@npm:^4.0.0": + version: 4.0.3 + resolution: "chokidar@npm:4.0.3" + dependencies: + readdirp: "npm:^4.0.1" + checksum: 10c0/a58b9df05bb452f7d105d9e7229ac82fa873741c0c40ddcc7bb82f8a909fbe3f7814c9ebe9bc9a2bef9b737c0ec6e2d699d179048ef06ad3ec46315df0ebe6ad + languageName: node + linkType: hard + +"chownr@npm:^3.0.0": + version: 3.0.0 + resolution: "chownr@npm:3.0.0" + checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10 + languageName: node + linkType: hard + +"cli-boxes@npm:^3.0.0": + version: 3.0.0 + resolution: "cli-boxes@npm:3.0.0" + checksum: 10c0/4db3e8fbfaf1aac4fb3a6cbe5a2d3fa048bee741a45371b906439b9ffc821c6e626b0f108bdcd3ddf126a4a319409aedcf39a0730573ff050fdd7b6731e99fb9 + languageName: node + linkType: hard + +"cli-cursor@npm:^2.0.0, cli-cursor@npm:^2.1.0": + version: 2.1.0 + resolution: "cli-cursor@npm:2.1.0" + dependencies: + restore-cursor: "npm:^2.0.0" + checksum: 10c0/09ee6d8b5b818d840bf80ec9561eaf696672197d3a02a7daee2def96d5f52ce6e0bbe7afca754ccf14f04830b5a1b4556273e983507d5029f95bba3016618eda + languageName: node + linkType: hard + +"cli-cursor@npm:^4.0.0": + version: 4.0.0 + resolution: "cli-cursor@npm:4.0.0" + dependencies: + restore-cursor: "npm:^4.0.0" + checksum: 10c0/e776e8c3c6727300d0539b0d25160b2bb56aed1a63942753ba1826b012f337a6f4b7ace3548402e4f2f13b5e16bfd751be672c44b203205e7eca8be94afec42c + languageName: node + linkType: hard + +"cli-truncate@npm:^0.2.1": + version: 0.2.1 + resolution: "cli-truncate@npm:0.2.1" + dependencies: + slice-ansi: "npm:0.0.4" + string-width: "npm:^1.0.1" + checksum: 10c0/c6caa5e2b70d841c42f4a2270d6fc7129df915f8911e4afa90c79231ccc857cd819a2c90e0707fde04e51ce56b4d71646b843f6cbaff4f7cdcb3b91ed51f6e89 + languageName: node + linkType: hard + +"cli-truncate@npm:^5.1.1": + version: 5.2.0 + resolution: "cli-truncate@npm:5.2.0" + dependencies: + slice-ansi: "npm:^8.0.0" + string-width: "npm:^8.2.0" + checksum: 10c0/0d4ec94702ca85b64522ac93633837fb5ea7db17b79b1322a60f6045e6ae2b8cd7bd4c1d19ac7d1f9e10e3bbda1112e172e439b68c02b785ee00da8d6a5c5471 + languageName: node + linkType: hard + +"cli-width@npm:^4.1.0": + version: 4.1.0 + resolution: "cli-width@npm:4.1.0" + checksum: 10c0/1fbd56413578f6117abcaf858903ba1f4ad78370a4032f916745fa2c7e390183a9d9029cf837df320b0fdce8137668e522f60a30a5f3d6529ff3872d265a955f + languageName: node + linkType: hard + +"cliui@npm:^6.0.0": + version: 6.0.0 + resolution: "cliui@npm:6.0.0" + dependencies: + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.0" + wrap-ansi: "npm:^6.2.0" + checksum: 10c0/35229b1bb48647e882104cac374c9a18e34bbf0bace0e2cf03000326b6ca3050d6b59545d91e17bfe3705f4a0e2988787aa5cde6331bf5cbbf0164732cef6492 + languageName: node + linkType: hard + +"code-excerpt@npm:^4.0.0": + version: 4.0.0 + resolution: "code-excerpt@npm:4.0.0" + dependencies: + convert-to-spaces: "npm:^2.0.1" + checksum: 10c0/b6c5a06e039cecd2ab6a0e10ee0831de8362107d1f298ca3558b5f9004cb8e0260b02dd6c07f57b9a0e346c76864d2873311ee1989809fdeb05bd5fbbadde773 + languageName: node + linkType: hard + +"code-point-at@npm:^1.0.0": + version: 1.1.0 + resolution: "code-point-at@npm:1.1.0" + checksum: 10c0/33f6b234084e46e6e369b6f0b07949392651b4dde70fc6a592a8d3dafa08d5bb32e3981a02f31f6fc323a26bc03a4c063a9d56834848695bda7611c2417ea2e6 + languageName: node + linkType: hard + +"color-convert@npm:^1.9.0": + version: 1.9.3 + resolution: "color-convert@npm:1.9.3" + dependencies: + color-name: "npm:1.1.3" + checksum: 10c0/5ad3c534949a8c68fca8fbc6f09068f435f0ad290ab8b2f76841b9e6af7e0bb57b98cb05b0e19fe33f5d91e5a8611ad457e5f69e0a484caad1f7487fd0e8253c + languageName: node + linkType: hard + +"color-convert@npm:^2.0.1": + version: 2.0.1 + resolution: "color-convert@npm:2.0.1" + dependencies: + color-name: "npm:~1.1.4" + checksum: 10c0/37e1150172f2e311fe1b2df62c6293a342ee7380da7b9cfdba67ea539909afbd74da27033208d01d6d5cfc65ee7868a22e18d7e7648e004425441c0f8a15a7d7 + languageName: node + linkType: hard + +"color-name@npm:1.1.3": + version: 1.1.3 + resolution: "color-name@npm:1.1.3" + checksum: 10c0/566a3d42cca25b9b3cd5528cd7754b8e89c0eb646b7f214e8e2eaddb69994ac5f0557d9c175eb5d8f0ad73531140d9c47525085ee752a91a2ab15ab459caf6d6 + languageName: node + linkType: hard + +"color-name@npm:~1.1.4": + version: 1.1.4 + resolution: "color-name@npm:1.1.4" + checksum: 10c0/a1a3f914156960902f46f7f56bc62effc6c94e84b2cae157a526b1c1f74b677a47ec602bf68a61abfa2b42d15b7c5651c6dbe72a43af720bc588dff885b10f95 + languageName: node + linkType: hard + +"combined-stream@npm:^1.0.8": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: "npm:~1.0.0" + checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5 + languageName: node + linkType: hard + +"commander@npm:^12.0.0": + version: 12.1.0 + resolution: "commander@npm:12.1.0" + checksum: 10c0/6e1996680c083b3b897bfc1cfe1c58dfbcd9842fd43e1aaf8a795fbc237f65efcc860a3ef457b318e73f29a4f4a28f6403c3d653d021d960e4632dd45bde54a9 + languageName: node + linkType: hard + +"concat-map@npm:0.0.1": + version: 0.0.1 + resolution: "concat-map@npm:0.0.1" + checksum: 10c0/c996b1cfdf95b6c90fee4dae37e332c8b6eb7d106430c17d538034c0ad9a1630cb194d2ab37293b1bdd4d779494beee7786d586a50bd9376fd6f7bcc2bd4c98f + languageName: node + linkType: hard + +"convert-to-spaces@npm:^2.0.1": + version: 2.0.1 + resolution: "convert-to-spaces@npm:2.0.1" + checksum: 10c0/d90aa0e3b6a27f9d5265a8d32def3c5c855b3e823a9db1f26d772f8146d6b91020a2fdfd905ce8048a73fad3aaf836fef8188c67602c374405e2ae8396c4ac46 + languageName: node + linkType: hard + +"csstype@npm:^3.2.2": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce + languageName: node + linkType: hard + +"date-fns@npm:^1.27.2": + version: 1.30.1 + resolution: "date-fns@npm:1.30.1" + checksum: 10c0/bad6ad7c15180121e15d61ad62a4a214c108d66f35b35f5eeb6ade837a3c29aa4444b9528a93a5374b95ba11231c142276351bf52f4d168676f9a1e17ce3726a + languageName: node + linkType: hard + +"debug@npm:^4.1.1, debug@npm:^4.4.1": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 + languageName: node + linkType: hard + +"decamelize@npm:^1.2.0": + version: 1.2.0 + resolution: "decamelize@npm:1.2.0" + checksum: 10c0/85c39fe8fbf0482d4a1e224ef0119db5c1897f8503bcef8b826adff7a1b11414972f6fef2d7dec2ee0b4be3863cf64ac1439137ae9e6af23a3d8dcbe26a5b4b2 + languageName: node + linkType: hard + +"deep-eql@npm:^5.0.1": + version: 5.0.2 + resolution: "deep-eql@npm:5.0.2" + checksum: 10c0/7102cf3b7bb719c6b9c0db2e19bf0aa9318d141581befe8c7ce8ccd39af9eaa4346e5e05adef7f9bd7015da0f13a3a25dcfe306ef79dc8668aedbecb658dd247 + languageName: node + linkType: hard + +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19 + languageName: node + linkType: hard + +"dir-glob@npm:^3.0.1": + version: 3.0.1 + resolution: "dir-glob@npm:3.0.1" + dependencies: + path-type: "npm:^4.0.0" + checksum: 10c0/dcac00920a4d503e38bb64001acb19df4efc14536ada475725e12f52c16777afdee4db827f55f13a908ee7efc0cb282e2e3dbaeeb98c0993dd93d1802d3bf00c + languageName: node + linkType: hard + +"dotenv@npm:^16.4.0": + version: 16.6.1 + resolution: "dotenv@npm:16.6.1" + checksum: 10c0/15ce56608326ea0d1d9414a5c8ee6dcf0fffc79d2c16422b4ac2268e7e2d76ff5a572d37ffe747c377de12005f14b3cc22361e79fc7f1061cce81f77d2c973dc + languageName: node + linkType: hard + +"dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + gopd: "npm:^1.2.0" + checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031 + languageName: node + linkType: hard + +"elegant-spinner@npm:^1.0.1": + version: 1.0.1 + resolution: "elegant-spinner@npm:1.0.1" + checksum: 10c0/df607c83c20fc3ce56c514175dd5d1ee7f667da00cee13d04d32c70d55e76555091fa236689e691cf7dedba17b0020fec635e499cdde84dbea2ef8639314e5f8 + languageName: node + linkType: hard + +"emoji-regex@npm:^10.3.0": + version: 10.6.0 + resolution: "emoji-regex@npm:10.6.0" + checksum: 10c0/1e4aa097bb007301c3b4b1913879ae27327fdc48e93eeefefe3b87e495eb33c5af155300be951b4349ff6ac084f4403dc9eff970acba7c1c572d89396a9a32d7 + languageName: node + linkType: hard + +"emoji-regex@npm:^8.0.0": + version: 8.0.0 + resolution: "emoji-regex@npm:8.0.0" + checksum: 10c0/b6053ad39951c4cf338f9092d7bfba448cdfd46fe6a2a034700b149ac9ffbc137e361cbd3c442297f86bed2e5f7576c1b54cc0a6bf8ef5106cc62f496af35010 + languageName: node + linkType: hard + +"env-paths@npm:^2.2.0": + version: 2.2.1 + resolution: "env-paths@npm:2.2.1" + checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 + languageName: node + linkType: hard + +"environment@npm:^1.0.0": + version: 1.1.0 + resolution: "environment@npm:1.1.0" + checksum: 10c0/fb26434b0b581ab397039e51ff3c92b34924a98b2039dcb47e41b7bca577b9dbf134a8eadb364415c74464b682e2d3afe1a4c0eb9873dc44ea814c5d3103331d + languageName: node + linkType: hard + +"es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c + languageName: node + linkType: hard + +"es-errors@npm:^1.3.0": + version: 1.3.0 + resolution: "es-errors@npm:1.3.0" + checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85 + languageName: node + linkType: hard + +"es-module-lexer@npm:^1.7.0": + version: 1.7.0 + resolution: "es-module-lexer@npm:1.7.0" + checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b + languageName: node + linkType: hard + +"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": + version: 1.1.1 + resolution: "es-object-atoms@npm:1.1.1" + dependencies: + es-errors: "npm:^1.3.0" + checksum: 10c0/65364812ca4daf48eb76e2a3b7a89b3f6a2e62a1c420766ce9f692665a29d94fe41fe88b65f24106f449859549711e4b40d9fb8002d862dfd7eb1c512d10be0c + languageName: node + linkType: hard + +"es-set-tostringtag@npm:^2.1.0": + version: 2.1.0 + resolution: "es-set-tostringtag@npm:2.1.0" + dependencies: + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.6" + has-tostringtag: "npm:^1.0.2" + hasown: "npm:^2.0.2" + checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af + languageName: node + linkType: hard + +"es-toolkit@npm:^1.39.10": + version: 1.46.0 + resolution: "es-toolkit@npm:1.46.0" + dependenciesMeta: + "@trivago/prettier-plugin-sort-imports@4.3.0": + unplugged: true + prettier-plugin-sort-re-exports@0.0.1: + unplugged: true + checksum: 10c0/d52d3602e89621d8cf8e0f74d6b574e7d5296d97ff34b57ae5331735a4296e7c88c8c28fe6e43388c3c6df84fa06179705a3125767ebcc8b76f672bd06dccfb7 + languageName: node + linkType: hard + +"esbuild@npm:^0.25.0": + version: 0.25.12 + resolution: "esbuild@npm:0.25.12" + dependencies: + "@esbuild/aix-ppc64": "npm:0.25.12" + "@esbuild/android-arm": "npm:0.25.12" + "@esbuild/android-arm64": "npm:0.25.12" + "@esbuild/android-x64": "npm:0.25.12" + "@esbuild/darwin-arm64": "npm:0.25.12" + "@esbuild/darwin-x64": "npm:0.25.12" + "@esbuild/freebsd-arm64": "npm:0.25.12" + "@esbuild/freebsd-x64": "npm:0.25.12" + "@esbuild/linux-arm": "npm:0.25.12" + "@esbuild/linux-arm64": "npm:0.25.12" + "@esbuild/linux-ia32": "npm:0.25.12" + "@esbuild/linux-loong64": "npm:0.25.12" + "@esbuild/linux-mips64el": "npm:0.25.12" + "@esbuild/linux-ppc64": "npm:0.25.12" + "@esbuild/linux-riscv64": "npm:0.25.12" + "@esbuild/linux-s390x": "npm:0.25.12" + "@esbuild/linux-x64": "npm:0.25.12" + "@esbuild/netbsd-arm64": "npm:0.25.12" + "@esbuild/netbsd-x64": "npm:0.25.12" + "@esbuild/openbsd-arm64": "npm:0.25.12" + "@esbuild/openbsd-x64": "npm:0.25.12" + "@esbuild/openharmony-arm64": "npm:0.25.12" + "@esbuild/sunos-x64": "npm:0.25.12" + "@esbuild/win32-arm64": "npm:0.25.12" + "@esbuild/win32-ia32": "npm:0.25.12" + "@esbuild/win32-x64": "npm:0.25.12" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/c205357531423220a9de8e1e6c6514242bc9b1666e762cd67ccdf8fdfdc3f1d0bd76f8d9383958b97ad4c953efdb7b6e8c1f9ca5951cd2b7c5235e8755b34a6b + languageName: node + linkType: hard + +"esbuild@npm:^0.27.0": + version: 0.27.7 + resolution: "esbuild@npm:0.27.7" + dependencies: + "@esbuild/aix-ppc64": "npm:0.27.7" + "@esbuild/android-arm": "npm:0.27.7" + "@esbuild/android-arm64": "npm:0.27.7" + "@esbuild/android-x64": "npm:0.27.7" + "@esbuild/darwin-arm64": "npm:0.27.7" + "@esbuild/darwin-x64": "npm:0.27.7" + "@esbuild/freebsd-arm64": "npm:0.27.7" + "@esbuild/freebsd-x64": "npm:0.27.7" + "@esbuild/linux-arm": "npm:0.27.7" + "@esbuild/linux-arm64": "npm:0.27.7" + "@esbuild/linux-ia32": "npm:0.27.7" + "@esbuild/linux-loong64": "npm:0.27.7" + "@esbuild/linux-mips64el": "npm:0.27.7" + "@esbuild/linux-ppc64": "npm:0.27.7" + "@esbuild/linux-riscv64": "npm:0.27.7" + "@esbuild/linux-s390x": "npm:0.27.7" + "@esbuild/linux-x64": "npm:0.27.7" + "@esbuild/netbsd-arm64": "npm:0.27.7" + "@esbuild/netbsd-x64": "npm:0.27.7" + "@esbuild/openbsd-arm64": "npm:0.27.7" + "@esbuild/openbsd-x64": "npm:0.27.7" + "@esbuild/openharmony-arm64": "npm:0.27.7" + "@esbuild/sunos-x64": "npm:0.27.7" + "@esbuild/win32-arm64": "npm:0.27.7" + "@esbuild/win32-ia32": "npm:0.27.7" + "@esbuild/win32-x64": "npm:0.27.7" + dependenciesMeta: + "@esbuild/aix-ppc64": + optional: true + "@esbuild/android-arm": + optional: true + "@esbuild/android-arm64": + optional: true + "@esbuild/android-x64": + optional: true + "@esbuild/darwin-arm64": + optional: true + "@esbuild/darwin-x64": + optional: true + "@esbuild/freebsd-arm64": + optional: true + "@esbuild/freebsd-x64": + optional: true + "@esbuild/linux-arm": + optional: true + "@esbuild/linux-arm64": + optional: true + "@esbuild/linux-ia32": + optional: true + "@esbuild/linux-loong64": + optional: true + "@esbuild/linux-mips64el": + optional: true + "@esbuild/linux-ppc64": + optional: true + "@esbuild/linux-riscv64": + optional: true + "@esbuild/linux-s390x": + optional: true + "@esbuild/linux-x64": + optional: true + "@esbuild/netbsd-arm64": + optional: true + "@esbuild/netbsd-x64": + optional: true + "@esbuild/openbsd-arm64": + optional: true + "@esbuild/openbsd-x64": + optional: true + "@esbuild/openharmony-arm64": + optional: true + "@esbuild/sunos-x64": + optional: true + "@esbuild/win32-arm64": + optional: true + "@esbuild/win32-ia32": + optional: true + "@esbuild/win32-x64": + optional: true + bin: + esbuild: bin/esbuild + checksum: 10c0/ccd51f0555708bc9ff4ec9dc3ac92d3daacd45ecaac949ca8645984c5c323bf8cefe98c2df307418685e0b4ce37f9a3bdbfe8e3651fe632a0059a436195a17d4 + languageName: node + linkType: hard + +"escape-string-regexp@npm:^1.0.2, escape-string-regexp@npm:^1.0.5": + version: 1.0.5 + resolution: "escape-string-regexp@npm:1.0.5" + checksum: 10c0/a968ad453dd0c2724e14a4f20e177aaf32bb384ab41b674a8454afe9a41c5e6fe8903323e0a1052f56289d04bd600f81278edf140b0fcc02f5cac98d0f5b5371 + languageName: node + linkType: hard + +"escape-string-regexp@npm:^2.0.0": + version: 2.0.0 + resolution: "escape-string-regexp@npm:2.0.0" + checksum: 10c0/2530479fe8db57eace5e8646c9c2a9c80fa279614986d16dcc6bcaceb63ae77f05a851ba6c43756d816c61d7f4534baf56e3c705e3e0d884818a46808811c507 + languageName: node + linkType: hard + +"estree-walker@npm:^3.0.3": + version: 3.0.3 + resolution: "estree-walker@npm:3.0.3" + dependencies: + "@types/estree": "npm:^1.0.0" + checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d + languageName: node + linkType: hard + +"eventemitter3@npm:^3.1.0": + version: 3.1.2 + resolution: "eventemitter3@npm:3.1.2" + checksum: 10c0/c67262eccbf85848b7cc6d4abb6c6e34155e15686db2a01c57669fd0d44441a574a19d44d25948b442929e065774cbe5003d8e77eed47674fbf876ac77887793 + languageName: node + linkType: hard + +"expect-type@npm:^1.2.1": + version: 1.3.0 + resolution: "expect-type@npm:1.3.0" + checksum: 10c0/8412b3fe4f392c420ab41dae220b09700e4e47c639a29ba7ba2e83cc6cffd2b4926f7ac9e47d7e277e8f4f02acda76fd6931cb81fd2b382fa9477ef9ada953fd + languageName: node + linkType: hard + +"exponential-backoff@npm:^3.1.1": + version: 3.1.3 + resolution: "exponential-backoff@npm:3.1.3" + checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267 + languageName: node + linkType: hard + +"external-editor@npm:^3.1.0": + version: 3.1.0 + resolution: "external-editor@npm:3.1.0" + dependencies: + chardet: "npm:^0.7.0" + iconv-lite: "npm:^0.4.24" + tmp: "npm:^0.0.33" + checksum: 10c0/c98f1ba3efdfa3c561db4447ff366a6adb5c1e2581462522c56a18bf90dfe4da382f9cd1feee3e330108c3595a854b218272539f311ba1b3298f841eb0fbf339 + languageName: node + linkType: hard + +"fast-glob@npm:^3.2.9": + version: 3.3.3 + resolution: "fast-glob@npm:3.3.3" + dependencies: + "@nodelib/fs.stat": "npm:^2.0.2" + "@nodelib/fs.walk": "npm:^1.2.3" + glob-parent: "npm:^5.1.2" + merge2: "npm:^1.3.0" + micromatch: "npm:^4.0.8" + checksum: 10c0/f6aaa141d0d3384cf73cbcdfc52f475ed293f6d5b65bfc5def368b09163a9f7e5ec2b3014d80f733c405f58e470ee0cc451c2937685045cddcdeaa24199c43fe + languageName: node + linkType: hard + +"fastq@npm:^1.6.0": + version: 1.20.1 + resolution: "fastq@npm:1.20.1" + dependencies: + reusify: "npm:^1.0.4" + checksum: 10c0/e5dd725884decb1f11e5c822221d76136f239d0236f176fab80b7b8f9e7619ae57e6b4e5b73defc21e6b9ef99437ee7b545cff8e6c2c337819633712fa9d352e + languageName: node + linkType: hard + +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f + languageName: node + linkType: hard + +"figures@npm:^1.7.0": + version: 1.7.0 + resolution: "figures@npm:1.7.0" + dependencies: + escape-string-regexp: "npm:^1.0.5" + object-assign: "npm:^4.1.0" + checksum: 10c0/a10942b0eec3372bf61822ab130d2bbecdf527d551b0b013fbe7175b7a0238ead644ee8930a1a3cb872fb9ab2ec27df30e303765a3b70b97852e2e9ee43bdff3 + languageName: node + linkType: hard + +"figures@npm:^2.0.0": + version: 2.0.0 + resolution: "figures@npm:2.0.0" + dependencies: + escape-string-regexp: "npm:^1.0.5" + checksum: 10c0/5dc5a75fec3e7e04ae65d6ce51d28b3e70d4656c51b06996b6fdb2cb5b542df512e3b3c04482f5193a964edddafa5521479ff948fa84e12ff556e53e094ab4ce + languageName: node + linkType: hard + +"fill-range@npm:^7.1.1": + version: 7.1.1 + resolution: "fill-range@npm:7.1.1" + dependencies: + to-regex-range: "npm:^5.0.1" + checksum: 10c0/b75b691bbe065472f38824f694c2f7449d7f5004aa950426a2c28f0306c60db9b880c0b0e4ed819997ffb882d1da02cfcfc819bddc94d71627f5269682edf018 + languageName: node + linkType: hard + +"find-up@npm:^4.1.0": + version: 4.1.0 + resolution: "find-up@npm:4.1.0" + dependencies: + locate-path: "npm:^5.0.0" + path-exists: "npm:^4.0.0" + checksum: 10c0/0406ee89ebeefa2d507feb07ec366bebd8a6167ae74aa4e34fb4c4abd06cf782a3ce26ae4194d70706f72182841733f00551c209fe575cb00bd92104056e78c1 + languageName: node + linkType: hard + +"follow-redirects@npm:^1.15.11": + version: 1.16.0 + resolution: "follow-redirects@npm:1.16.0" + peerDependenciesMeta: + debug: + optional: true + checksum: 10c0/a1e2900163e6f1b4d1ed5c221b607f41decbab65534c63fe7e287e40a5d552a6496e7d9d7d976fa4ba77b4c51c11e5e9f683f10b43011ea11e442ff128d0e181 + languageName: node + linkType: hard + +"form-data@npm:^4.0.5": + version: 4.0.5 + resolution: "form-data@npm:4.0.5" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.2" + mime-types: "npm:^2.1.12" + checksum: 10c0/dd6b767ee0bbd6d84039db12a0fa5a2028160ffbfaba1800695713b46ae974a5f6e08b3356c3195137f8530dcd9dfcb5d5ae1eeff53d0db1e5aad863b619ce3b + languageName: node + linkType: hard + +"fs-extra@npm:^10.1.0": + version: 10.1.0 + resolution: "fs-extra@npm:10.1.0" + dependencies: + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^6.0.1" + universalify: "npm:^2.0.0" + checksum: 10c0/5f579466e7109719d162a9249abbeffe7f426eb133ea486e020b89bc6d67a741134076bf439983f2eb79276ceaf6bd7b7c1e43c3fd67fe889863e69072fb0a5e + languageName: node + linkType: hard + +"fs.realpath@npm:^1.0.0": + version: 1.0.0 + resolution: "fs.realpath@npm:1.0.0" + checksum: 10c0/444cf1291d997165dfd4c0d58b69f0e4782bfd9149fd72faa4fe299e68e0e93d6db941660b37dd29153bf7186672ececa3b50b7e7249477b03fdf850f287c948 + languageName: node + linkType: hard + +"fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60 + conditions: os=darwin + languageName: node + linkType: hard + +"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + +"function-bind@npm:^1.1.2": + version: 1.1.2 + resolution: "function-bind@npm:1.1.2" + checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5 + languageName: node + linkType: hard + +"generator-function@npm:^2.0.0": + version: 2.0.1 + resolution: "generator-function@npm:2.0.1" + checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8 + languageName: node + linkType: hard + +"get-caller-file@npm:^2.0.1": + version: 2.0.5 + resolution: "get-caller-file@npm:2.0.5" + checksum: 10c0/c6c7b60271931fa752aeb92f2b47e355eac1af3a2673f47c9589e8f8a41adc74d45551c1bc57b5e66a80609f10ffb72b6f575e4370d61cc3f7f3aaff01757cde + languageName: node + linkType: hard + +"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.3.1, get-east-asian-width@npm:^1.5.0": + version: 1.5.0 + resolution: "get-east-asian-width@npm:1.5.0" + checksum: 10c0/bff8bbc8d81790b9477f7aa55b1806b9f082a8dc1359fff7bd8b96939622c86b729685afc2bfeb22def1fc6ef1e5228e4d87dd4e6da60bc43a5edfb03c4ee167 + languageName: node + linkType: hard + +"get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": + version: 1.3.1 + resolution: "get-intrinsic@npm:1.3.1" + dependencies: + async-function: "npm:^1.0.0" + async-generator-function: "npm:^1.0.0" + call-bind-apply-helpers: "npm:^1.0.2" + es-define-property: "npm:^1.0.1" + es-errors: "npm:^1.3.0" + es-object-atoms: "npm:^1.1.1" + function-bind: "npm:^1.1.2" + generator-function: "npm:^2.0.0" + get-proto: "npm:^1.0.1" + gopd: "npm:^1.2.0" + has-symbols: "npm:^1.1.0" + hasown: "npm:^2.0.2" + math-intrinsics: "npm:^1.1.0" + checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d + languageName: node + linkType: hard + +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: "npm:^1.0.1" + es-object-atoms: "npm:^1.0.0" + checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c + languageName: node + linkType: hard + +"github-connector@workspace:.": + version: 0.0.0-use.local + resolution: "github-connector@workspace:." + dependencies: + "@types/node": "npm:^24.7.2" + "@types/react": "npm:^19.0.0" + oxlint: "npm:^0.16.0" + react: "npm:^19.0.0" + react-dom: "npm:^19.0.0" + twenty-client-sdk: "npm:2.0.0" + twenty-sdk: "npm:2.0.0" + typescript: "npm:^5.9.3" + vite-tsconfig-paths: "npm:^4.2.1" + vitest: "npm:^3.1.1" + languageName: unknown + linkType: soft + +"glob-parent@npm:^5.1.2": + version: 5.1.2 + resolution: "glob-parent@npm:5.1.2" + dependencies: + is-glob: "npm:^4.0.1" + checksum: 10c0/cab87638e2112bee3f839ef5f6e0765057163d39c66be8ec1602f3823da4692297ad4e972de876ea17c44d652978638d2fd583c6713d0eb6591706825020c9ee + languageName: node + linkType: hard + +"glob@npm:^7.1.3": + version: 7.2.3 + resolution: "glob@npm:7.2.3" + dependencies: + fs.realpath: "npm:^1.0.0" + inflight: "npm:^1.0.4" + inherits: "npm:2" + minimatch: "npm:^3.1.1" + once: "npm:^1.3.0" + path-is-absolute: "npm:^1.0.0" + checksum: 10c0/65676153e2b0c9095100fe7f25a778bf45608eeb32c6048cf307f579649bcc30353277b3b898a3792602c65764e5baa4f643714dfbdfd64ea271d210c7a425fe + languageName: node + linkType: hard + +"globby@npm:^11.0.3": + version: 11.1.0 + resolution: "globby@npm:11.1.0" + dependencies: + array-union: "npm:^2.1.0" + dir-glob: "npm:^3.0.1" + fast-glob: "npm:^3.2.9" + ignore: "npm:^5.2.0" + merge2: "npm:^1.4.1" + slash: "npm:^3.0.0" + checksum: 10c0/b39511b4afe4bd8a7aead3a27c4ade2b9968649abab0a6c28b1a90141b96ca68ca5db1302f7c7bd29eab66bf51e13916b8e0a3d0ac08f75e1e84a39b35691189 + languageName: node + linkType: hard + +"globrex@npm:^0.1.2": + version: 0.1.2 + resolution: "globrex@npm:0.1.2" + checksum: 10c0/a54c029520cf58bda1d8884f72bd49b4cd74e977883268d931fd83bcbd1a9eb96d57c7dbd4ad80148fb9247467ebfb9b215630b2ed7563b2a8de02e1ff7f89d1 + languageName: node + linkType: hard + +"gopd@npm:^1.2.0": + version: 1.2.0 + resolution: "gopd@npm:1.2.0" + checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead + languageName: node + linkType: hard + +"graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.6": + version: 4.2.11 + resolution: "graceful-fs@npm:4.2.11" + checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 + languageName: node + linkType: hard + +"graphql-query-batcher@npm:^1.0.1": + version: 1.0.1 + resolution: "graphql-query-batcher@npm:1.0.1" + checksum: 10c0/804d0f4064721a2116a16b9eac422e9233e85f4ab5b250cb8f83662725658ffde35779a8ae8211037f3dd2f9717de8cb63b394ad8057ce22171a83db2471196a + languageName: node + linkType: hard + +"graphql-sse@npm:^2.5.4": + version: 2.6.0 + resolution: "graphql-sse@npm:2.6.0" + peerDependencies: + graphql: ">=0.11 <=16" + checksum: 10c0/e05f0b5c8539d61e5ce34af8e0bb418c02bf922d6a7f9232a9abd53c77df5654684ca14f674ac771645c8fba9c37ce666c5d06f46eef54ea07e63653468065b0 + languageName: node + linkType: hard + +"graphql@npm:^16.6.0, graphql@npm:^16.8.1": + version: 16.13.2 + resolution: "graphql@npm:16.13.2" + checksum: 10c0/64e822a0a0e4398781e4bc9765b88d370c08261498b517add4b878038ef7be2005b6b394a79a5102b9379d57052f60bc7f23fec8f39808d101984a74772ebd9d + languageName: node + linkType: hard + +"has-ansi@npm:^2.0.0": + version: 2.0.0 + resolution: "has-ansi@npm:2.0.0" + dependencies: + ansi-regex: "npm:^2.0.0" + checksum: 10c0/f54e4887b9f8f3c4bfefd649c48825b3c093987c92c27880ee9898539e6f01aed261e82e73153c3f920fde0db5bf6ebd58deb498ed1debabcb4bc40113ccdf05 + languageName: node + linkType: hard + +"has-flag@npm:^3.0.0": + version: 3.0.0 + resolution: "has-flag@npm:3.0.0" + checksum: 10c0/1c6c83b14b8b1b3c25b0727b8ba3e3b647f99e9e6e13eb7322107261de07a4c1be56fc0d45678fc376e09772a3a1642ccdaf8fc69bdf123b6c086598397ce473 + languageName: node + linkType: hard + +"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0": + version: 1.1.0 + resolution: "has-symbols@npm:1.1.0" + checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e + languageName: node + linkType: hard + +"has-tostringtag@npm:^1.0.2": + version: 1.0.2 + resolution: "has-tostringtag@npm:1.0.2" + dependencies: + has-symbols: "npm:^1.0.3" + checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c + languageName: node + linkType: hard + +"hasown@npm:^2.0.2": + version: 2.0.3 + resolution: "hasown@npm:2.0.3" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10c0/f5eb28c3fd0d3e4facd821c1eeee3836c37b70ab0b0fc532e8a39976e18fef43652415dadc52f8c7a5ff6d5ac93b7bef128789aa6f90f4e9b9a9083dce74ab38 + languageName: node + linkType: hard + +"iconv-lite@npm:^0.4.24": + version: 0.4.24 + resolution: "iconv-lite@npm:0.4.24" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3" + checksum: 10c0/c6886a24cc00f2a059767440ec1bc00d334a89f250db8e0f7feb4961c8727118457e27c495ba94d082e51d3baca378726cd110aaf7ded8b9bbfd6a44760cf1d4 + languageName: node + linkType: hard + +"ignore@npm:^5.2.0": + version: 5.3.2 + resolution: "ignore@npm:5.3.2" + checksum: 10c0/f9f652c957983634ded1e7f02da3b559a0d4cc210fca3792cb67f1b153623c9c42efdc1c4121af171e295444459fc4a9201101fb041b1104a3c000bccb188337 + languageName: node + linkType: hard + +"indent-string@npm:^3.0.0": + version: 3.2.0 + resolution: "indent-string@npm:3.2.0" + checksum: 10c0/91b6d61621d24944c5c4d365d6f1ff4a490264ccaf1162a602faa0d323e69231db2180ad4ccc092c2f49cf8888cdb3da7b73e904cc0fdeec40d0bfb41ceb9478 + languageName: node + linkType: hard + +"indent-string@npm:^5.0.0": + version: 5.0.0 + resolution: "indent-string@npm:5.0.0" + checksum: 10c0/8ee77b57d92e71745e133f6f444d6fa3ed503ad0e1bcd7e80c8da08b42375c07117128d670589725ed07b1978065803fa86318c309ba45415b7fe13e7f170220 + languageName: node + linkType: hard + +"inflight@npm:^1.0.4": + version: 1.0.6 + resolution: "inflight@npm:1.0.6" + dependencies: + once: "npm:^1.3.0" + wrappy: "npm:1" + checksum: 10c0/7faca22584600a9dc5b9fca2cd5feb7135ac8c935449837b315676b4c90aa4f391ec4f42240178244b5a34e8bede1948627fda392ca3191522fc46b34e985ab2 + languageName: node + linkType: hard + +"inherits@npm:2": + version: 2.0.4 + resolution: "inherits@npm:2.0.4" + checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 + languageName: node + linkType: hard + +"ink@npm:^6.8.0": + version: 6.8.0 + resolution: "ink@npm:6.8.0" + dependencies: + "@alcalzone/ansi-tokenize": "npm:^0.2.4" + ansi-escapes: "npm:^7.3.0" + ansi-styles: "npm:^6.2.1" + auto-bind: "npm:^5.0.1" + chalk: "npm:^5.6.0" + cli-boxes: "npm:^3.0.0" + cli-cursor: "npm:^4.0.0" + cli-truncate: "npm:^5.1.1" + code-excerpt: "npm:^4.0.0" + es-toolkit: "npm:^1.39.10" + indent-string: "npm:^5.0.0" + is-in-ci: "npm:^2.0.0" + patch-console: "npm:^2.0.0" + react-reconciler: "npm:^0.33.0" + scheduler: "npm:^0.27.0" + signal-exit: "npm:^3.0.7" + slice-ansi: "npm:^8.0.0" + stack-utils: "npm:^2.0.6" + string-width: "npm:^8.1.1" + terminal-size: "npm:^4.0.1" + type-fest: "npm:^5.4.1" + widest-line: "npm:^6.0.0" + wrap-ansi: "npm:^9.0.0" + ws: "npm:^8.18.0" + yoga-layout: "npm:~3.2.1" + peerDependencies: + "@types/react": ">=19.0.0" + react: ">=19.0.0" + react-devtools-core: ">=6.1.2" + peerDependenciesMeta: + "@types/react": + optional: true + react-devtools-core: + optional: true + checksum: 10c0/50500e547fdf6a1f1d836d6befbd4770e3ab649ef0be1884500a6da411fb68a90e22dd7dcc9c404911d30e9f87506b3b9d8e997c6c6ceac85ee054b4dadefaff + languageName: node + linkType: hard + +"inquirer@npm:^10.0.0": + version: 10.2.2 + resolution: "inquirer@npm:10.2.2" + dependencies: + "@inquirer/core": "npm:^9.1.0" + "@inquirer/prompts": "npm:^5.5.0" + "@inquirer/type": "npm:^1.5.3" + "@types/mute-stream": "npm:^0.0.4" + ansi-escapes: "npm:^4.3.2" + mute-stream: "npm:^1.0.0" + run-async: "npm:^3.0.0" + rxjs: "npm:^7.8.1" + checksum: 10c0/09bcff887a968ce29d3a8cba749ef35b6531b7fb7bf5b28aaf3e10aebae07af2494dd3ec67ae6f1dabe76da1064cfe4514098d4c7658fcaf3fd480b2975d7163 + languageName: node + linkType: hard + +"is-extglob@npm:^2.1.1": + version: 2.1.1 + resolution: "is-extglob@npm:2.1.1" + checksum: 10c0/5487da35691fbc339700bbb2730430b07777a3c21b9ebaecb3072512dfd7b4ba78ac2381a87e8d78d20ea08affb3f1971b4af629173a6bf435ff8a4c47747912 + languageName: node + linkType: hard + +"is-fullwidth-code-point@npm:^1.0.0": + version: 1.0.0 + resolution: "is-fullwidth-code-point@npm:1.0.0" + dependencies: + number-is-nan: "npm:^1.0.0" + checksum: 10c0/12acfcf16142f2d431bf6af25d68569d3198e81b9799b4ae41058247aafcc666b0127d64384ea28e67a746372611fcbe9b802f69175287aba466da3eddd5ba0f + languageName: node + linkType: hard + +"is-fullwidth-code-point@npm:^2.0.0": + version: 2.0.0 + resolution: "is-fullwidth-code-point@npm:2.0.0" + checksum: 10c0/e58f3e4a601fc0500d8b2677e26e9fe0cd450980e66adb29d85b6addf7969731e38f8e43ed2ec868a09c101a55ac3d8b78902209269f38c5286bc98f5bc1b4d9 + languageName: node + linkType: hard + +"is-fullwidth-code-point@npm:^3.0.0": + version: 3.0.0 + resolution: "is-fullwidth-code-point@npm:3.0.0" + checksum: 10c0/bb11d825e049f38e04c06373a8d72782eee0205bda9d908cc550ccb3c59b99d750ff9537982e01733c1c94a58e35400661f57042158ff5e8f3e90cf936daf0fc + languageName: node + linkType: hard + +"is-fullwidth-code-point@npm:^5.0.0, is-fullwidth-code-point@npm:^5.1.0": + version: 5.1.0 + resolution: "is-fullwidth-code-point@npm:5.1.0" + dependencies: + get-east-asian-width: "npm:^1.3.1" + checksum: 10c0/c1172c2e417fb73470c56c431851681591f6a17233603a9e6f94b7ba870b2e8a5266506490573b607fb1081318589372034aa436aec07b465c2029c0bc7f07a4 + languageName: node + linkType: hard + +"is-glob@npm:^4.0.1": + version: 4.0.3 + resolution: "is-glob@npm:4.0.3" + dependencies: + is-extglob: "npm:^2.1.1" + checksum: 10c0/17fb4014e22be3bbecea9b2e3a76e9e34ff645466be702f1693e8f1ee1adac84710d0be0bd9f967d6354036fd51ab7c2741d954d6e91dae6bb69714de92c197a + languageName: node + linkType: hard + +"is-in-ci@npm:^2.0.0": + version: 2.0.0 + resolution: "is-in-ci@npm:2.0.0" + bin: + is-in-ci: cli.js + checksum: 10c0/1e1d1056939a681e8206035de5ad84e0404556eaa7622bb55f0f1868b9788bff3df427bc0b1ed5a172623154a90fcb1e759a230817cd73d09435543ae3c71feb + languageName: node + linkType: hard + +"is-number@npm:^7.0.0": + version: 7.0.0 + resolution: "is-number@npm:7.0.0" + checksum: 10c0/b4686d0d3053146095ccd45346461bc8e53b80aeb7671cc52a4de02dbbf7dc0d1d2a986e2fe4ae206984b4d34ef37e8b795ebc4f4295c978373e6575e295d811 + languageName: node + linkType: hard + +"is-observable@npm:^1.1.0": + version: 1.1.0 + resolution: "is-observable@npm:1.1.0" + dependencies: + symbol-observable: "npm:^1.1.0" + checksum: 10c0/cf3166b0822f70ad06e7851e09430166ce658349d54aaa64c93a03320420b9285735821b23164bdce741ff83a86730ac3e53035ce4e2511ed843dbff4105bfa2 + languageName: node + linkType: hard + +"is-promise@npm:^2.1.0": + version: 2.2.2 + resolution: "is-promise@npm:2.2.2" + checksum: 10c0/2dba959812380e45b3df0fb12e7cb4d4528c989c7abb03ececb1d1fd6ab1cbfee956ca9daa587b9db1d8ac3c1e5738cf217bdb3dfd99df8c691be4c00ae09069 + languageName: node + linkType: hard + +"is-stream@npm:^1.1.0": + version: 1.1.0 + resolution: "is-stream@npm:1.1.0" + checksum: 10c0/b8ae7971e78d2e8488d15f804229c6eed7ed36a28f8807a1815938771f4adff0e705218b7dab968270433f67103e4fef98062a0beea55d64835f705ee72c7002 + languageName: node + linkType: hard + +"isexe@npm:^4.0.0": + version: 4.0.0 + resolution: "isexe@npm:4.0.0" + checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce + languageName: node + linkType: hard + +"isomorphic-unfetch@npm:^3.0.0": + version: 3.1.0 + resolution: "isomorphic-unfetch@npm:3.1.0" + dependencies: + node-fetch: "npm:^2.6.1" + unfetch: "npm:^4.2.0" + checksum: 10c0/d3b61fca06304db692b7f76bdfd3a00f410e42cfa7403c3b250546bf71589d18cf2f355922f57198e4cc4a9872d3647b20397a5c3edf1a347c90d57c83cf2a89 + languageName: node + linkType: hard + +"iterall@npm:^1.2.1": + version: 1.3.0 + resolution: "iterall@npm:1.3.0" + checksum: 10c0/40de624e5fe937c4c0e511981b91caea9ff2142bfc0316cccc8506eaa03aa253820cc17c5bc5f0a98706c7268a373e5ebee9af9a0c8a359730cf7c05938b57b5 + languageName: node + linkType: hard + +"js-tokens@npm:^9.0.1": + version: 9.0.1 + resolution: "js-tokens@npm:9.0.1" + checksum: 10c0/68dcab8f233dde211a6b5fd98079783cbcd04b53617c1250e3553ee16ab3e6134f5e65478e41d82f6d351a052a63d71024553933808570f04dbf828d7921e80e + languageName: node + linkType: hard + +"jsonc-parser@npm:^3.2.0": + version: 3.3.1 + resolution: "jsonc-parser@npm:3.3.1" + checksum: 10c0/269c3ae0a0e4f907a914bf334306c384aabb9929bd8c99f909275ebd5c2d3bc70b9bcd119ad794f339dec9f24b6a4ee9cd5a8ab2e6435e730ad4075388fc2ab6 + languageName: node + linkType: hard + +"jsonfile@npm:^6.0.1": + version: 6.2.1 + resolution: "jsonfile@npm:6.2.1" + dependencies: + graceful-fs: "npm:^4.1.6" + universalify: "npm:^2.0.0" + dependenciesMeta: + graceful-fs: + optional: true + checksum: 10c0/e1abf000ecee9942d4d028a8e02dc752617face227d72afd1cfb2187e2433079e625bf82b807a313689db71b6472c6b2b389a2340d2798737b1199a39631c28a + languageName: node + linkType: hard + +"kleur@npm:^4.1.5": + version: 4.1.5 + resolution: "kleur@npm:4.1.5" + checksum: 10c0/e9de6cb49657b6fa70ba2d1448fd3d691a5c4370d8f7bbf1c2f64c24d461270f2117e1b0afe8cb3114f13bbd8e51de158c2a224953960331904e636a5e4c0f2a + languageName: node + linkType: hard + +"listr-silent-renderer@npm:^1.1.1": + version: 1.1.1 + resolution: "listr-silent-renderer@npm:1.1.1" + checksum: 10c0/a13e08ebf863516a757bce4887f05290070772113d89095e9f51a07cf0b11a43a7563a67ff3b287c752c08f6d781fdb2123b02957534e3e0675fb564f2a42e1b + languageName: node + linkType: hard + +"listr-update-renderer@npm:^0.5.0": + version: 0.5.0 + resolution: "listr-update-renderer@npm:0.5.0" + dependencies: + chalk: "npm:^1.1.3" + cli-truncate: "npm:^0.2.1" + elegant-spinner: "npm:^1.0.1" + figures: "npm:^1.7.0" + indent-string: "npm:^3.0.0" + log-symbols: "npm:^1.0.2" + log-update: "npm:^2.3.0" + strip-ansi: "npm:^3.0.1" + peerDependencies: + listr: ^0.14.2 + checksum: 10c0/8ade44bf3dc6146c8e0178000619439e8889792c4689b66be6ce82bd459f5fe462ecb34b05147fb206a8ad60e6d4e6f34c9f48038e18366f867fd972688b8edc + languageName: node + linkType: hard + +"listr-verbose-renderer@npm:^0.5.0": + version: 0.5.0 + resolution: "listr-verbose-renderer@npm:0.5.0" + dependencies: + chalk: "npm:^2.4.1" + cli-cursor: "npm:^2.1.0" + date-fns: "npm:^1.27.2" + figures: "npm:^2.0.0" + checksum: 10c0/041cd1e82da7054f27ae0a914e98b40d15faf9f950ef850578fc6241d3fff3c2d7158a4f6226006e566b4c47bf445be2d254dd1ce5c16569a3a5dcd575bec656 + languageName: node + linkType: hard + +"listr@npm:^0.14.3": + version: 0.14.3 + resolution: "listr@npm:0.14.3" + dependencies: + "@samverschueren/stream-to-observable": "npm:^0.3.0" + is-observable: "npm:^1.1.0" + is-promise: "npm:^2.1.0" + is-stream: "npm:^1.1.0" + listr-silent-renderer: "npm:^1.1.1" + listr-update-renderer: "npm:^0.5.0" + listr-verbose-renderer: "npm:^0.5.0" + p-map: "npm:^2.0.0" + rxjs: "npm:^6.3.3" + checksum: 10c0/753d518218c423f46bee8eeacccecadfd2e414ba9c0f602e7f85fe3f6fa18404dfab0812433aeda4683ee2548358488f597ac1a3d321196baec5d3149b200b10 + languageName: node + linkType: hard + +"locate-path@npm:^5.0.0": + version: 5.0.0 + resolution: "locate-path@npm:5.0.0" + dependencies: + p-locate: "npm:^4.1.0" + checksum: 10c0/33a1c5247e87e022f9713e6213a744557a3e9ec32c5d0b5efb10aa3a38177615bf90221a5592674857039c1a0fd2063b82f285702d37b792d973e9e72ace6c59 + languageName: node + linkType: hard + +"lodash@npm:^4.17.20, lodash@npm:^4.17.21": + version: 4.18.1 + resolution: "lodash@npm:4.18.1" + checksum: 10c0/757228fc68805c59789e82185135cf85f05d0b2d3d54631d680ca79ec21944ec8314d4533639a14b8bcfbd97a517e78960933041a5af17ecb693ec6eecb99a27 + languageName: node + linkType: hard + +"log-symbols@npm:^1.0.2": + version: 1.0.2 + resolution: "log-symbols@npm:1.0.2" + dependencies: + chalk: "npm:^1.0.0" + checksum: 10c0/c64e1fe41d0d043840f8b592d043b8607a836b846506f525a53d99d578561f02f97b2cba1d2b3c30bae5311d64b308d5a392a9930d252b906a9042fc2877da7a + languageName: node + linkType: hard + +"log-update@npm:^2.3.0": + version: 2.3.0 + resolution: "log-update@npm:2.3.0" + dependencies: + ansi-escapes: "npm:^3.0.0" + cli-cursor: "npm:^2.0.0" + wrap-ansi: "npm:^3.0.1" + checksum: 10c0/9bf21b138801ab4770a2bfa735161cf005b869360eaf5003a84ba64ddc5f5c3ce7217f4f1fa79d9c1f510d792213b2c9800327228e94df05859d19b716215d90 + languageName: node + linkType: hard + +"loupe@npm:^3.1.0, loupe@npm:^3.1.4": + version: 3.2.1 + resolution: "loupe@npm:3.2.1" + checksum: 10c0/910c872cba291309664c2d094368d31a68907b6f5913e989d301b5c25f30e97d76d77f23ab3bf3b46d0f601ff0b6af8810c10c31b91d2c6b2f132809ca2cc705 + languageName: node + linkType: hard + +"magic-string@npm:^0.30.17": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a + languageName: node + linkType: hard + +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f + languageName: node + linkType: hard + +"merge2@npm:^1.3.0, merge2@npm:^1.4.1": + version: 1.4.1 + resolution: "merge2@npm:1.4.1" + checksum: 10c0/254a8a4605b58f450308fc474c82ac9a094848081bf4c06778200207820e5193726dc563a0d2c16468810516a5c97d9d3ea0ca6585d23c58ccfff2403e8dbbeb + languageName: node + linkType: hard + +"micromatch@npm:^4.0.8": + version: 4.0.8 + resolution: "micromatch@npm:4.0.8" + dependencies: + braces: "npm:^3.0.3" + picomatch: "npm:^2.3.1" + checksum: 10c0/166fa6eb926b9553f32ef81f5f531d27b4ce7da60e5baf8c021d043b27a388fb95e46a8038d5045877881e673f8134122b59624d5cecbd16eb50a42e7a6b5ca8 + languageName: node + linkType: hard + +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa + languageName: node + linkType: hard + +"mime-types@npm:^2.1.12": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 + languageName: node + linkType: hard + +"mimic-fn@npm:^1.0.0": + version: 1.2.0 + resolution: "mimic-fn@npm:1.2.0" + checksum: 10c0/ad55214aec6094c0af4c0beec1a13787556f8116ed88807cf3f05828500f21f93a9482326bcd5a077ae91e3e8795b4e76b5b4c8bb12237ff0e4043a365516cba + languageName: node + linkType: hard + +"mimic-fn@npm:^2.1.0": + version: 2.1.0 + resolution: "mimic-fn@npm:2.1.0" + checksum: 10c0/b26f5479d7ec6cc2bce275a08f146cf78f5e7b661b18114e2506dd91ec7ec47e7a25bf4360e5438094db0560bcc868079fb3b1fb3892b833c1ecbf63f80c95a4 + languageName: node + linkType: hard + +"minimatch@npm:^3.1.1": + version: 3.1.5 + resolution: "minimatch@npm:3.1.5" + dependencies: + brace-expansion: "npm:^1.1.7" + checksum: 10c0/2ecbdc0d33f07bddb0315a8b5afbcb761307a8778b48f0b312418ccbced99f104a2d17d8aca7573433c70e8ccd1c56823a441897a45e384ea76ef401a26ace70 + languageName: node + linkType: hard + +"minimist@npm:^1.2.6": + version: 1.2.8 + resolution: "minimist@npm:1.2.8" + checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 + languageName: node + linkType: hard + +"minipass@npm:^7.0.4, minipass@npm:^7.1.2": + version: 7.1.3 + resolution: "minipass@npm:7.1.3" + checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb + languageName: node + linkType: hard + +"minizlib@npm:^3.1.0": + version: 3.1.0 + resolution: "minizlib@npm:3.1.0" + dependencies: + minipass: "npm:^7.1.2" + checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec + languageName: node + linkType: hard + +"mkdirp@npm:^0.5.1": + version: 0.5.6 + resolution: "mkdirp@npm:0.5.6" + dependencies: + minimist: "npm:^1.2.6" + bin: + mkdirp: bin/cmd.js + checksum: 10c0/e2e2be789218807b58abced04e7b49851d9e46e88a2f9539242cc8a92c9b5c3a0b9bab360bd3014e02a140fc4fbc58e31176c408b493f8a2a6f4986bd7527b01 + languageName: node + linkType: hard + +"ms@npm:^2.1.3": + version: 2.1.3 + resolution: "ms@npm:2.1.3" + checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 + languageName: node + linkType: hard + +"mute-stream@npm:^1.0.0": + version: 1.0.0 + resolution: "mute-stream@npm:1.0.0" + checksum: 10c0/dce2a9ccda171ec979a3b4f869a102b1343dee35e920146776780de182f16eae459644d187e38d59a3d37adf85685e1c17c38cf7bfda7e39a9880f7a1d10a74c + languageName: node + linkType: hard + +"nanoid@npm:^3.3.11": + version: 3.3.11 + resolution: "nanoid@npm:3.3.11" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/40e7f70b3d15f725ca072dfc4f74e81fcf1fbb02e491cf58ac0c79093adc9b0a73b152bcde57df4b79cd097e13023d7504acb38404a4da7bc1cd8e887b82fe0b + languageName: node + linkType: hard + +"native-fetch@npm:^4.0.2": + version: 4.0.2 + resolution: "native-fetch@npm:4.0.2" + peerDependencies: + undici: "*" + checksum: 10c0/e3b824721daaa628086d9dcd02e8eb12f0a6c5e13a1d182682bae238d80c9bbf3dfd6314a94692ebe20316aa354476804b4df148201d066b46fc552a5794cfab + languageName: node + linkType: hard + +"node-fetch@npm:^2.6.1": + version: 2.7.0 + resolution: "node-fetch@npm:2.7.0" + dependencies: + whatwg-url: "npm:^5.0.0" + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8 + languageName: node + linkType: hard + +"node-gyp@npm:latest": + version: 12.3.0 + resolution: "node-gyp@npm:12.3.0" + dependencies: + env-paths: "npm:^2.2.0" + exponential-backoff: "npm:^3.1.1" + graceful-fs: "npm:^4.2.6" + nopt: "npm:^9.0.0" + proc-log: "npm:^6.0.0" + semver: "npm:^7.3.5" + tar: "npm:^7.5.4" + tinyglobby: "npm:^0.2.12" + undici: "npm:^6.25.0" + which: "npm:^6.0.0" + bin: + node-gyp: bin/node-gyp.js + checksum: 10c0/9d9032b405cbe42f72a105259d9eb679376470c102df4a2dbaa51e07d59bf741dcffb85897087ea9d8318b9cabb824a8978af51508ae142f0239ae1e6a3c2329 + languageName: node + linkType: hard + +"nopt@npm:^9.0.0": + version: 9.0.0 + resolution: "nopt@npm:9.0.0" + dependencies: + abbrev: "npm:^4.0.0" + bin: + nopt: bin/nopt.js + checksum: 10c0/1822eb6f9b020ef6f7a7516d7b64a8036e09666ea55ac40416c36e4b2b343122c3cff0e2f085675f53de1d2db99a2a89a60ccea1d120bcd6a5347bf6ceb4a7fd + languageName: node + linkType: hard + +"normalize-path@npm:^2.1.1": + version: 2.1.1 + resolution: "normalize-path@npm:2.1.1" + dependencies: + remove-trailing-separator: "npm:^1.0.1" + checksum: 10c0/db814326ff88057437233361b4c7e9cac7b54815b051b57f2d341ce89b1d8ec8cbd43e7fa95d7652b3b69ea8fcc294b89b8530d556a84d1bdace94229e1e9a8b + languageName: node + linkType: hard + +"number-is-nan@npm:^1.0.0": + version: 1.0.1 + resolution: "number-is-nan@npm:1.0.1" + checksum: 10c0/cb97149006acc5cd512c13c1838223abdf202e76ddfa059c5e8e7507aff2c3a78cd19057516885a2f6f5b576543dc4f7b6f3c997cc7df53ae26c260855466df5 + languageName: node + linkType: hard + +"object-assign@npm:^4.1.0": + version: 4.1.1 + resolution: "object-assign@npm:4.1.1" + checksum: 10c0/1f4df9945120325d041ccf7b86f31e8bcc14e73d29171e37a7903050e96b81323784ec59f93f102ec635bcf6fa8034ba3ea0a8c7e69fa202b87ae3b6cec5a414 + languageName: node + linkType: hard + +"object-inspect@npm:^1.13.3, object-inspect@npm:^1.13.4": + version: 1.13.4 + resolution: "object-inspect@npm:1.13.4" + checksum: 10c0/d7f8711e803b96ea3191c745d6f8056ce1f2496e530e6a19a0e92d89b0fa3c76d910c31f0aa270432db6bd3b2f85500a376a83aaba849a8d518c8845b3211692 + languageName: node + linkType: hard + +"once@npm:^1.3.0": + version: 1.4.0 + resolution: "once@npm:1.4.0" + dependencies: + wrappy: "npm:1" + checksum: 10c0/5d48aca287dfefabd756621c5dfce5c91a549a93e9fdb7b8246bc4c4790aa2ec17b34a260530474635147aeb631a2dcc8b32c613df0675f96041cbb8244517d0 + languageName: node + linkType: hard + +"onetime@npm:^2.0.0": + version: 2.0.1 + resolution: "onetime@npm:2.0.1" + dependencies: + mimic-fn: "npm:^1.0.0" + checksum: 10c0/b4e44a8c34e70e02251bfb578a6e26d6de6eedbed106cd78211d2fd64d28b6281d54924696554e4e966559644243753ac5df73c87f283b0927533d3315696215 + languageName: node + linkType: hard + +"onetime@npm:^5.1.0": + version: 5.1.2 + resolution: "onetime@npm:5.1.2" + dependencies: + mimic-fn: "npm:^2.1.0" + checksum: 10c0/ffcef6fbb2692c3c40749f31ea2e22677a876daea92959b8a80b521d95cca7a668c884d8b2045d1d8ee7d56796aa405c405462af112a1477594cc63531baeb8f + languageName: node + linkType: hard + +"os-tmpdir@npm:~1.0.2": + version: 1.0.2 + resolution: "os-tmpdir@npm:1.0.2" + checksum: 10c0/f438450224f8e2687605a8dd318f0db694b6293c5d835ae509a69e97c8de38b6994645337e5577f5001115470414638978cc49da1cdcc25106dad8738dc69990 + languageName: node + linkType: hard + +"oxlint@npm:^0.16.0": + version: 0.16.12 + resolution: "oxlint@npm:0.16.12" + dependencies: + "@oxlint/darwin-arm64": "npm:0.16.12" + "@oxlint/darwin-x64": "npm:0.16.12" + "@oxlint/linux-arm64-gnu": "npm:0.16.12" + "@oxlint/linux-arm64-musl": "npm:0.16.12" + "@oxlint/linux-x64-gnu": "npm:0.16.12" + "@oxlint/linux-x64-musl": "npm:0.16.12" + "@oxlint/win32-arm64": "npm:0.16.12" + "@oxlint/win32-x64": "npm:0.16.12" + dependenciesMeta: + "@oxlint/darwin-arm64": + optional: true + "@oxlint/darwin-x64": + optional: true + "@oxlint/linux-arm64-gnu": + optional: true + "@oxlint/linux-arm64-musl": + optional: true + "@oxlint/linux-x64-gnu": + optional: true + "@oxlint/linux-x64-musl": + optional: true + "@oxlint/win32-arm64": + optional: true + "@oxlint/win32-x64": + optional: true + bin: + oxc_language_server: bin/oxc_language_server + oxlint: bin/oxlint + checksum: 10c0/209c3484039c4f1fdd340689e81be93a70fdf74e8c111731e70d81580d223b0029439107c95fa4118cec3c9b03ba6c56624d3dcb8cdc79af222da3561122b3e7 + languageName: node + linkType: hard + +"p-limit@npm:3.1.0": + version: 3.1.0 + resolution: "p-limit@npm:3.1.0" + dependencies: + yocto-queue: "npm:^0.1.0" + checksum: 10c0/9db675949dbdc9c3763c89e748d0ef8bdad0afbb24d49ceaf4c46c02c77d30db4e0652ed36d0a0a7a95154335fab810d95c86153105bb73b3a90448e2bb14e1a + languageName: node + linkType: hard + +"p-limit@npm:^2.2.0": + version: 2.3.0 + resolution: "p-limit@npm:2.3.0" + dependencies: + p-try: "npm:^2.0.0" + checksum: 10c0/8da01ac53efe6a627080fafc127c873da40c18d87b3f5d5492d465bb85ec7207e153948df6b9cbaeb130be70152f874229b8242ee2be84c0794082510af97f12 + languageName: node + linkType: hard + +"p-locate@npm:^4.1.0": + version: 4.1.0 + resolution: "p-locate@npm:4.1.0" + dependencies: + p-limit: "npm:^2.2.0" + checksum: 10c0/1b476ad69ad7f6059744f343b26d51ce091508935c1dbb80c4e0a2f397ffce0ca3a1f9f5cd3c7ce19d7929a09719d5c65fe70d8ee289c3f267cd36f2881813e9 + languageName: node + linkType: hard + +"p-map@npm:^2.0.0": + version: 2.1.0 + resolution: "p-map@npm:2.1.0" + checksum: 10c0/735dae87badd4737a2dd582b6d8f93e49a1b79eabbc9815a4d63a528d5e3523e978e127a21d784cccb637010e32103a40d2aaa3ab23ae60250b1a820ca752043 + languageName: node + linkType: hard + +"p-try@npm:^2.0.0": + version: 2.2.0 + resolution: "p-try@npm:2.2.0" + checksum: 10c0/c36c19907734c904b16994e6535b02c36c2224d433e01a2f1ab777237f4d86e6289fd5fd464850491e940379d4606ed850c03e0f9ab600b0ebddb511312e177f + languageName: node + linkType: hard + +"patch-console@npm:^2.0.0": + version: 2.0.0 + resolution: "patch-console@npm:2.0.0" + checksum: 10c0/486602591a0af7af8d4c76d8eea42cad32b6de7200488819c6383c75e43733ca7bdc80e30f2e68ce05f06a1607cce1683a1706c6672ca27dada1921b366e8f1c + languageName: node + linkType: hard + +"path-exists@npm:^4.0.0": + version: 4.0.0 + resolution: "path-exists@npm:4.0.0" + checksum: 10c0/8c0bd3f5238188197dc78dced15207a4716c51cc4e3624c44fc97acf69558f5ebb9a2afff486fe1b4ee148e0c133e96c5e11a9aa5c48a3006e3467da070e5e1b + languageName: node + linkType: hard + +"path-is-absolute@npm:^1.0.0": + version: 1.0.1 + resolution: "path-is-absolute@npm:1.0.1" + checksum: 10c0/127da03c82172a2a50099cddbf02510c1791fc2cc5f7713ddb613a56838db1e8168b121a920079d052e0936c23005562059756d653b7c544c53185efe53be078 + languageName: node + linkType: hard + +"path-type@npm:^4.0.0": + version: 4.0.0 + resolution: "path-type@npm:4.0.0" + checksum: 10c0/666f6973f332f27581371efaf303fd6c272cc43c2057b37aa99e3643158c7e4b2626549555d88626e99ea9e046f82f32e41bbde5f1508547e9a11b149b52387c + languageName: node + linkType: hard + +"pathe@npm:^2.0.3": + version: 2.0.3 + resolution: "pathe@npm:2.0.3" + checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1 + languageName: node + linkType: hard + +"pathval@npm:^2.0.0": + version: 2.0.1 + resolution: "pathval@npm:2.0.1" + checksum: 10c0/460f4709479fbf2c45903a65655fc8f0a5f6d808f989173aeef5fdea4ff4f303dc13f7870303999add60ec49d4c14733895c0a869392e9866f1091fa64fd7581 + languageName: node + linkType: hard + +"picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + +"picomatch@npm:^2.3.1": + version: 2.3.2 + resolution: "picomatch@npm:2.3.2" + checksum: 10c0/a554d1709e59be97d1acb9eaedbbc700a5c03dbd4579807baed95100b00420bc729335440ef15004ae2378984e2487a7c1cebd743cfdb72b6fa9ab69223c0d61 + languageName: node + linkType: hard + +"picomatch@npm:^4.0.2, picomatch@npm:^4.0.3, picomatch@npm:^4.0.4": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 + languageName: node + linkType: hard + +"postcss@npm:^8.5.6": + version: 8.5.10 + resolution: "postcss@npm:8.5.10" + dependencies: + nanoid: "npm:^3.3.11" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/c592dffa0c4873b401f01955b265538d9942f425040df5e2b8f0ad34c83773a792ea0fa5859ccc99cfb5b955b4ebff118ab7056315388dc83b107b0fa8313576 + languageName: node + linkType: hard + +"preact@npm:^10.28.3": + version: 10.29.1 + resolution: "preact@npm:10.29.1" + checksum: 10c0/55da3994d0d9ebcc26476552c6571f8be1150cccdafe842aad40eff9abc84c867555f6d01db386fc63ac4cbe9f9da245002468bb24df0642365162d8f1b545e2 + languageName: node + linkType: hard + +"prettier@npm:^2.8.0": + version: 2.8.8 + resolution: "prettier@npm:2.8.8" + bin: + prettier: bin-prettier.js + checksum: 10c0/463ea8f9a0946cd5b828d8cf27bd8b567345cf02f56562d5ecde198b91f47a76b7ac9eae0facd247ace70e927143af6135e8cf411986b8cb8478784a4d6d724a + languageName: node + linkType: hard + +"proc-log@npm:^6.0.0": + version: 6.1.0 + resolution: "proc-log@npm:6.1.0" + checksum: 10c0/4f178d4062733ead9d71a9b1ab24ebcecdfe2250916a5b1555f04fe2eda972a0ec76fbaa8df1ad9c02707add6749219d118a4fc46dc56bdfe4dde4b47d80bb82 + languageName: node + linkType: hard + +"proxy-from-env@npm:^2.1.0": + version: 2.1.0 + resolution: "proxy-from-env@npm:2.1.0" + checksum: 10c0/ed01729fd4d094eab619cd7e17ce3698b3413b31eb102c4904f9875e677cd207392795d5b4adee9cec359dfd31c44d5ad7595a3a3ad51c40250e141512281c58 + languageName: node + linkType: hard + +"qs@npm:^6.11.0": + version: 6.15.1 + resolution: "qs@npm:6.15.1" + dependencies: + side-channel: "npm:^1.1.0" + checksum: 10c0/19ee504f0ebff72598503e38cd6d9bd7b52a8ab62ae18b1e6bee3d4db58469bd65871ef1893a881bafb0f80ef2f9ab586e1f255cf25cc8d816c0f5a704721d97 + languageName: node + linkType: hard + +"queue-microtask@npm:^1.2.2": + version: 1.2.3 + resolution: "queue-microtask@npm:1.2.3" + checksum: 10c0/900a93d3cdae3acd7d16f642c29a642aea32c2026446151f0778c62ac089d4b8e6c986811076e1ae180a694cedf077d453a11b58ff0a865629a4f82ab558e102 + languageName: node + linkType: hard + +"react-dom@npm:^19.0.0": + version: 19.2.5 + resolution: "react-dom@npm:19.2.5" + dependencies: + scheduler: "npm:^0.27.0" + peerDependencies: + react: ^19.2.5 + checksum: 10c0/8067606e9f58e4c2e8cb5f09570217dbc71c4843ebcaa20ae2085912d3e3a351f17d8f7c1713313cdda7f272840c8c34ff6c860fcb840862071bceea218e0c63 + languageName: node + linkType: hard + +"react-reconciler@npm:^0.33.0": + version: 0.33.0 + resolution: "react-reconciler@npm:0.33.0" + dependencies: + scheduler: "npm:^0.27.0" + peerDependencies: + react: ^19.2.0 + checksum: 10c0/3f7b27ea8d0ff4c8bf0e402a285e1af9b7d0e6f4c1a70a28f4384938bc1130bc82a90a31df0b79ef5e380e2e55e2598bd90b4dbf802b1203d735ba0355817d3a + languageName: node + linkType: hard + +"react@npm:^19.0.0": + version: 19.2.5 + resolution: "react@npm:19.2.5" + checksum: 10c0/4b5f231dbef92886f602533c9ce3bde04d99f0e71dfb5d794c43e02726efaad0421c08688f75fc98a6d6e1dc017372e1af7abbfecdc86a79968f461675931a7a + languageName: node + linkType: hard + +"readdirp@npm:^4.0.1": + version: 4.1.2 + resolution: "readdirp@npm:4.1.2" + checksum: 10c0/60a14f7619dec48c9c850255cd523e2717001b0e179dc7037cfa0895da7b9e9ab07532d324bfb118d73a710887d1e35f79c495fa91582784493e085d18c72c62 + languageName: node + linkType: hard + +"remove-trailing-separator@npm:^1.0.1": + version: 1.1.0 + resolution: "remove-trailing-separator@npm:1.1.0" + checksum: 10c0/3568f9f8f5af3737b4aee9e6e1e8ec4be65a92da9cb27f989e0893714d50aa95ed2ff02d40d1fa35e1b1a234dc9c2437050ef356704a3999feaca6667d9e9bfc + languageName: node + linkType: hard + +"require-directory@npm:^2.1.1": + version: 2.1.1 + resolution: "require-directory@npm:2.1.1" + checksum: 10c0/83aa76a7bc1531f68d92c75a2ca2f54f1b01463cb566cf3fbc787d0de8be30c9dbc211d1d46be3497dac5785fe296f2dd11d531945ac29730643357978966e99 + languageName: node + linkType: hard + +"require-main-filename@npm:^2.0.0": + version: 2.0.0 + resolution: "require-main-filename@npm:2.0.0" + checksum: 10c0/db91467d9ead311b4111cbd73a4e67fa7820daed2989a32f7023785a2659008c6d119752d9c4ac011ae07e537eb86523adff99804c5fdb39cd3a017f9b401bb6 + languageName: node + linkType: hard + +"resolve-from@npm:5.0.0": + version: 5.0.0 + resolution: "resolve-from@npm:5.0.0" + checksum: 10c0/b21cb7f1fb746de8107b9febab60095187781137fd803e6a59a76d421444b1531b641bba5857f5dc011974d8a5c635d61cec49e6bd3b7fc20e01f0fafc4efbf2 + languageName: node + linkType: hard + +"restore-cursor@npm:^2.0.0": + version: 2.0.0 + resolution: "restore-cursor@npm:2.0.0" + dependencies: + onetime: "npm:^2.0.0" + signal-exit: "npm:^3.0.2" + checksum: 10c0/f5b335bee06f440445e976a7031a3ef53691f9b7c4a9d42a469a0edaf8a5508158a0d561ff2b26a1f4f38783bcca2c0e5c3a44f927326f6694d5b44d7a4993e6 + languageName: node + linkType: hard + +"restore-cursor@npm:^4.0.0": + version: 4.0.0 + resolution: "restore-cursor@npm:4.0.0" + dependencies: + onetime: "npm:^5.1.0" + signal-exit: "npm:^3.0.2" + checksum: 10c0/6f7da8c5e422ac26aa38354870b1afac09963572cf2879443540449068cb43476e9cbccf6f8de3e0171e0d6f7f533c2bc1a0a008003c9a525bbc098e89041318 + languageName: node + linkType: hard + +"reusify@npm:^1.0.4": + version: 1.1.0 + resolution: "reusify@npm:1.1.0" + checksum: 10c0/4eff0d4a5f9383566c7d7ec437b671cc51b25963bd61bf127c3f3d3f68e44a026d99b8d2f1ad344afff8d278a8fe70a8ea092650a716d22287e8bef7126bb2fa + languageName: node + linkType: hard + +"rimraf@npm:^2.6.3": + version: 2.7.1 + resolution: "rimraf@npm:2.7.1" + dependencies: + glob: "npm:^7.1.3" + bin: + rimraf: ./bin.js + checksum: 10c0/4eef73d406c6940927479a3a9dee551e14a54faf54b31ef861250ac815172bade86cc6f7d64a4dc5e98b65e4b18a2e1c9ff3b68d296be0c748413f092bb0dd40 + languageName: node + linkType: hard + +"rollup@npm:^4.43.0": + version: 4.60.2 + resolution: "rollup@npm:4.60.2" + dependencies: + "@rollup/rollup-android-arm-eabi": "npm:4.60.2" + "@rollup/rollup-android-arm64": "npm:4.60.2" + "@rollup/rollup-darwin-arm64": "npm:4.60.2" + "@rollup/rollup-darwin-x64": "npm:4.60.2" + "@rollup/rollup-freebsd-arm64": "npm:4.60.2" + "@rollup/rollup-freebsd-x64": "npm:4.60.2" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.60.2" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.60.2" + "@rollup/rollup-linux-arm64-gnu": "npm:4.60.2" + "@rollup/rollup-linux-arm64-musl": "npm:4.60.2" + "@rollup/rollup-linux-loong64-gnu": "npm:4.60.2" + "@rollup/rollup-linux-loong64-musl": "npm:4.60.2" + "@rollup/rollup-linux-ppc64-gnu": "npm:4.60.2" + "@rollup/rollup-linux-ppc64-musl": "npm:4.60.2" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.60.2" + "@rollup/rollup-linux-riscv64-musl": "npm:4.60.2" + "@rollup/rollup-linux-s390x-gnu": "npm:4.60.2" + "@rollup/rollup-linux-x64-gnu": "npm:4.60.2" + "@rollup/rollup-linux-x64-musl": "npm:4.60.2" + "@rollup/rollup-openbsd-x64": "npm:4.60.2" + "@rollup/rollup-openharmony-arm64": "npm:4.60.2" + "@rollup/rollup-win32-arm64-msvc": "npm:4.60.2" + "@rollup/rollup-win32-ia32-msvc": "npm:4.60.2" + "@rollup/rollup-win32-x64-gnu": "npm:4.60.2" + "@rollup/rollup-win32-x64-msvc": "npm:4.60.2" + "@types/estree": "npm:1.0.8" + fsevents: "npm:~2.3.2" + dependenciesMeta: + "@rollup/rollup-android-arm-eabi": + optional: true + "@rollup/rollup-android-arm64": + optional: true + "@rollup/rollup-darwin-arm64": + optional: true + "@rollup/rollup-darwin-x64": + optional: true + "@rollup/rollup-freebsd-arm64": + optional: true + "@rollup/rollup-freebsd-x64": + optional: true + "@rollup/rollup-linux-arm-gnueabihf": + optional: true + "@rollup/rollup-linux-arm-musleabihf": + optional: true + "@rollup/rollup-linux-arm64-gnu": + optional: true + "@rollup/rollup-linux-arm64-musl": + optional: true + "@rollup/rollup-linux-loong64-gnu": + optional: true + "@rollup/rollup-linux-loong64-musl": + optional: true + "@rollup/rollup-linux-ppc64-gnu": + optional: true + "@rollup/rollup-linux-ppc64-musl": + optional: true + "@rollup/rollup-linux-riscv64-gnu": + optional: true + "@rollup/rollup-linux-riscv64-musl": + optional: true + "@rollup/rollup-linux-s390x-gnu": + optional: true + "@rollup/rollup-linux-x64-gnu": + optional: true + "@rollup/rollup-linux-x64-musl": + optional: true + "@rollup/rollup-openbsd-x64": + optional: true + "@rollup/rollup-openharmony-arm64": + optional: true + "@rollup/rollup-win32-arm64-msvc": + optional: true + "@rollup/rollup-win32-ia32-msvc": + optional: true + "@rollup/rollup-win32-x64-gnu": + optional: true + "@rollup/rollup-win32-x64-msvc": + optional: true + fsevents: + optional: true + bin: + rollup: dist/bin/rollup + checksum: 10c0/f67d6156fc5b895f33b929a4762392906d00fa5550f0a1f5e66519ab1c05d835aec5f201b4e748c29d1c87f024d3d9c4b0a2d1285668456db661d82b961d379c + languageName: node + linkType: hard + +"run-async@npm:^3.0.0": + version: 3.0.0 + resolution: "run-async@npm:3.0.0" + checksum: 10c0/b18b562ae37c3020083dcaae29642e4cc360c824fbfb6b7d50d809a9d5227bb986152d09310255842c8dce40526e82ca768f02f00806c91ba92a8dfa6159cb85 + languageName: node + linkType: hard + +"run-parallel@npm:^1.1.9": + version: 1.2.0 + resolution: "run-parallel@npm:1.2.0" + dependencies: + queue-microtask: "npm:^1.2.2" + checksum: 10c0/200b5ab25b5b8b7113f9901bfe3afc347e19bb7475b267d55ad0eb86a62a46d77510cb0f232507c9e5d497ebda569a08a9867d0d14f57a82ad5564d991588b39 + languageName: node + linkType: hard + +"rxjs@npm:^6.3.3": + version: 6.6.7 + resolution: "rxjs@npm:6.6.7" + dependencies: + tslib: "npm:^1.9.0" + checksum: 10c0/e556a13a9aa89395e5c9d825eabcfa325568d9c9990af720f3f29f04a888a3b854f25845c2b55875d875381abcae2d8100af9cacdc57576e7ed6be030a01d2fe + languageName: node + linkType: hard + +"rxjs@npm:^7.8.1": + version: 7.8.2 + resolution: "rxjs@npm:7.8.2" + dependencies: + tslib: "npm:^2.1.0" + checksum: 10c0/1fcd33d2066ada98ba8f21fcbbcaee9f0b271de1d38dc7f4e256bfbc6ffcdde68c8bfb69093de7eeb46f24b1fb820620bf0223706cff26b4ab99a7ff7b2e2c45 + languageName: node + linkType: hard + +"safer-buffer@npm:>= 2.1.2 < 3": + version: 2.1.2 + resolution: "safer-buffer@npm:2.1.2" + checksum: 10c0/7e3c8b2e88a1841c9671094bbaeebd94448111dd90a81a1f606f3f67708a6ec57763b3b47f06da09fc6054193e0e6709e77325415dc8422b04497a8070fa02d4 + languageName: node + linkType: hard + +"scheduler@npm:^0.27.0": + version: 0.27.0 + resolution: "scheduler@npm:0.27.0" + checksum: 10c0/4f03048cb05a3c8fddc45813052251eca00688f413a3cee236d984a161da28db28ba71bd11e7a3dd02f7af84ab28d39fb311431d3b3772fed557945beb00c452 + languageName: node + linkType: hard + +"semver@npm:^7.3.5": + version: 7.7.4 + resolution: "semver@npm:7.7.4" + bin: + semver: bin/semver.js + checksum: 10c0/5215ad0234e2845d4ea5bb9d836d42b03499546ddafb12075566899fc617f68794bb6f146076b6881d755de17d6c6cc73372555879ec7dce2c2feee947866ad2 + languageName: node + linkType: hard + +"set-blocking@npm:^2.0.0": + version: 2.0.0 + resolution: "set-blocking@npm:2.0.0" + checksum: 10c0/9f8c1b2d800800d0b589de1477c753492de5c1548d4ade52f57f1d1f5e04af5481554d75ce5e5c43d4004b80a3eb714398d6907027dc0534177b7539119f4454 + languageName: node + linkType: hard + +"side-channel-list@npm:^1.0.0": + version: 1.0.1 + resolution: "side-channel-list@npm:1.0.1" + dependencies: + es-errors: "npm:^1.3.0" + object-inspect: "npm:^1.13.4" + checksum: 10c0/d346c787fd2f9f1c2fdea14f00e8250118db0e7596d85a6cb9faa75f105d31a73a8f7a341c93d7df2a2429098c3d37a77bd3be9e88c37094b8c01807bc77c7a2 + languageName: node + linkType: hard + +"side-channel-map@npm:^1.0.1": + version: 1.0.1 + resolution: "side-channel-map@npm:1.0.1" + dependencies: + call-bound: "npm:^1.0.2" + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.5" + object-inspect: "npm:^1.13.3" + checksum: 10c0/010584e6444dd8a20b85bc926d934424bd809e1a3af941cace229f7fdcb751aada0fb7164f60c2e22292b7fa3c0ff0bce237081fd4cdbc80de1dc68e95430672 + languageName: node + linkType: hard + +"side-channel-weakmap@npm:^1.0.2": + version: 1.0.2 + resolution: "side-channel-weakmap@npm:1.0.2" + dependencies: + call-bound: "npm:^1.0.2" + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.5" + object-inspect: "npm:^1.13.3" + side-channel-map: "npm:^1.0.1" + checksum: 10c0/71362709ac233e08807ccd980101c3e2d7efe849edc51455030327b059f6c4d292c237f94dc0685031dd11c07dd17a68afde235d6cf2102d949567f98ab58185 + languageName: node + linkType: hard + +"side-channel@npm:^1.1.0": + version: 1.1.0 + resolution: "side-channel@npm:1.1.0" + dependencies: + es-errors: "npm:^1.3.0" + object-inspect: "npm:^1.13.3" + side-channel-list: "npm:^1.0.0" + side-channel-map: "npm:^1.0.1" + side-channel-weakmap: "npm:^1.0.2" + checksum: 10c0/cb20dad41eb032e6c24c0982e1e5a24963a28aa6122b4f05b3f3d6bf8ae7fd5474ef382c8f54a6a3ab86e0cac4d41a23bd64ede3970e5bfb50326ba02a7996e6 + languageName: node + linkType: hard + +"siginfo@npm:^2.0.0": + version: 2.0.0 + resolution: "siginfo@npm:2.0.0" + checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34 + languageName: node + linkType: hard + +"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.7": + version: 3.0.7 + resolution: "signal-exit@npm:3.0.7" + checksum: 10c0/25d272fa73e146048565e08f3309d5b942c1979a6f4a58a8c59d5fa299728e9c2fcd1a759ec870863b1fd38653670240cd420dad2ad9330c71f36608a6a1c912 + languageName: node + linkType: hard + +"signal-exit@npm:^4.1.0": + version: 4.1.0 + resolution: "signal-exit@npm:4.1.0" + checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 + languageName: node + linkType: hard + +"slash@npm:^3.0.0": + version: 3.0.0 + resolution: "slash@npm:3.0.0" + checksum: 10c0/e18488c6a42bdfd4ac5be85b2ced3ccd0224773baae6ad42cfbb9ec74fc07f9fa8396bd35ee638084ead7a2a0818eb5e7151111544d4731ce843019dab4be47b + languageName: node + linkType: hard + +"slice-ansi@npm:0.0.4": + version: 0.0.4 + resolution: "slice-ansi@npm:0.0.4" + checksum: 10c0/997d4cc73e34aa8c0f60bdb71701b16c062cc4acd7a95e3b10e8c05d790eb5e735d9b470270dc6f443b1ba21492db7ceb849d5c93011d1256061bf7ed7216c7a + languageName: node + linkType: hard + +"slice-ansi@npm:^8.0.0": + version: 8.0.0 + resolution: "slice-ansi@npm:8.0.0" + dependencies: + ansi-styles: "npm:^6.2.3" + is-fullwidth-code-point: "npm:^5.1.0" + checksum: 10c0/0ce4aa91febb7cea4a00c2c27bb820fa53b6d2862ce0f80f7120134719f7914fc416b0ed966cf35250a3169e152916392f35917a2d7cad0fcc5d8b841010fa9a + languageName: node + linkType: hard + +"source-map-js@npm:^1.2.1": + version: 1.2.1 + resolution: "source-map-js@npm:1.2.1" + checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf + languageName: node + linkType: hard + +"stack-utils@npm:^2.0.6": + version: 2.0.6 + resolution: "stack-utils@npm:2.0.6" + dependencies: + escape-string-regexp: "npm:^2.0.0" + checksum: 10c0/651c9f87667e077584bbe848acaecc6049bc71979f1e9a46c7b920cad4431c388df0f51b8ad7cfd6eed3db97a2878d0fc8b3122979439ea8bac29c61c95eec8a + languageName: node + linkType: hard + +"stackback@npm:0.0.2": + version: 0.0.2 + resolution: "stackback@npm:0.0.2" + checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983 + languageName: node + linkType: hard + +"std-env@npm:^3.9.0": + version: 3.10.0 + resolution: "std-env@npm:3.10.0" + checksum: 10c0/1814927a45004d36dde6707eaf17552a546769bc79a6421be2c16ce77d238158dfe5de30910b78ec30d95135cc1c59ea73ee22d2ca170f8b9753f84da34c427f + languageName: node + linkType: hard + +"string-width@npm:^1.0.1": + version: 1.0.2 + resolution: "string-width@npm:1.0.2" + dependencies: + code-point-at: "npm:^1.0.0" + is-fullwidth-code-point: "npm:^1.0.0" + strip-ansi: "npm:^3.0.0" + checksum: 10c0/c558438baed23a9ab9370bb6a939acbdb2b2ffc517838d651aad0f5b2b674fb85d460d9b1d0b6a4c210dffd09e3235222d89a5bd4c0c1587f78b2bb7bc00c65e + languageName: node + linkType: hard + +"string-width@npm:^2.1.1": + version: 2.1.1 + resolution: "string-width@npm:2.1.1" + dependencies: + is-fullwidth-code-point: "npm:^2.0.0" + strip-ansi: "npm:^4.0.0" + checksum: 10c0/e5f2b169fcf8a4257a399f95d069522f056e92ec97dbdcb9b0cdf14d688b7ca0b1b1439a1c7b9773cd79446cbafd582727279d6bfdd9f8edd306ea5e90e5b610 + languageName: node + linkType: hard + +"string-width@npm:^4.1.0, string-width@npm:^4.2.0": + version: 4.2.3 + resolution: "string-width@npm:4.2.3" + dependencies: + emoji-regex: "npm:^8.0.0" + is-fullwidth-code-point: "npm:^3.0.0" + strip-ansi: "npm:^6.0.1" + checksum: 10c0/1e525e92e5eae0afd7454086eed9c818ee84374bb80328fc41217ae72ff5f065ef1c9d7f72da41de40c75fa8bb3dee63d92373fd492c84260a552c636392a47b + languageName: node + linkType: hard + +"string-width@npm:^7.0.0": + version: 7.2.0 + resolution: "string-width@npm:7.2.0" + dependencies: + emoji-regex: "npm:^10.3.0" + get-east-asian-width: "npm:^1.0.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/eb0430dd43f3199c7a46dcbf7a0b34539c76fe3aa62763d0b0655acdcbdf360b3f66f3d58ca25ba0205f42ea3491fa00f09426d3b7d3040e506878fc7664c9b9 + languageName: node + linkType: hard + +"string-width@npm:^8.1.0, string-width@npm:^8.1.1, string-width@npm:^8.2.0": + version: 8.2.0 + resolution: "string-width@npm:8.2.0" + dependencies: + get-east-asian-width: "npm:^1.5.0" + strip-ansi: "npm:^7.1.2" + checksum: 10c0/d8915428b43519b0f494da6590dbe4491857d8a12e40250e50fc01fbb616ffd8400a436bbe25712255ee129511fe0414c49d3b6b9627e2bc3a33dcec1d2eda02 + languageName: node + linkType: hard + +"strip-ansi@npm:^3.0.0, strip-ansi@npm:^3.0.1": + version: 3.0.1 + resolution: "strip-ansi@npm:3.0.1" + dependencies: + ansi-regex: "npm:^2.0.0" + checksum: 10c0/f6e7fbe8e700105dccf7102eae20e4f03477537c74b286fd22cfc970f139002ed6f0d9c10d0e21aa9ed9245e0fa3c9275930e8795c5b947da136e4ecb644a70f + languageName: node + linkType: hard + +"strip-ansi@npm:^4.0.0": + version: 4.0.0 + resolution: "strip-ansi@npm:4.0.0" + dependencies: + ansi-regex: "npm:^3.0.0" + checksum: 10c0/d75d9681e0637ea316ddbd7d4d3be010b1895a17e885155e0ed6a39755ae0fd7ef46e14b22162e66a62db122d3a98ab7917794e255532ab461bb0a04feb03e7d + languageName: node + linkType: hard + +"strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": + version: 6.0.1 + resolution: "strip-ansi@npm:6.0.1" + dependencies: + ansi-regex: "npm:^5.0.1" + checksum: 10c0/1ae5f212a126fe5b167707f716942490e3933085a5ff6c008ab97ab2f272c8025d3aa218b7bd6ab25729ca20cc81cddb252102f8751e13482a5199e873680952 + languageName: node + linkType: hard + +"strip-ansi@npm:^7.1.0, strip-ansi@npm:^7.1.2": + version: 7.2.0 + resolution: "strip-ansi@npm:7.2.0" + dependencies: + ansi-regex: "npm:^6.2.2" + checksum: 10c0/544d13b7582f8254811ea97db202f519e189e59d35740c46095897e254e4f1aa9fe1524a83ad6bc5ad67d4dd6c0281d2e0219ed62b880a6238a16a17d375f221 + languageName: node + linkType: hard + +"strip-literal@npm:^3.0.0": + version: 3.1.0 + resolution: "strip-literal@npm:3.1.0" + dependencies: + js-tokens: "npm:^9.0.1" + checksum: 10c0/50918f669915d9ad0fe4b7599902b735f853f2201c97791ead00104a654259c0c61bc2bc8fa3db05109339b61f4cf09e47b94ecc874ffbd0e013965223893af8 + languageName: node + linkType: hard + +"subscriptions-transport-ws@npm:^0.9.16": + version: 0.9.19 + resolution: "subscriptions-transport-ws@npm:0.9.19" + dependencies: + backo2: "npm:^1.0.2" + eventemitter3: "npm:^3.1.0" + iterall: "npm:^1.2.1" + symbol-observable: "npm:^1.0.4" + ws: "npm:^5.2.0 || ^6.0.0 || ^7.0.0" + peerDependencies: + graphql: ">=0.10.0" + checksum: 10c0/6f2ade56865f0ba291d3ff82c79781b051c2374873bac853286fedfdbc05001b8c4018ab7cba44af667ead7f573e48d18892d58a8f9ca8d90dfb4bff5c125045 + languageName: node + linkType: hard + +"supports-color@npm:^2.0.0": + version: 2.0.0 + resolution: "supports-color@npm:2.0.0" + checksum: 10c0/570e0b63be36cccdd25186350a6cb2eaad332a95ff162fa06d9499982315f2fe4217e69dd98e862fbcd9c81eaff300a825a1fe7bf5cc752e5b84dfed042b0dda + languageName: node + linkType: hard + +"supports-color@npm:^5.3.0": + version: 5.5.0 + resolution: "supports-color@npm:5.5.0" + dependencies: + has-flag: "npm:^3.0.0" + checksum: 10c0/6ae5ff319bfbb021f8a86da8ea1f8db52fac8bd4d499492e30ec17095b58af11f0c55f8577390a749b1c4dde691b6a0315dab78f5f54c9b3d83f8fb5905c1c05 + languageName: node + linkType: hard + +"symbol-observable@npm:^1.0.4, symbol-observable@npm:^1.1.0": + version: 1.2.0 + resolution: "symbol-observable@npm:1.2.0" + checksum: 10c0/009fee50798ef80ed4b8195048288f108b03de162db07493f2e1fd993b33fafa72d659e832b584da5a2427daa78e5a738fb2a9ab027ee9454252e0bedbcd1fdc + languageName: node + linkType: hard + +"tagged-tag@npm:^1.0.0": + version: 1.0.0 + resolution: "tagged-tag@npm:1.0.0" + checksum: 10c0/91d25c9ffb86a91f20522cefb2cbec9b64caa1febe27ad0df52f08993ff60888022d771e868e6416cf2e72dab68449d2139e8709ba009b74c6c7ecd4000048d1 + languageName: node + linkType: hard + +"tar@npm:^7.5.4": + version: 7.5.13 + resolution: "tar@npm:7.5.13" + dependencies: + "@isaacs/fs-minipass": "npm:^4.0.0" + chownr: "npm:^3.0.0" + minipass: "npm:^7.1.2" + minizlib: "npm:^3.1.0" + yallist: "npm:^5.0.0" + checksum: 10c0/5c65b8084799bde7a791593a1c1a45d3d6ee98182e3700b24c247b7b8f8654df4191642abbdb07ff25043d45dcff35620827c3997b88ae6c12040f64bed5076b + languageName: node + linkType: hard + +"terminal-size@npm:^4.0.1": + version: 4.0.1 + resolution: "terminal-size@npm:4.0.1" + checksum: 10c0/89afd9d816dd9dbfe4499da9aeea70491bbde4ff4592226a9c8ac71074a7580afead6a78e95ecc35f6d42e09087b55ffcb1019302cd55e0cc957b6ce5c4847e8 + languageName: node + linkType: hard + +"tinybench@npm:^2.9.0": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c + languageName: node + linkType: hard + +"tinyexec@npm:^0.3.2": + version: 0.3.2 + resolution: "tinyexec@npm:0.3.2" + checksum: 10c0/3efbf791a911be0bf0821eab37a3445c2ba07acc1522b1fa84ae1e55f10425076f1290f680286345ed919549ad67527d07281f1c19d584df3b74326909eb1f90 + languageName: node + linkType: hard + +"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.14, tinyglobby@npm:^0.2.15": + version: 0.2.16 + resolution: "tinyglobby@npm:0.2.16" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.4" + checksum: 10c0/f2e09fd93dd95c41e522113b686ff6f7c13020962f8698a864a257f3d7737599afc47722b7ab726e12f8a813f779906187911ff8ee6701ede65072671a7e934b + languageName: node + linkType: hard + +"tinypool@npm:^1.1.1": + version: 1.1.1 + resolution: "tinypool@npm:1.1.1" + checksum: 10c0/bf26727d01443061b04fa863f571016950888ea994ba0cd8cba3a1c51e2458d84574341ab8dbc3664f1c3ab20885c8cf9ff1cc4b18201f04c2cde7d317fff69b + languageName: node + linkType: hard + +"tinyrainbow@npm:^2.0.0": + version: 2.0.0 + resolution: "tinyrainbow@npm:2.0.0" + checksum: 10c0/c83c52bef4e0ae7fb8ec6a722f70b5b6fa8d8be1c85792e829f56c0e1be94ab70b293c032dc5048d4d37cfe678f1f5babb04bdc65fd123098800148ca989184f + languageName: node + linkType: hard + +"tinyspy@npm:^4.0.3": + version: 4.0.4 + resolution: "tinyspy@npm:4.0.4" + checksum: 10c0/a8020fc17799251e06a8398dcc352601d2770aa91c556b9531ecd7a12581161fd1c14e81cbdaff0c1306c93bfdde8ff6d1c1a3f9bbe6d91604f0fd4e01e2f1eb + languageName: node + linkType: hard + +"tmp@npm:^0.0.33": + version: 0.0.33 + resolution: "tmp@npm:0.0.33" + dependencies: + os-tmpdir: "npm:~1.0.2" + checksum: 10c0/69863947b8c29cabad43fe0ce65cec5bb4b481d15d4b4b21e036b060b3edbf3bc7a5541de1bacb437bb3f7c4538f669752627fdf9b4aaf034cebd172ba373408 + languageName: node + linkType: hard + +"to-regex-range@npm:^5.0.1": + version: 5.0.1 + resolution: "to-regex-range@npm:5.0.1" + dependencies: + is-number: "npm:^7.0.0" + checksum: 10c0/487988b0a19c654ff3e1961b87f471702e708fa8a8dd02a298ef16da7206692e8552a0250e8b3e8759270f62e9d8314616f6da274734d3b558b1fc7b7724e892 + languageName: node + linkType: hard + +"tr46@npm:~0.0.3": + version: 0.0.3 + resolution: "tr46@npm:0.0.3" + checksum: 10c0/047cb209a6b60c742f05c9d3ace8fa510bff609995c129a37ace03476a9b12db4dbf975e74600830ef0796e18882b2381fb5fb1f6b4f96b832c374de3ab91a11 + languageName: node + linkType: hard + +"tsconfck@npm:^3.0.3": + version: 3.1.6 + resolution: "tsconfck@npm:3.1.6" + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + bin: + tsconfck: bin/tsconfck.js + checksum: 10c0/269c3c513540be44844117bb9b9258fe6f8aeab026d32aeebf458d5299125f330711429dbb556dbf125a0bc25f4a81e6c24ac96de2740badd295c3fb400f66c4 + languageName: node + linkType: hard + +"tslib@npm:^1.9.0, tslib@npm:^1.9.3": + version: 1.14.1 + resolution: "tslib@npm:1.14.1" + checksum: 10c0/69ae09c49eea644bc5ebe1bca4fa4cc2c82b7b3e02f43b84bd891504edf66dbc6b2ec0eef31a957042de2269139e4acff911e6d186a258fb14069cd7f6febce2 + languageName: node + linkType: hard + +"tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.4.0": + version: 2.8.1 + resolution: "tslib@npm:2.8.1" + checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 + languageName: node + linkType: hard + +"twenty-client-sdk@npm:2.0.0": + version: 2.0.0 + resolution: "twenty-client-sdk@npm:2.0.0" + dependencies: + "@genql/cli": "npm:^3.0.3" + "@genql/runtime": "npm:^2.10.0" + esbuild: "npm:^0.25.0" + graphql: "npm:^16.8.1" + checksum: 10c0/f8cbeef32d72febe562387b881b89b8edf3eb92ab4268b47d24286b3c291e032b6cc065d9e9cd7e42a7ea0f27741651d3a270623f5c0478782eafbe3b62f30f8 + languageName: node + linkType: hard + +"twenty-sdk@npm:2.0.0": + version: 2.0.0 + resolution: "twenty-sdk@npm:2.0.0" + dependencies: + "@genql/cli": "npm:^3.0.3" + "@genql/runtime": "npm:^2.10.0" + "@sniptt/guards": "npm:^0.2.0" + axios: "npm:^1.13.5" + chalk: "npm:^5.3.0" + chokidar: "npm:^4.0.0" + commander: "npm:^12.0.0" + dotenv: "npm:^16.4.0" + esbuild: "npm:^0.25.0" + graphql: "npm:^16.8.1" + graphql-sse: "npm:^2.5.4" + ink: "npm:^6.8.0" + inquirer: "npm:^10.0.0" + jsonc-parser: "npm:^3.2.0" + preact: "npm:^10.28.3" + react: "npm:^19.0.0" + react-dom: "npm:^19.0.0" + tinyglobby: "npm:^0.2.15" + twenty-client-sdk: "npm:2.0.0" + typescript: "npm:^5.9.2" + uuid: "npm:^13.0.0" + vite: "npm:^7.0.0" + vite-tsconfig-paths: "npm:^4.2.1" + zod: "npm:^4.1.11" + bin: + twenty: dist/cli.cjs + checksum: 10c0/ea0d50a002e88b177555987840fa9dfeda0f3482f42240bda04a79485f69e9717492a265de8080f599e03960859cbcab0e686c6b6bf8fe0872139f9dc1645dd5 + languageName: node + linkType: hard + +"type-fest@npm:^0.21.3": + version: 0.21.3 + resolution: "type-fest@npm:0.21.3" + checksum: 10c0/902bd57bfa30d51d4779b641c2bc403cdf1371fb9c91d3c058b0133694fcfdb817aef07a47f40faf79039eecbaa39ee9d3c532deff244f3a19ce68cea71a61e8 + languageName: node + linkType: hard + +"type-fest@npm:^5.4.1": + version: 5.6.0 + resolution: "type-fest@npm:5.6.0" + dependencies: + tagged-tag: "npm:^1.0.0" + checksum: 10c0/5468a8ffda7f3904e6f7bbd8069eb8b6dd4bd9156e206df7a01d09a73e28cd1afedf74ead9d0fc12841c8c90074194859feca240511c50800962fde1bd9ddcbc + languageName: node + linkType: hard + +"typescript@npm:^5.9.2, typescript@npm:^5.9.3": + version: 5.9.3 + resolution: "typescript@npm:5.9.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A^5.9.2#optional!builtin, typescript@patch:typescript@npm%3A^5.9.3#optional!builtin": + version: 5.9.3 + resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 + languageName: node + linkType: hard + +"undici-types@npm:~6.21.0": + version: 6.21.0 + resolution: "undici-types@npm:6.21.0" + checksum: 10c0/c01ed51829b10aa72fc3ce64b747f8e74ae9b60eafa19a7b46ef624403508a54c526ffab06a14a26b3120d055e1104d7abe7c9017e83ced038ea5cf52f8d5e04 + languageName: node + linkType: hard + +"undici-types@npm:~7.16.0": + version: 7.16.0 + resolution: "undici-types@npm:7.16.0" + checksum: 10c0/3033e2f2b5c9f1504bdc5934646cb54e37ecaca0f9249c983f7b1fc2e87c6d18399ebb05dc7fd5419e02b2e915f734d872a65da2e3eeed1813951c427d33cc9a + languageName: node + linkType: hard + +"undici-types@npm:~7.19.0": + version: 7.19.2 + resolution: "undici-types@npm:7.19.2" + checksum: 10c0/7159f10546f9f6c47d36776bb1bbf8671e87c1e587a6fee84ae1f111ae8de4f914efa8ca0dfcd224f4f4a9dfc3f6028f627ccb5ddaccf82d7fd54671b89fac3e + languageName: node + linkType: hard + +"undici@npm:^5.18.0": + version: 5.29.0 + resolution: "undici@npm:5.29.0" + dependencies: + "@fastify/busboy": "npm:^2.0.0" + checksum: 10c0/e4e4d631ca54ee0ad82d2e90e7798fa00a106e27e6c880687e445cc2f13b4bc87c5eba2a88c266c3eecffb18f26e227b778412da74a23acc374fca7caccec49b + languageName: node + linkType: hard + +"undici@npm:^6.25.0": + version: 6.25.0 + resolution: "undici@npm:6.25.0" + checksum: 10c0/2597cc6689bdb02c210c557b1f85febbfda65becae6e6fc1061508e2f33734d25207f81cd8af56ada9956329eb3a7bd7431e87dcfeceba20ee87059b57dcf985 + languageName: node + linkType: hard + +"unfetch@npm:^4.2.0": + version: 4.2.0 + resolution: "unfetch@npm:4.2.0" + checksum: 10c0/a5c0a896a6f09f278b868075aea65652ad185db30e827cb7df45826fe5ab850124bf9c44c4dafca4bf0c55a0844b17031e8243467fcc38dd7a7d435007151f1b + languageName: node + linkType: hard + +"universalify@npm:^2.0.0": + version: 2.0.1 + resolution: "universalify@npm:2.0.1" + checksum: 10c0/73e8ee3809041ca8b818efb141801a1004e3fc0002727f1531f4de613ea281b494a40909596dae4a042a4fb6cd385af5d4db2e137b1362e0e91384b828effd3a + languageName: node + linkType: hard + +"unixify@npm:^1.0.0": + version: 1.0.0 + resolution: "unixify@npm:1.0.0" + dependencies: + normalize-path: "npm:^2.1.1" + checksum: 10c0/8b89100619ebde9f0ab4024a4d402316fb7b1d4853723410fc828944e8d3d01480f210cddf94d9a1699559f8180d861eb6323da8011b7bcc1bbaf6a11a5b1f1e + languageName: node + linkType: hard + +"utility-types@npm:^3.10.0": + version: 3.11.0 + resolution: "utility-types@npm:3.11.0" + checksum: 10c0/2f1580137b0c3e6cf5405f37aaa8f5249961a76d26f1ca8efc0ff49a2fc0e0b2db56de8e521a174d075758e0c7eb3e590edec0832eb44478b958f09914920f19 + languageName: node + linkType: hard + +"uuid@npm:^13.0.0": + version: 13.0.0 + resolution: "uuid@npm:13.0.0" + bin: + uuid: dist-node/bin/uuid + checksum: 10c0/950e4c18d57fef6c69675344f5700a08af21e26b9eff2bf2180427564297368c538ea11ac9fb2e6528b17fc3966a9fd2c5049361b0b63c7d654f3c550c9b3d67 + languageName: node + linkType: hard + +"value-or-promise@npm:^1.0.12": + version: 1.0.12 + resolution: "value-or-promise@npm:1.0.12" + checksum: 10c0/b75657b74e4d17552bd88e0c2857020fbab34a4d091dc058db18c470e7da0336067e72c130b3358e3321ac0a6ff11c0b92b67a382318a3705ad5d57de7ff3262 + languageName: node + linkType: hard + +"vite-node@npm:3.2.4": + version: 3.2.4 + resolution: "vite-node@npm:3.2.4" + dependencies: + cac: "npm:^6.7.14" + debug: "npm:^4.4.1" + es-module-lexer: "npm:^1.7.0" + pathe: "npm:^2.0.3" + vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" + bin: + vite-node: vite-node.mjs + checksum: 10c0/6ceca67c002f8ef6397d58b9539f80f2b5d79e103a18367288b3f00a8ab55affa3d711d86d9112fce5a7fa658a212a087a005a045eb8f4758947dd99af2a6c6b + languageName: node + linkType: hard + +"vite-tsconfig-paths@npm:^4.2.1": + version: 4.3.2 + resolution: "vite-tsconfig-paths@npm:4.3.2" + dependencies: + debug: "npm:^4.1.1" + globrex: "npm:^0.1.2" + tsconfck: "npm:^3.0.3" + peerDependencies: + vite: "*" + peerDependenciesMeta: + vite: + optional: true + checksum: 10c0/f390ac1d1c3992fc5ac50f9274c1090f8b55ab34a89ea88893db9a6924a3b26c9f64bc1163615150ad100749db73b6b2cf1d57f6cd60df6e762ceb5b8ad30024 + languageName: node + linkType: hard + +"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0, vite@npm:^7.0.0": + version: 7.3.2 + resolution: "vite@npm:7.3.2" + dependencies: + esbuild: "npm:^0.27.0" + fdir: "npm:^6.5.0" + fsevents: "npm:~2.3.3" + picomatch: "npm:^4.0.3" + postcss: "npm:^8.5.6" + rollup: "npm:^4.43.0" + tinyglobby: "npm:^0.2.15" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + jiti: ">=1.21.0" + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/74be36907e208916f18bfec81c8eba18b869f0a170f1ece0a4dcb14874d0f0e7c022fb6c2ad896e3ee6c973fe88f53ac23b4078879ada340d8b263260868b8d4 + languageName: node + linkType: hard + +"vitest@npm:^3.1.1": + version: 3.2.4 + resolution: "vitest@npm:3.2.4" + dependencies: + "@types/chai": "npm:^5.2.2" + "@vitest/expect": "npm:3.2.4" + "@vitest/mocker": "npm:3.2.4" + "@vitest/pretty-format": "npm:^3.2.4" + "@vitest/runner": "npm:3.2.4" + "@vitest/snapshot": "npm:3.2.4" + "@vitest/spy": "npm:3.2.4" + "@vitest/utils": "npm:3.2.4" + chai: "npm:^5.2.0" + debug: "npm:^4.4.1" + expect-type: "npm:^1.2.1" + magic-string: "npm:^0.30.17" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.2" + std-env: "npm:^3.9.0" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^0.3.2" + tinyglobby: "npm:^0.2.14" + tinypool: "npm:^1.1.1" + tinyrainbow: "npm:^2.0.0" + vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" + vite-node: "npm:3.2.4" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@types/debug": ^4.1.12 + "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 + "@vitest/browser": 3.2.4 + "@vitest/ui": 3.2.4 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/debug": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10c0/5bf53ede3ae6a0e08956d72dab279ae90503f6b5a05298a6a5e6ef47d2fd1ab386aaf48fafa61ed07a0ebfe9e371772f1ccbe5c258dd765206a8218bf2eb79eb + languageName: node + linkType: hard + +"webidl-conversions@npm:^3.0.0": + version: 3.0.1 + resolution: "webidl-conversions@npm:3.0.1" + checksum: 10c0/5612d5f3e54760a797052eb4927f0ddc01383550f542ccd33d5238cfd65aeed392a45ad38364970d0a0f4fea32e1f4d231b3d8dac4a3bdd385e5cf802ae097db + languageName: node + linkType: hard + +"whatwg-url@npm:^5.0.0": + version: 5.0.0 + resolution: "whatwg-url@npm:5.0.0" + dependencies: + tr46: "npm:~0.0.3" + webidl-conversions: "npm:^3.0.0" + checksum: 10c0/1588bed84d10b72d5eec1d0faa0722ba1962f1821e7539c535558fb5398d223b0c50d8acab950b8c488b4ba69043fd833cc2697056b167d8ad46fac3995a55d5 + languageName: node + linkType: hard + +"which-module@npm:^2.0.0": + version: 2.0.1 + resolution: "which-module@npm:2.0.1" + checksum: 10c0/087038e7992649eaffa6c7a4f3158d5b53b14cf5b6c1f0e043dccfacb1ba179d12f17545d5b85ebd94a42ce280a6fe65d0cbcab70f4fc6daad1dfae85e0e6a3e + languageName: node + linkType: hard + +"which@npm:^6.0.0": + version: 6.0.1 + resolution: "which@npm:6.0.1" + dependencies: + isexe: "npm:^4.0.0" + bin: + node-which: bin/which.js + checksum: 10c0/7e710e54ea36d2d6183bee2f9caa27a3b47b9baf8dee55a199b736fcf85eab3b9df7556fca3d02b50af7f3dfba5ea3a45644189836df06267df457e354da66d5 + languageName: node + linkType: hard + +"why-is-node-running@npm:^2.3.0": + version: 2.3.0 + resolution: "why-is-node-running@npm:2.3.0" + dependencies: + siginfo: "npm:^2.0.0" + stackback: "npm:0.0.2" + bin: + why-is-node-running: cli.js + checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054 + languageName: node + linkType: hard + +"widest-line@npm:^6.0.0": + version: 6.0.0 + resolution: "widest-line@npm:6.0.0" + dependencies: + string-width: "npm:^8.1.0" + checksum: 10c0/735f1fdcd97fe765a07bb8b5e73c020bed8e53ab34e83ce0ef01693ba3c914d9e7977fe5f5facf0d0b670297a82dd5e376d3efa0896860dfcdaf7cd6924c0fb7 + languageName: node + linkType: hard + +"wrap-ansi@npm:^3.0.1": + version: 3.0.1 + resolution: "wrap-ansi@npm:3.0.1" + dependencies: + string-width: "npm:^2.1.1" + strip-ansi: "npm:^4.0.0" + checksum: 10c0/ad6fed8f242c26755badaf452da154122d0d862f8b7aab56e758466857f230efafdc5fbffca026650b947ac3fc0eb563df5c05b9e2190a52a4a68f4eef3d4555 + languageName: node + linkType: hard + +"wrap-ansi@npm:^6.2.0": + version: 6.2.0 + resolution: "wrap-ansi@npm:6.2.0" + dependencies: + ansi-styles: "npm:^4.0.0" + string-width: "npm:^4.1.0" + strip-ansi: "npm:^6.0.0" + checksum: 10c0/baad244e6e33335ea24e86e51868fe6823626e3a3c88d9a6674642afff1d34d9a154c917e74af8d845fd25d170c4ea9cf69a47133c3f3656e1252b3d462d9f6c + languageName: node + linkType: hard + +"wrap-ansi@npm:^9.0.0": + version: 9.0.2 + resolution: "wrap-ansi@npm:9.0.2" + dependencies: + ansi-styles: "npm:^6.2.1" + string-width: "npm:^7.0.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/3305839b9a0d6fb930cb63a52f34d3936013d8b0682ff3ec133c9826512620f213800ffa19ea22904876d5b7e9a3c1f40682f03597d986a4ca881fa7b033688c + languageName: node + linkType: hard + +"wrappy@npm:1": + version: 1.0.2 + resolution: "wrappy@npm:1.0.2" + checksum: 10c0/56fece1a4018c6a6c8e28fbc88c87e0fbf4ea8fd64fc6c63b18f4acc4bd13e0ad2515189786dd2c30d3eec9663d70f4ecf699330002f8ccb547e4a18231fc9f0 + languageName: node + linkType: hard + +"ws@npm:^5.2.0 || ^6.0.0 || ^7.0.0": + version: 7.5.10 + resolution: "ws@npm:7.5.10" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/bd7d5f4aaf04fae7960c23dcb6c6375d525e00f795dd20b9385902bd008c40a94d3db3ce97d878acc7573df852056ca546328b27b39f47609f80fb22a0a9b61d + languageName: node + linkType: hard + +"ws@npm:^6.1.4": + version: 6.2.3 + resolution: "ws@npm:6.2.3" + dependencies: + async-limiter: "npm:~1.0.0" + checksum: 10c0/56a35b9799993cea7ce2260197e7879f21bbbb194a967f31acbbda6f7f46ecda4365951966fb062044c95197e19fb2f053be6f65c172435455186835f494de41 + languageName: node + linkType: hard + +"ws@npm:^8.18.0": + version: 8.20.0 + resolution: "ws@npm:8.20.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10c0/956ac5f11738c914089b65878b9223692ace77337ba55379ae68e1ecbeae9b47a0c6eb9403688f609999a58c80d83d99865fe0029b229d308b08c1ef93d4ea14 + languageName: node + linkType: hard + +"y18n@npm:^4.0.0": + version: 4.0.3 + resolution: "y18n@npm:4.0.3" + checksum: 10c0/308a2efd7cc296ab2c0f3b9284fd4827be01cfeb647b3ba18230e3a416eb1bc887ac050de9f8c4fd9e7856b2e8246e05d190b53c96c5ad8d8cb56dffb6f81024 + languageName: node + linkType: hard + +"yallist@npm:^5.0.0": + version: 5.0.0 + resolution: "yallist@npm:5.0.0" + checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416 + languageName: node + linkType: hard + +"yargs-parser@npm:^18.1.2": + version: 18.1.3 + resolution: "yargs-parser@npm:18.1.3" + dependencies: + camelcase: "npm:^5.0.0" + decamelize: "npm:^1.2.0" + checksum: 10c0/25df918833592a83f52e7e4f91ba7d7bfaa2b891ebf7fe901923c2ee797534f23a176913ff6ff7ebbc1cc1725a044cc6a6539fed8bfd4e13b5b16376875f9499 + languageName: node + linkType: hard + +"yargs@npm:^15.3.1": + version: 15.4.1 + resolution: "yargs@npm:15.4.1" + dependencies: + cliui: "npm:^6.0.0" + decamelize: "npm:^1.2.0" + find-up: "npm:^4.1.0" + get-caller-file: "npm:^2.0.1" + require-directory: "npm:^2.1.1" + require-main-filename: "npm:^2.0.0" + set-blocking: "npm:^2.0.0" + string-width: "npm:^4.2.0" + which-module: "npm:^2.0.0" + y18n: "npm:^4.0.0" + yargs-parser: "npm:^18.1.2" + checksum: 10c0/f1ca680c974333a5822732825cca7e95306c5a1e7750eb7b973ce6dc4f97a6b0a8837203c8b194f461969bfe1fb1176d1d423036635285f6010b392fa498ab2d + languageName: node + linkType: hard + +"yocto-queue@npm:^0.1.0": + version: 0.1.0 + resolution: "yocto-queue@npm:0.1.0" + checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f + languageName: node + linkType: hard + +"yoctocolors-cjs@npm:^2.1.2": + version: 2.1.3 + resolution: "yoctocolors-cjs@npm:2.1.3" + checksum: 10c0/584168ef98eb5d913473a4858dce128803c4a6cd87c0f09e954fa01126a59a33ab9e513b633ad9ab953786ed16efdd8c8700097a51635aafaeed3fef7712fa79 + languageName: node + linkType: hard + +"yoga-layout@npm:~3.2.1": + version: 3.2.1 + resolution: "yoga-layout@npm:3.2.1" + checksum: 10c0/9001e51be993c85e03757e5a04a2b61b8b30c9e5a7865d0156ca87a6431a3b717d51eb4990bfe588189fcfeac688dd9c3de707bbd50d1c344a84e63974cc54a8 + languageName: node + linkType: hard + +"zen-observable-ts@npm:^0.8.21": + version: 0.8.21 + resolution: "zen-observable-ts@npm:0.8.21" + dependencies: + tslib: "npm:^1.9.3" + zen-observable: "npm:^0.8.0" + checksum: 10c0/fe4a02f862b5f7e8ae0f86230c37b84c7d5611f5c206981afb4043e732d04cf7067a6cbe1ba82d20f18b735a3387937195a12542158a631d308ae3959a1d93c4 + languageName: node + linkType: hard + +"zen-observable@npm:^0.8.0": + version: 0.8.15 + resolution: "zen-observable@npm:0.8.15" + checksum: 10c0/71cc2f2bbb537300c3f569e25693d37b3bc91f225cefce251a71c30bc6bb3e7f8e9420ca0eb57f2ac9e492b085b8dfa075fd1e8195c40b83c951dd59c6e4fbf8 + languageName: node + linkType: hard + +"zod@npm:^4.1.11": + version: 4.3.6 + resolution: "zod@npm:4.3.6" + checksum: 10c0/860d25a81ab41d33aa25f8d0d07b091a04acb426e605f396227a796e9e800c44723ed96d0f53a512b57be3d1520f45bf69c0cb3b378a232a00787a2609625307 + languageName: node + linkType: hard