From 3ebeb3a3e881deee8aca4791bd2713fb26640cdc Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Wed, 22 Apr 2026 18:17:08 +0200 Subject: [PATCH] feat(community): add github-connector example app (#19961) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a new community app at `packages/twenty-apps/community/github-connector` that demonstrates a complete, production-style GitHub integration built on the Twenty SDK. It is extracted (and decoupled) from the internal `twenty-eng` workspace so external developers can use it as a reference for their own connectors. What it ships: - **Six synced objects**: `pullRequest`, `pullRequestReview`, `pullRequestReviewEvent`, `issue`, `projectItem`, `engineer` - **Logic functions** for periodic backfills (PRs, reviews, issues, project items, contributors) and a single signed-webhook route trigger (`POST /github/webhook`) that performs idempotent upserts for `pull_request`, `pull_request_review`, `issues`, and `projects_v2_item` events - **Views, navigation menu items and a GitHub folder** so the data is discoverable in the UI out of the box - **Configurable repos / project numbers** via `GITHUB_REPOS` and `GITHUB_PROJECT_NUMBERS` application variables — no hardcoded org ## Authentication Two interchangeable modes (PAT preferred for quick setup, GitHub App recommended for production): 1. **Personal Access Token** — set `GITHUB_TOKEN`. Used as-is for both REST and GraphQL. 2. **GitHub App** — set `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_INSTALLATION_ID`. Issues a signed JWT, exchanges it for a short-lived installation token, and caches the token until expiry. Webhook signature verification (`X-Hub-Signature-256`) is enforced when `GITHUB_WEBHOOK_SECRET` is set. ## Notes - Built on `twenty-sdk@2.0.0` / `twenty-client-sdk@2.0.0` - Decoupled from internal modules (`quality/bug`, `discord`, `release`, `code-build`, `project-management`) — `mustBeQa` is inlined and a local `github` nav folder replaces shared ones - `npx twenty typecheck`, `yarn lint`, and `npx twenty build` all run cleanly - Includes a comprehensive README with setup, env vars, webhook configuration, and the auth resolution flow --- .../community/github-connector/.gitignore | 32 + .../community/github-connector/.nvmrc | 1 + .../community/github-connector/.oxlintrc.json | 19 + .../community/github-connector/.yarnrc.yml | 1 + .../community/github-connector/README.md | 254 + .../community/github-connector/package.json | 35 + .../public/screenshots/app-listing.png | Bin 0 -> 44831 bytes .../public/screenshots/contributor-stats.png | Bin 0 -> 50313 bytes .../public/screenshots/github-dashboard.png | Bin 0 -> 72904 bytes .../public/screenshots/pull-requests-view.png | Bin 0 -> 93238 bytes .../src/__tests__/global-setup.ts | 54 + .../src/__tests__/helpers/metadata.ts | 128 + .../logic-functions.integration-test.ts | 73 + .../src/__tests__/schema.integration-test.ts | 124 + .../webhook-signature.integration-test.ts | 121 + .../src/application-config.ts | 66 + .../github-connector/src/default-role.ts | 16 + .../src/modules/github/connector/auth.ts | 122 + .../src/modules/github/connector/config.ts | 55 + .../github/connector/count-across-repos.ts | 45 + .../modules/github/connector/github-client.ts | 157 + .../modules/github/connector/github-user.ts | 5 + .../github/connector/webhook-payload.ts | 15 + .../github/connector/webhook-signature.ts | 54 + .../contributor/components/leaderboard.tsx | 282 ++ .../github/contributor/components/theme.ts | 23 + ...gned-project-items-on-contributor.field.ts | 24 + .../authored-issues-on-contributor.field.ts | 22 + .../authored-prs-on-contributor.field.ts | 23 + ...rged-pull-requests-on-contributor.field.ts | 24 + .../review-events-on-contributor.field.ts | 23 + .../fields/reviews-on-contributor.field.ts | 23 + .../contributor-stats.front-component.tsx | 693 +++ .../fetch-contributors.front-component.tsx | 106 + .../top-pr-authors.front-component.tsx | 66 + .../top-reviewers.front-component.tsx | 66 + .../graphql/github/count-contributors.ts | 20 + .../graphql/github/fetch-contributors.ts | 52 + .../graphql/mutations/batch-upsert.ts | 21 + .../graphql/queries/find-by-gh-login.ts | 27 + .../graphql/queries/search-contributors.ts | 52 + .../contributor-stats.logic-function.ts | 376 ++ .../count-contributors.logic-function.ts | 28 + .../fetch-contributors.logic-function.ts | 70 + .../search-contributors.logic-function.ts | 43 + .../top-contributors.logic-function.ts | 257 + .../modules/github/contributor/normalizers.ts | 47 + .../contributor/objects/contributor.object.ts | 72 + .../contributor/types/contributor-row.ts | 10 + .../github/contributor/utils/is-bot-login.ts | 30 + .../views/all-contributors.view.ts | 51 + .../issue/fields/author-on-issue.field.ts | 25 + .../fields/project-items-on-issue.field.ts | 23 + .../fetch-issues.front-component.tsx | 109 + .../issue/graphql/github/count-issues.ts | 26 + .../issue/graphql/github/fetch-issues.ts | 66 + .../issue/graphql/mutations/batch-upsert.ts | 27 + .../queries/find-by-number-and-repo.ts | 40 + .../count-issues.logic-function.ts | 24 + .../fetch-issues.logic-function.ts | 93 + .../issues.navigation-menu-item.ts | 11 + .../src/modules/github/issue/normalizers.ts | 63 + .../github/issue/objects/issue.object.ts | 134 + .../github/issue/types/github-issue.ts | 13 + .../modules/github/issue/types/issue-row.ts | 15 + .../github/issue/views/all-issues.view.ts | 87 + .../handle-webhook.logic-function.ts | 260 + .../contributors.navigation-menu-item.ts | 14 + .../github-dashboard.navigation-menu-item.ts | 17 + .../github-folder.navigation-menu-item.ts | 13 + .../github-dashboard.page-layout.ts | 281 ++ .../linked-issue-on-project-item.field.ts | 24 + .../fields/linked-pr-on-project-item.field.ts | 25 + .../main-assignee-on-project-item.field.ts | 25 + .../fetch-project-items.front-component.tsx | 108 + .../graphql/github/count-project-items.ts | 41 + .../github/fetch-project-item-by-node-id.ts | 31 + .../graphql/github/fetch-project-items.ts | 78 + .../project-item/graphql/github/fragments.ts | 46 + .../graphql/mutations/batch-upsert.ts | 29 + .../count-project-items.logic-function.ts | 47 + .../fetch-project-items.logic-function.ts | 57 + .../project-items.navigation-menu-item.ts | 11 + .../github/project-item/normalizers.ts | 106 + .../objects/project-item.object.ts | 181 + .../types/github-project-v2-item.ts | 7 + .../project-item/types/project-item-row.ts | 16 + .../project-item/types/project-v2-item.ts | 42 + .../project-item/utils/extract-field-value.ts | 34 + .../views/all-project-items.view.ts | 123 + .../pull-request-on-review-event.field.ts | 30 + .../fields/review-on-review-event.field.ts | 30 + .../fields/reviewer-on-review-event.field.ts | 30 + .../graphql/mutations/batch-upsert.ts | 25 + .../graphql/queries/find-events-for-pair.ts | 61 + ...uest-review-events.navigation-menu-item.ts | 15 + .../pull-request-review-event/normalizers.ts | 48 + .../pull-request-review-event.object.ts | 97 + .../types/github-review.ts | 9 + .../types/pull-request-review-event-row.ts | 10 + .../all-pull-request-review-events.view.ts | 78 + .../fields/events-on-review.field.ts | 23 + .../fields/pull-request-on-review.field.ts | 30 + .../fields/reviewer-on-review.field.ts | 30 + .../graphql/mutations/batch-upsert.ts | 19 + ...ute-pull-request-reviews.logic-function.ts | 164 + ...ll-request-reviews.navigation-menu-item.ts | 14 + .../objects/pull-request-review.object.ts | 120 + .../types/pull-request-review-row.ts | 11 + .../utils/build-consolidated-row.ts | 53 + .../consolidate-reviews.integration-test.ts | 145 + .../utils/consolidate-reviews.ts | 54 + .../views/all-pull-request-reviews.view.ts | 95 + .../fields/author-on-pull-request.field.ts | 25 + .../fields/merger-on-pull-request.field.ts | 25 + .../project-items-on-pull-request.field.ts | 23 + .../review-events-on-pull-request.field.ts | 23 + .../fields/reviews-on-pull-request.field.ts | 23 + .../fetch-prs.front-component.tsx | 112 + .../graphql/github/count-pull-requests.ts | 26 + .../graphql/github/fetch-pull-requests.ts | 82 + .../graphql/mutations/batch-upsert.ts | 26 + .../graphql/mutations/update-pull-request.ts | 14 + .../graphql/queries/find-by-github-number.ts | 55 + .../count-prs.logic-function.ts | 24 + .../fetch-prs.logic-function.ts | 230 + .../pull-requests.navigation-menu-item.ts | 11 + .../github/pull-request/normalizers.ts | 64 + .../objects/pull-request.object.ts | 133 + .../pull-request/types/github-pull-request.ts | 14 + .../pull-request/types/pull-request-row.ts | 15 + .../views/all-pull-requests.view.ts | 84 + .../src/modules/shared/call-app-route.ts | 50 + .../src/modules/shared/fixtures.ts | 3 + .../src/modules/shared/retry.ts | 49 + .../src/modules/shared/timing.ts | 17 + .../src/modules/shared/twenty-client.ts | 48 + .../src/modules/shared/types.ts | 9 + .../modules/shared/universal-identifiers.ts | 30 + .../community/github-connector/tsconfig.json | 42 + .../github-connector/tsconfig.spec.json | 9 + .../github-connector/vitest.config.ts | 30 + .../community/github-connector/yarn.lock | 4292 +++++++++++++++++ 143 files changed, 13059 insertions(+) create mode 100644 packages/twenty-apps/community/github-connector/.gitignore create mode 100644 packages/twenty-apps/community/github-connector/.nvmrc create mode 100644 packages/twenty-apps/community/github-connector/.oxlintrc.json create mode 100644 packages/twenty-apps/community/github-connector/.yarnrc.yml create mode 100644 packages/twenty-apps/community/github-connector/README.md create mode 100644 packages/twenty-apps/community/github-connector/package.json create mode 100644 packages/twenty-apps/community/github-connector/public/screenshots/app-listing.png create mode 100644 packages/twenty-apps/community/github-connector/public/screenshots/contributor-stats.png create mode 100644 packages/twenty-apps/community/github-connector/public/screenshots/github-dashboard.png create mode 100644 packages/twenty-apps/community/github-connector/public/screenshots/pull-requests-view.png create mode 100644 packages/twenty-apps/community/github-connector/src/__tests__/global-setup.ts create mode 100644 packages/twenty-apps/community/github-connector/src/__tests__/helpers/metadata.ts create mode 100644 packages/twenty-apps/community/github-connector/src/__tests__/logic-functions.integration-test.ts create mode 100644 packages/twenty-apps/community/github-connector/src/__tests__/schema.integration-test.ts create mode 100644 packages/twenty-apps/community/github-connector/src/__tests__/webhook-signature.integration-test.ts create mode 100644 packages/twenty-apps/community/github-connector/src/application-config.ts create mode 100644 packages/twenty-apps/community/github-connector/src/default-role.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/connector/auth.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/connector/config.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/connector/count-across-repos.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/connector/github-client.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/connector/github-user.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/connector/webhook-payload.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/connector/webhook-signature.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/components/leaderboard.tsx create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/components/theme.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/assigned-project-items-on-contributor.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/authored-issues-on-contributor.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/authored-prs-on-contributor.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/merged-pull-requests-on-contributor.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/review-events-on-contributor.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/fields/reviews-on-contributor.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/front-components/contributor-stats.front-component.tsx create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/front-components/fetch-contributors.front-component.tsx create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/front-components/top-pr-authors.front-component.tsx create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/front-components/top-reviewers.front-component.tsx create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/github/count-contributors.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/github/fetch-contributors.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/mutations/batch-upsert.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/queries/find-by-gh-login.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/graphql/queries/search-contributors.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/contributor-stats.logic-function.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/count-contributors.logic-function.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/fetch-contributors.logic-function.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/search-contributors.logic-function.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/logic-functions/top-contributors.logic-function.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/normalizers.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/objects/contributor.object.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/types/contributor-row.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/utils/is-bot-login.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/contributor/views/all-contributors.view.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/issue/fields/author-on-issue.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/issue/fields/project-items-on-issue.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/issue/front-components/fetch-issues.front-component.tsx create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/issue/graphql/github/count-issues.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/issue/graphql/github/fetch-issues.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/issue/graphql/mutations/batch-upsert.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/issue/graphql/queries/find-by-number-and-repo.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/issue/logic-functions/count-issues.logic-function.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/issue/logic-functions/fetch-issues.logic-function.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/issue/navigation-menu-items/issues.navigation-menu-item.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/issue/normalizers.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/issue/objects/issue.object.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/issue/types/github-issue.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/issue/types/issue-row.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/issue/views/all-issues.view.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/logic-functions/handle-webhook.logic-function.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/navigation-menu-items/contributors.navigation-menu-item.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/navigation-menu-items/github-dashboard.navigation-menu-item.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/navigation-menu-items/github-folder.navigation-menu-item.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/page-layouts/github-dashboard.page-layout.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/fields/linked-issue-on-project-item.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/fields/linked-pr-on-project-item.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/fields/main-assignee-on-project-item.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/front-components/fetch-project-items.front-component.tsx create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/github/count-project-items.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/github/fetch-project-item-by-node-id.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/github/fetch-project-items.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/github/fragments.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/graphql/mutations/batch-upsert.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/logic-functions/count-project-items.logic-function.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/logic-functions/fetch-project-items.logic-function.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/navigation-menu-items/project-items.navigation-menu-item.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/normalizers.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/objects/project-item.object.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/types/github-project-v2-item.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/types/project-item-row.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/types/project-v2-item.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/utils/extract-field-value.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/project-item/views/all-project-items.view.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/fields/pull-request-on-review-event.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/fields/review-on-review-event.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/fields/reviewer-on-review-event.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/graphql/mutations/batch-upsert.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/graphql/queries/find-events-for-pair.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/navigation-menu-items/pull-request-review-events.navigation-menu-item.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/normalizers.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/objects/pull-request-review-event.object.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/types/github-review.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/types/pull-request-review-event-row.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review-event/views/all-pull-request-review-events.view.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/fields/events-on-review.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/fields/pull-request-on-review.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/fields/reviewer-on-review.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/graphql/mutations/batch-upsert.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/logic-functions/recompute-pull-request-reviews.logic-function.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/navigation-menu-items/pull-request-reviews.navigation-menu-item.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/objects/pull-request-review.object.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/types/pull-request-review-row.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/utils/build-consolidated-row.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/utils/consolidate-reviews.integration-test.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/utils/consolidate-reviews.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request-review/views/all-pull-request-reviews.view.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/author-on-pull-request.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/merger-on-pull-request.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/project-items-on-pull-request.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/review-events-on-pull-request.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/fields/reviews-on-pull-request.field.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/front-components/fetch-prs.front-component.tsx create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/github/count-pull-requests.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/github/fetch-pull-requests.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/mutations/batch-upsert.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/mutations/update-pull-request.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/graphql/queries/find-by-github-number.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/logic-functions/count-prs.logic-function.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/logic-functions/fetch-prs.logic-function.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/navigation-menu-items/pull-requests.navigation-menu-item.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/normalizers.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/objects/pull-request.object.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/types/github-pull-request.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/types/pull-request-row.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/github/pull-request/views/all-pull-requests.view.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/shared/call-app-route.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/shared/fixtures.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/shared/retry.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/shared/timing.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/shared/twenty-client.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/shared/types.ts create mode 100644 packages/twenty-apps/community/github-connector/src/modules/shared/universal-identifiers.ts create mode 100644 packages/twenty-apps/community/github-connector/tsconfig.json create mode 100644 packages/twenty-apps/community/github-connector/tsconfig.spec.json create mode 100644 packages/twenty-apps/community/github-connector/vitest.config.ts create mode 100644 packages/twenty-apps/community/github-connector/yarn.lock 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 0000000000000000000000000000000000000000..1b3b88a238e1e13959b41d4605e342ff5f66ab27 GIT binary patch literal 44831 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ي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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f33c31d23ad006a6be70694dd2e93175ad53bf48 GIT binary patch literal 72904 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%0A9zxj2Kt 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 Vt>SA%Cxq!jEzo;8A)$XP{~tSUhI;@2 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c8ad0ccb9ce5f4d0a81a8edc08ab1bf9dc440018 GIT binary patch literal 93238 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 z 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 literal 0 HcmV?d00001 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