Compare commits

..
Author SHA1 Message Date
Sonarly Claude Code e82aa6d7a6 chore: improve monitoring for fix(server): add MemorySize to Lambda executor fun
Changed the HTTP status code for `LOGIC_FUNCTION_EXECUTION_ERROR` from 500 to 422 in the route-trigger REST API exception filter.

Logic function execution errors are user code failures — a user's webhook handler crashing, running out of memory, or throwing an unhandled exception. These are expected operational behavior for a platform that runs user-provided code, not platform infrastructure failures.

Returning 500 caused `shouldCaptureException()` (which filters out errors with statusCode < 500) to report every user code failure to Sentry, creating monitoring noise that obscures actual platform bugs. With 422 (Unprocessable Entity), the error is still returned to the caller with the full error details and `LOGIC_FUNCTION_EXECUTION_ERROR` code in the response body, but it will no longer be captured by Sentry's exception handler.

The 422 status is semantically correct: the server understood the webhook request but the user's logic function could not process it successfully.
2026-04-10 12:25:02 +00:00
Sonarly Claude Code cb92f6ea42 fix(server): add MemorySize to Lambda executor function creation
https://sonarly.com/issue/23736?type=bug

User-deployed logic functions running as AWS Lambda executors are created without a `MemorySize` parameter, defaulting to 128 MB — causing OOM kills ("signal: killed") when functions process non-trivial payloads like webhook callbacks.

Fix: Added `EXECUTOR_LAMBDA_MEMORY_MB = 512` constant and applied it to the `createLambdaExecutor()` Lambda creation call in `lambda.driver.ts`.

The root cause was that the `CreateFunctionCommandInput` for executor Lambdas (which run user logic functions) omitted the `MemorySize` parameter, causing AWS to use the default 128 MB. This is insufficient for Node.js 22 functions that need to load the twenty-client-sdk layer, user dependencies, and process webhook payloads. The "signal: killed" error is the Lambda runtime being OOM-killed by the kernel.

The fix adds `MemorySize: EXECUTOR_LAMBDA_MEMORY_MB` (512 MB) to match the builder Lambda allocation and follow the same constant pattern used throughout the file. Both the yarn-install (1024 MB) and builder (512 MB) Lambdas already had explicit memory configured.

**Important deployment note:** Existing Lambda executor functions already provisioned at 128 MB will NOT be automatically recreated. They will continue using 128 MB until the logic function's code changes or the SDK layer becomes stale, which triggers a rebuild via `canSkipBuild()`. For the affected customer, the fastest resolution is to trigger a re-save of their `on-recall-webhook` logic function, which will force a Lambda rebuild with the new 512 MB allocation.
2026-04-10 12:25:02 +00:00
3866 changed files with 90373 additions and 192883 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ This main guide provides a high-level overview and navigation hub.
A syncable entity is a metadata entity that:
- Has a **`universalIdentifier`**: A unique identifier used for syncing entities across workspaces/applications
- Has an **`applicationId`**: Links the entity to an application (Standard or Custom applications)
- Has an **`applicationId`**: Links the entity to an application (Twenty Standard or Custom applications)
- Participates in the **workspace migration system**: Can be created, updated, and deleted through the migration pipeline
- Is **cached as a flat entity**: Denormalized representation for efficient validation and change detection
+1 -8
View File
@@ -8,10 +8,6 @@ inputs:
api-key:
description: API key or access token for the target instance
required: true
app-path:
description: Path to the app directory (relative to repo root). Defaults to repo root.
required: false
default: '.'
runs:
using: composite
@@ -23,13 +19,11 @@ runs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '${{ inputs.app-path }}/.nvmrc'
node-version-file: '.nvmrc'
cache: yarn
cache-dependency-path: '${{ inputs.app-path }}/yarn.lock'
- name: Install dependencies
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn install --immutable
- name: Configure remote
@@ -49,5 +43,4 @@ runs:
- name: Deploy
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty deploy --remote target
@@ -8,10 +8,6 @@ inputs:
api-key:
description: API key or access token for the target workspace
required: true
app-path:
description: Path to the app directory (relative to repo root). Defaults to repo root.
required: false
default: '.'
runs:
using: composite
@@ -23,13 +19,11 @@ runs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: '${{ inputs.app-path }}/.nvmrc'
node-version-file: '.nvmrc'
cache: yarn
cache-dependency-path: '${{ inputs.app-path }}/yarn.lock'
- name: Install dependencies
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn install --immutable
- name: Configure remote
@@ -49,5 +43,4 @@ runs:
- name: Install
shell: bash
working-directory: ${{ inputs.app-path }}
run: yarn twenty install --remote target
@@ -15,8 +15,8 @@ outputs:
description: 'URL where the Twenty test server can be reached'
value: http://localhost:2021
api-key:
description: 'API key (type: API_KEY) for the seeded Twenty dev workspace'
value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc
description: 'API key for the Twenty test instance'
value: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4
runs:
using: 'composite'
+7 -17
View File
@@ -19,7 +19,6 @@ jobs:
files: |
packages/twenty-sdk/**
packages/twenty-server/**
.github/workflows/ci-sdk.yaml
!packages/twenty-sdk/package.json
sdk-test:
needs: changed-files-check
@@ -70,6 +69,7 @@ jobs:
ports:
- 6379:6379
env:
NODE_ENV: test
TWENTY_API_URL: http://localhost:3000
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
steps:
@@ -79,26 +79,16 @@ jobs:
fetch-depth: 10
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Build SDK
- name: Build
run: npx nx build twenty-sdk
- name: Setup server environment
run: npx nx reset:env:e2e-testing-server twenty-server
- name: Create databases
- name: Server / Create Test DB
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "default";'
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d postgres -c 'CREATE DATABASE "test";'
- name: Setup database
run: npx nx run twenty-server:database:reset
- name: Start server
run: nohup npx nx start:ci twenty-server > /tmp/twenty-server.log 2>&1 &
- name: Wait for server to be ready
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
- name: SDK / Run e2e Tests
run: NODE_ENV=test npx vitest run --config ./vitest.e2e.config.ts
working-directory: packages/twenty-sdk
- name: Server / Dump logs on failure
if: failure()
run: tail -100 /tmp/twenty-server.log || echo "No server log file found"
uses: ./.github/actions/nx-affected
with:
tag: scope:sdk
tasks: test:e2e
ci-sdk-status-check:
if: always() && !cancelled()
timeout-minutes: 5
+9 -14
View File
@@ -25,7 +25,6 @@ jobs:
packages/twenty-server/**
packages/twenty-front/src/generated/**
packages/twenty-front/src/generated-metadata/**
packages/twenty-front/src/generated-admin/**
packages/twenty-client-sdk/**
packages/twenty-emails/**
packages/twenty-shared/**
@@ -145,18 +144,15 @@ jobs:
exit 1
- name: Server / Check for Pending Migrations
run: |
npx nx database:migrate:generate twenty-server -- --name pending-migration-check || true
CORE_MIGRATION_OUTPUT=$(npx nx database:migrate:generate twenty-server -- --name core-migration-check || true)
if ! git diff --quiet; then
CORE_MIGRATION_FILE=$(ls packages/twenty-server/src/database/typeorm/core/migrations/common/*core-migration-check.ts 2>/dev/null || echo "")
if [ -n "$CORE_MIGRATION_FILE" ]; then
echo "::error::Unexpected migration files were generated. Please run 'npx nx database:migrate:generate twenty-server -- --name <migration-name>' and commit the result."
echo ""
echo "The following migration changes were detected:"
echo "==================================================="
git diff
echo "==================================================="
echo ""
echo "$CORE_MIGRATION_OUTPUT"
git checkout -- .
rm -f packages/twenty-server/src/database/typeorm/core/migrations/common/*core-migration-check.ts
exit 1
fi
@@ -166,14 +162,13 @@ jobs:
npx nx run twenty-front:graphql:generate
npx nx run twenty-front:graphql:generate --configuration=metadata
npx nx run twenty-front:graphql:generate --configuration=admin
if ! git diff --quiet -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata packages/twenty-front/src/generated-admin; then
echo "::error::GraphQL schema changes detected. Please run the three graphql:generate configurations ('data', 'metadata', 'admin') and commit the changes."
if ! git diff --quiet -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata; then
echo "::error::GraphQL schema changes detected. Please run 'npx nx run twenty-front:graphql:generate' and 'npx nx run twenty-front:graphql:generate --configuration=metadata' and commit the changes."
echo ""
echo "The following GraphQL schema changes were detected:"
echo "==================================================="
git diff -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata packages/twenty-front/src/generated-admin
git diff -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata
echo "==================================================="
echo ""
HAS_ERRORS=true
+3 -4
View File
@@ -69,14 +69,13 @@ jobs:
- name: Server / Start
run: |
npx nx run twenty-server:start:ci &
npx nx start twenty-server &
echo "Waiting for server to be ready..."
timeout 60 bash -c 'until curl -sf http://localhost:3000/healthz; do sleep 2; done'
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
- name: Start worker
working-directory: packages/twenty-server
run: |
NODE_ENV=development node dist/queue-worker/queue-worker.js &
npx nx run twenty-server:worker &
echo "Worker started"
- name: Zapier / Build
+1 -3
View File
@@ -1,8 +1,7 @@
**/**/.env
.DS_Store
/.idea
.claude/
.cursor/debug-*.log
.claude/settings.json
**/**/node_modules/
.cache
@@ -54,4 +53,3 @@ mcp.json
TRANSLATION_QA_REPORT.md
.playwright-mcp/
.playwright-cli/
output/playwright/
+89 -122
View File
@@ -4,161 +4,123 @@
</a>
</p>
<h2 align="center" >The #1 Open-Source CRM</h2>
<h2 align="center" >The #1 Open-Source CRM </h2>
<p align="center"><a href="https://twenty.com">🌐 Website</a> · <a href="https://docs.twenty.com">📚 Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.png" width="12" height="12"/> Figma</a></p>
<br />
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.png" width="12" height="12"/> Figma</a></p>
<p align="center">
<a href="https://www.twenty.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/github-cover-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/github-cover-light.png" />
<img src="./packages/twenty-website/public/images/readme/github-cover-light.png" alt="Twenty banner" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/github-cover-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/github-cover-light.png" />
<img src="./packages/twenty-website/public/images/readme/github-cover-light.png" alt="Cover" />
</picture>
</a>
</p>
<br />
# Why Twenty
Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.
<a href="https://twenty.com/why-twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
<br />
# Installation
### <img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
See:
🚀 [Self-hosting](https://docs.twenty.com/developers/self-host/capabilities/docker-compose)
🖥️ [Local Setup](https://docs.twenty.com/developers/contribute/capabilities/local-setup)
The fastest way to get started. Sign up at [twenty.com](https://twenty.com) and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.
# Why Twenty
### <img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
We built Twenty for three reasons:
Scaffold a new app with the Twenty CLI:
**CRMs are too expensive, and users are trapped.** Companies use locked-in customer data to hike prices. It shouldn't be that way.
```bash
npx create-twenty-app my-app
```
**A fresh start is required to build a better experience.** We can learn from past mistakes and craft a cohesive experience inspired by new UX patterns from tools like Notion, Airtable or Linear.
Define objects, fields, and views as code:
```ts
import { defineObject, FieldType } from 'twenty-sdk/define';
export default defineObject({
nameSingular: 'deal',
namePlural: 'deals',
labelSingular: 'Deal',
labelPlural: 'Deals',
fields: [
{ name: 'name', label: 'Name', type: FieldType.TEXT },
{ name: 'amount', label: 'Amount', type: FieldType.CURRENCY },
{ name: 'closeDate', label: 'Close Date', type: FieldType.DATE_TIME },
],
});
```
Then ship it to your workspace:
```bash
npx twenty deploy
```
See the [app development guide](https://docs.twenty.com/developers/extend/apps/getting-started) for objects, views, agents, and logic functions.
### <img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.com/developers/self-host/capabilities/docker-compose), or contribute locally via the [local setup guide](https://docs.twenty.com/developers/contribute/capabilities/local-setup).
**We believe in open-source and community.** Hundreds of developers are already building Twenty together. Once we have plugin capabilities, a whole ecosystem will grow around it.
<br />
<br />
# Everything you need
# What You Can Do With Twenty
Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.
Please feel free to flag any specific needs you have by creating an issue.
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
Below are a few features we have implemented to date:
<table align="center">
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-build-apps-light.png" alt="Create your apps" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-version-control-light.png" alt="Stay on top with version control" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-all-tools-light.png" alt="All the tools you need to build anything" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-tools-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-tools-light.png" alt="Customize your layouts" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-ai-agents-light.png" alt="AI agents and chats" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-crm-tools-light.png" alt="Plus all the tools of a good CRM" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
</td>
</tr>
</table>
+ [Personalize layouts with filters, sort, group by, kanban and table views](#personalize-layouts-with-filters-sort-group-by-kanban-and-table-views)
+ [Customize your objects and fields](#customize-your-objects-and-fields)
+ [Create and manage permissions with custom roles](#create-and-manage-permissions-with-custom-roles)
+ [Automate workflow with triggers and actions](#automate-workflow-with-triggers-and-actions)
+ [Emails, calendar events, files, and more](#emails-calendar-events-files-and-more)
## Personalize layouts with filters, sort, group by, kanban and table views
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/views-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/views-light.png" />
<img src="./packages/twenty-website/public/images/readme/views-light.png" alt="Companies Kanban Views" />
</picture>
</p>
## Customize your objects and fields
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/data-model-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/data-model-light.png" />
<img src="./packages/twenty-website/public/images/readme/data-model-light.png" alt="Setting Custom Objects" />
</picture>
</p>
## Create and manage permissions with custom roles
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/permissions-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/permissions-light.png" />
<img src="./packages/twenty-website/public/images/readme/permissions-light.png" alt="Permissions" />
</picture>
</p>
## Automate workflow with triggers and actions
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/workflows-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/workflows-light.png" />
<img src="./packages/twenty-website/public/images/readme/workflows-light.png" alt="Workflows" />
</picture>
</p>
## Emails, calendar events, files, and more
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/plus-other-features-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/plus-other-features-light.png" />
<img src="./packages/twenty-website/public/images/readme/plus-other-features-light.png" alt="Other Features" />
</picture>
</p>
<br />
# Stack
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
- <a href="https://nx.dev/"><img src="./packages/twenty-website/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
- <a href="https://nestjs.com/"><img src="./packages/twenty-website/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
- <a href="https://reactjs.org/"><img src="./packages/twenty-website/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
- [TypeScript](https://www.typescriptlang.org/)
- [Nx](https://nx.dev/)
- [NestJS](https://nestjs.com/), with [BullMQ](https://bullmq.io/), [PostgreSQL](https://www.postgresql.org/), [Redis](https://redis.io/)
- [React](https://reactjs.org/), with [Jotai](https://jotai.org/), [Linaria](https://linaria.dev/) and [Lingui](https://lingui.dev/)
# Thanks
<p align="center">
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.png" height="28" alt="Chromatic" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.png" height="28" alt="Greptile" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.png" height="28" alt="Sentry" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.png" height="28" alt="Crowdin" /></a>
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.png" height="30" alt="Chromatic" /></a>
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.png" height="30" alt="Greptile" /></a>
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.png" height="30" alt="Sentry" /></a>
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.png" height="30" alt="Crowdin" /></a>
<a href="https://e2b.dev/"><img src="./packages/twenty-website/public/images/readme/e2b.svg" height="30" alt="E2B" /></a>
</p>
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
@@ -166,4 +128,9 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
# Join the Community
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
- Star the repo
- Subscribe to releases (watch -> custom -> releases)
- Follow us on [Twitter](https://twitter.com/twentycrm) or [LinkedIn](https://www.linkedin.com/company/twenty/)
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
- Improve translations on [Crowdin](https://twenty.crowdin.com/twenty)
- [Contributions](https://github.com/twentyhq/twenty/contribute) are, of course, most welcome!
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.0.0",
"version": "0.9.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -7,7 +7,9 @@
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [],
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
@@ -0,0 +1,70 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { appBuild, appDeploy, appInstall, appUninstall } from 'twenty-sdk/cli';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const APP_PATH = process.cwd();
describe('App installation', () => {
beforeAll(async () => {
const buildResult = await appBuild({
appPath: APP_PATH,
tarball: true,
onProgress: (message: string) => console.log(`[build] ${message}`),
});
if (!buildResult.success) {
throw new Error(
`Build failed: ${buildResult.error?.message ?? 'Unknown error'}`,
);
}
const deployResult = await appDeploy({
tarballPath: buildResult.data.tarballPath!,
onProgress: (message: string) => console.log(`[deploy] ${message}`),
});
if (!deployResult.success) {
throw new Error(
`Deploy failed: ${deployResult.error?.message ?? 'Unknown error'}`,
);
}
const installResult = await appInstall({ appPath: APP_PATH });
if (!installResult.success) {
throw new Error(
`Install failed: ${installResult.error?.message ?? 'Unknown error'}`,
);
}
});
afterAll(async () => {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
});
it('should find the installed app in the applications list', async () => {
const metadataClient = new MetadataApiClient();
const result = await metadataClient.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const installedApp = result.findManyApplications.find(
(application: { universalIdentifier: string }) =>
application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(installedApp).toBeDefined();
});
});
@@ -1,87 +0,0 @@
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 validateEnv(): { apiUrl: string; apiKey: string } {
const apiUrl = process.env.TWENTY_API_URL;
const apiKey = process.env.TWENTY_API_KEY;
if (!apiUrl || !apiKey) {
throw new Error(
'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' +
'Start a local server: yarn twenty server start\n' +
'Or set them in vitest env config.',
);
}
return { apiUrl, apiKey };
}
async function checkServer(apiUrl: string) {
let response: Response;
try {
response = await fetch(`${apiUrl}/healthz`);
} catch {
throw new Error(
`Twenty server is not reachable at ${apiUrl}. ` +
'Make sure the server is running before executing integration tests.',
);
}
if (!response.ok) {
throw new Error(`Server at ${apiUrl} returned ${response.status}`);
}
}
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, apiKey } = validateEnv();
await checkServer(apiUrl);
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'}`,
);
}
}
@@ -1,46 +0,0 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { describe, expect, it } from 'vitest';
describe('App installation', () => {
it('should find the installed app in the applications list', async () => {
const client = new MetadataApiClient();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
const app = result.findManyApplications.find(
(a: { universalIdentifier: string }) =>
a.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER,
);
expect(app).toBeDefined();
});
});
describe('CoreApiClient', () => {
it('should support CRUD on standard objects', async () => {
const client = new CoreApiClient();
const created = await client.mutation({
createNote: {
__args: { data: { title: 'Integration test note' } },
id: true,
},
});
expect(created.createNote.id).toBeDefined();
await client.mutation({
destroyNote: {
__args: { id: created.createNote.id },
id: true,
},
});
});
});
@@ -1,4 +1,4 @@
import { defineApplication } from 'twenty-sdk/define';
import { defineApplication } from 'twenty-sdk';
import {
APP_DESCRIPTION,
@@ -1,4 +1,4 @@
import { defineRole } from 'twenty-sdk/define';
import { defineRole } from 'twenty-sdk';
import {
APP_DISPLAY_NAME,
@@ -1,15 +1,6 @@
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vitest/config';
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
const TWENTY_API_KEY =
process.env.TWENTY_API_KEY ??
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc';
// Make env vars available to globalSetup (test.env only applies to workers)
process.env.TWENTY_API_URL = TWENTY_API_URL;
process.env.TWENTY_API_KEY = TWENTY_API_KEY;
export default defineConfig({
plugins: [
tsconfigPaths({
@@ -20,12 +11,14 @@ export default defineConfig({
test: {
testTimeout: 120_000,
hookTimeout: 120_000,
fileParallelism: false,
include: ['src/**/*.integration-test.ts'],
globalSetup: ['src/__tests__/global-setup.ts'],
setupFiles: ['src/__tests__/setup-test.ts'],
env: {
TWENTY_API_URL,
TWENTY_API_KEY,
TWENTY_API_URL: process.env.TWENTY_API_URL ?? 'http://localhost:2020',
TWENTY_API_KEY:
process.env.TWENTY_API_KEY ??
// Tim Apple (admin) access token for twenty-app-dev
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ1c2VySWQiOiIyMDIwMjAyMC05ZTNiLTQ2ZDQtYTU1Ni04OGI5ZGRjMmIwMzQiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtMDY4Ny00YzQxLWI3MDctZWQxYmZjYTk3MmE3IiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtOWUzYi00NmQ0LWE1NTYtODhiOWRkYzJiMDM1IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjQ5MDQ4ODE3MDR9.9S4wc0MOr5iczsomlFxZdOHD1IRDS4dnRSwNVNpctF4',
},
},
});
@@ -149,25 +149,6 @@ describe('copyBaseApplicationProject', () => {
);
});
it('should create an empty public directory in the scaffolded project', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const publicDirectoryPath = join(testAppDirectory, 'public');
expect(await fs.pathExists(publicDirectoryPath)).toBe(true);
const publicDirectoryStats = await fs.stat(publicDirectoryPath);
expect(publicDirectoryStats.isDirectory()).toBe(true);
const publicDirectoryContents = await fs.readdir(publicDirectoryPath);
expect(publicDirectoryContents).toHaveLength(0);
});
it('should handle empty description', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
@@ -23,8 +23,6 @@ export const copyBaseApplicationProject = async ({
await renameDotfiles({ appDirectory });
await addEmptyPublicDirectory({ appDirectory });
await generateUniversalIdentifiers({
appDisplayName,
appDescription,
@@ -51,14 +49,6 @@ const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
}
};
const addEmptyPublicDirectory = async ({
appDirectory,
}: {
appDirectory: string;
}) => {
await fs.ensureDir(join(appDirectory, 'public'));
};
const generateUniversalIdentifiers = async ({
appDisplayName,
appDescription,
@@ -1,5 +1,5 @@
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { defineApplication } from 'twenty-sdk/define';
import { defineApplication } from 'twenty-sdk';
export default defineApplication({
universalIdentifier: 'ac1d2ed1-8835-4bd4-9043-28b46fdda465',
@@ -1,4 +1,8 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: 'da15cfc6-3657-457d-8757-4ba11b5bb6e1',
@@ -1,4 +1,8 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: '505532f5-1fc5-4a58-8074-ba9b48650dbc',
@@ -1,4 +1,8 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: 'be15e062-b065-48b4-979c-65b9a50e0cb1',
@@ -1,4 +1,8 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: 'c90ae72d-4ddf-4f22-882f-eef98c91e40e',
@@ -2,7 +2,7 @@ import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
import { OAuthApplicationVariables } from 'src/logic-functions/get-oauth-application-variables';
import { VERIFY_PAGE_PATH } from 'src/logic-functions/get-verify-page';
import { defineFrontComponent } from 'twenty-sdk/define';
import { defineFrontComponent } from 'twenty-sdk';
const StyledContainer = styled.div`
display: flex;
@@ -1,4 +1,4 @@
import { defineLogicFunction, RoutePayload } from "twenty-sdk/define";
import { defineLogicFunction, RoutePayload } from "twenty-sdk";
import { MetadataApiClient } from 'twenty-sdk/clients';
export const OAUTH_TOKEN_PAIRS_PATH = '/oauth/token-pairs';
@@ -1,4 +1,4 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk';
export type OAuthApplicationVariables = {
apolloClientId: string;
@@ -1,4 +1,4 @@
import { defineLogicFunction } from "twenty-sdk/define";
import { defineLogicFunction } from "twenty-sdk";
export const VERIFY_PAGE_PATH = '/oauth/verify';
@@ -1,4 +1,8 @@
import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordUpdateEvent } from 'twenty-sdk/define';
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/clients';
type CompanyRecord = {
@@ -1,4 +1,4 @@
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk/define';
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Post install logic function executed successfully!', payload.previousVersion);
@@ -1,4 +1,4 @@
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk/define';
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Pre install logic function executed successfully!', payload.previousVersion);
@@ -1,4 +1,4 @@
import { defineRole } from 'twenty-sdk/define';
import { defineRole } from 'twenty-sdk';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b8faae3f-e174-43fa-ab94-715712ae26cb';
@@ -1,4 +1,4 @@
import { type ApplicationConfig } from 'twenty-sdk/define';
import { type ApplicationConfig } from 'twenty-sdk';
const config: ApplicationConfig = {
universalIdentifier: 'a4df0c0f-c65e-44e5-8436-24814182d4ac',
@@ -1,4 +1,4 @@
import { Object } from 'twenty-sdk/define';
import { Object } from 'twenty-sdk';
@Object({
universalIdentifier: 'd1831348-b4a4-4426-9c0b-0af19e7a9c27',
@@ -1,4 +1,4 @@
import { type FunctionConfig } from 'twenty-sdk/define';
import { type FunctionConfig } from 'twenty-sdk';
import type { ProcessResult } from './types';
import { WebhookHandler } from './webhook-handler';
@@ -1,32 +0,0 @@
# 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
@@ -1 +0,0 @@
24.5.0
@@ -1,19 +0,0 @@
{
"$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"
}
}
@@ -1 +0,0 @@
nodeLinker: node-modules
@@ -1,254 +0,0 @@
# 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
<https://github.com/settings/personal-access-tokens>:
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. <https://github.com/settings/apps/new> (or
`https://github.com/organizations/<org>/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.
@@ -1,35 +0,0 @@
{
"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"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

@@ -1,54 +0,0 @@
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'}`,
);
}
}
@@ -1,128 +0,0 @@
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<string> {
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<string, unknown>,
): Promise<ExecutionResult> {
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, unknown> | string,
options: {
method?: string;
auth?: boolean;
headers?: Record<string, string>;
} = {},
): Promise<{ status: number; data: unknown }> {
const headers: Record<string, string> = {
'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,
);
}
@@ -1,73 +0,0 @@
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<string, string> = {};
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<string, unknown>)).toBe(false);
}
});
it('handle-github-webhook is registered (but rejects unsigned requests gracefully)', async () => {
expect(fnIds.webhook).toBeDefined();
});
});
@@ -1,124 +0,0 @@
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');
});
});
@@ -1,121 +0,0 @@
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',
});
});
});
@@ -1,66 +0,0 @@
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,
},
},
});
@@ -1,16 +0,0 @@
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,
});
@@ -1,122 +0,0 @@
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<CachedToken> {
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<string> {
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;
}
@@ -1,55 +0,0 @@
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);
}
@@ -1,45 +0,0 @@
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<number>,
logTag: string,
): Promise<CountAcrossReposResult> {
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 };
}
@@ -1,157 +0,0 @@
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<T>(
query: string,
variables: Record<string, unknown>,
): Promise<T> {
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<T>(
query: string,
variables: Record<string, unknown>,
): Promise<T | null> {
try {
return await githubGraphql<T>(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<T> = {
totalCount: number;
pageInfo: { hasNextPage: boolean; endCursor: string | null };
nodes: T[];
};
export type Paged<TItems extends Record<string, unknown[]>> = TItems & {
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
export const EMPTY_PAGE = {
totalCount: 0,
hasMore: false,
endCursor: null,
} as const;
@@ -1,5 +0,0 @@
export type GitHubUser = {
login: string;
id: number;
avatar_url?: string | null;
};
@@ -1,15 +0,0 @@
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 };
};
@@ -1,54 +0,0 @@
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' };
}
@@ -1,282 +0,0 @@
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) => (
<div style={styles.empty}>{content}</div>
);
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<HTMLElement>) => {
e.currentTarget.style.backgroundColor = THEME.bgSecondary;
};
const onLeave = (e: React.MouseEvent<HTMLElement>) => {
e.currentTarget.style.backgroundColor = THEME.bgPrimary;
};
const cells = (
<>
<div style={{ ...styles.cell, ...styles.rankCell }}>{rank}</div>
<div style={{ ...styles.cell, ...styles.contributorCell }}>
{entry.avatarUrl ? (
<img src={entry.avatarUrl} alt="" style={styles.avatar} />
) : (
<div style={styles.avatar} />
)}
<div style={styles.nameWrapper}>
<span style={styles.name}>{label}</span>
{entry.ghLogin && entry.ghLogin !== label && (
<span style={styles.login}>@{entry.ghLogin}</span>
)}
</div>
</div>
<div
style={{ ...styles.cell, ...styles.countCell, ...styles.cellLast }}
title={countLabel}
>
{entry.count}
</div>
</>
);
if (profileHref) {
return (
<a
href={profileHref}
target="_blank"
rel="noopener noreferrer"
style={{ ...styles.row, cursor: 'pointer' }}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
>
{cells}
</a>
);
}
return (
<div style={styles.row} onMouseEnter={onEnter} onMouseLeave={onLeave}>
{cells}
</div>
);
};
export const Leaderboard = ({
contributorColumnLabel,
countColumnLabel,
entries,
loading,
error,
emptyMessage = 'No activity in the last 90 days',
}: LeaderboardProps) => (
<div style={styles.root}>
<div style={styles.headerRow}>
<div style={{ ...styles.headerCell, ...styles.rankCell }}>#</div>
<div style={{ ...styles.headerCell, ...styles.contributorCell }}>
{contributorColumnLabel}
</div>
<div
style={{
...styles.headerCell,
...styles.countCell,
...styles.headerCellLast,
}}
>
{countColumnLabel}
</div>
</div>
{error ? (
renderState(error)
) : loading && entries.length === 0 ? (
renderState('Loading...')
) : entries.length === 0 ? (
renderState(emptyMessage)
) : (
<div style={styles.table}>
{entries.map((entry, i) => (
<Row
key={entry.id}
rank={i + 1}
entry={entry}
countLabel={countColumnLabel}
/>
))}
</div>
)}
</div>
);
@@ -1,23 +0,0 @@
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;
@@ -1,24 +0,0 @@
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,
},
});
@@ -1,22 +0,0 @@
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,
},
});
@@ -1,23 +0,0 @@
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,
},
});
@@ -1,24 +0,0 @@
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,
},
});
@@ -1,23 +0,0 @@
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,
},
});
@@ -1,23 +0,0 @@
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,
},
});
@@ -1,693 +0,0 @@
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<HTMLElement>,
): 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<HTMLElement>) => {
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,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 20 20' fill='gray'><path d='M10 13l-5-5h10z'/></svg>") 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 (
<div style={styles.chartContainer}>
<div
style={{
position: 'absolute',
left: 0,
top: CHART_PADDING.top,
bottom: CHART_PADDING.bottom,
width: CHART_PADDING.left,
}}
>
{yTicks.map((t) => {
const bottomPct = (t / yMax) * 100;
return (
<div
key={t}
style={{
position: 'absolute',
right: 4,
bottom: `${bottomPct}%`,
transform: 'translateY(50%)',
fontSize: 10,
color: THEME.fontTertiary,
lineHeight: 1,
fontVariantNumeric: 'tabular-nums',
}}
>
{t}
</div>
);
})}
</div>
<div
style={{
position: 'absolute',
left: CHART_PADDING.left,
right: CHART_PADDING.right,
top: CHART_PADDING.top,
bottom: CHART_PADDING.bottom,
}}
>
{yTicks.map((t, i) => {
const bottomPct = (t / yMax) * 100;
return (
<div
key={t}
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: `${bottomPct}%`,
borderTop: `1px ${i === 0 ? 'solid' : 'dotted'} ${THEME.borderLight}`,
pointerEvents: 'none',
}}
/>
);
})}
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'flex-end',
gap: 2,
}}
>
{buckets.map((b, i) => {
const v = b[valueKey];
const heightPct = (v / yMax) * 100;
const showLabel = i % labelEvery === 0 || i === n - 1;
return (
<div
key={b.key}
title={`${b.label}: ${v}`}
style={{
flex: '1 1 0',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-end',
position: 'relative',
minWidth: 0,
}}
>
<div
style={{
width: '70%',
maxWidth: 22,
height: `${heightPct}%`,
minHeight: v > 0 ? 2 : 0,
background: color,
borderRadius: '2px 2px 0 0',
}}
/>
{showLabel && (
<div
style={{
position: 'absolute',
top: '100%',
marginTop: 4,
fontSize: 10,
color: THEME.fontTertiary,
whiteSpace: 'nowrap',
lineHeight: 1,
pointerEvents: 'none',
fontVariantNumeric: 'tabular-nums',
}}
>
{b.label}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
};
const ContributorStats = () => {
const recordId = useRecordId();
const [period, setPeriod] = useState<Period>('month');
const [selectedId, setSelectedId] = useState<string | null>(recordId);
const [stats, setStats] = useState<StatsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResponse['contributors']>([]);
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) => (
<div style={styles.header}>
<div style={styles.contributor}>
{contributor?.avatarUrl ? (
<img src={contributor.avatarUrl} alt="" style={styles.avatar} />
) : (
<div style={styles.avatar} />
)}
<div style={styles.nameCol}>
<span style={styles.name}>
{contributor?.name ?? contributor?.ghLogin ?? 'Contributor stats'}
</span>
{contributor?.ghLogin &&
contributor.ghLogin !== contributor.name && (
<span style={styles.login}>@{contributor.ghLogin}</span>
)}
</div>
</div>
<div style={styles.controls}>
{selectedId && !recordId && (
<button
type="button"
onClick={() => setSelectedId(null)}
style={styles.buttonNeutral}
>
Change
</button>
)}
<select
value={period}
onChange={onValueChange((v) => setPeriod(v as Period))}
style={styles.select}
>
{PERIOD_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
);
if (!selectedId) {
return (
<div style={styles.root}>
{renderHeader(null)}
<div style={styles.searchWrapper}>
<input
value={searchQuery}
onInput={onValueChange(setSearchQuery)}
onChange={onValueChange(setSearchQuery)}
placeholder="Search a contributor by name or GitHub login..."
style={styles.searchInput}
/>
<div style={styles.searchList}>
{searchLoading && searchResults.length === 0 && (
<div style={{ ...styles.searchItem, color: THEME.fontTertiary }}>
Searching...
</div>
)}
{!searchLoading && searchResults.length === 0 && (
<div style={{ ...styles.searchItem, color: THEME.fontTertiary }}>
No contributors found.
</div>
)}
{searchResults.map((eng) => (
<div
key={eng.id}
role="button"
tabIndex={0}
onClick={() => setSelectedId(eng.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') setSelectedId(eng.id);
}}
style={styles.searchItem}
>
{eng.avatarUrl ? (
<img src={eng.avatarUrl} alt="" style={styles.smallAvatar} />
) : (
<div style={styles.smallAvatar} />
)}
<div style={styles.nameCol}>
<span style={styles.name}>
{eng.name ?? eng.ghLogin ?? 'Unknown'}
</span>
{eng.ghLogin && eng.ghLogin !== eng.name && (
<span style={styles.login}>@{eng.ghLogin}</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
return (
<div style={styles.root}>
{renderHeader(stats?.contributor ?? null)}
<div style={styles.body}>
{loading && !stats && (
<div style={styles.centered}>Loading contributor stats...</div>
)}
{error && <div style={styles.centered}>{error}</div>}
{stats && (
<>
<div style={styles.chartCard}>
<div style={styles.chartHeader}>
<span style={styles.chartTitle}>PRs authored (merged)</span>
<span style={styles.chartTotal}>{stats.totals.prAuthored}</span>
</div>
<BarChart
buckets={stats.buckets}
valueKey="prAuthored"
color={THEME.chartAuthored}
/>
{stats.truncated.prAuthored && (
<span style={styles.truncatedHint}>
Showing partial data (results truncated).
</span>
)}
</div>
<div style={styles.chartCard}>
<div style={styles.chartHeader}>
<span style={styles.chartTitle}>PRs merged</span>
<span style={styles.chartTotal}>{stats.totals.prMerged}</span>
</div>
<BarChart
buckets={stats.buckets}
valueKey="prMerged"
color={THEME.chartMerged}
/>
{stats.truncated.prMerged && (
<span style={styles.truncatedHint}>
Showing partial data (results truncated).
</span>
)}
</div>
<div style={styles.chartCard}>
<div style={styles.chartHeader}>
<span style={styles.chartTitle}>PRs reviewed</span>
<span style={styles.chartTotal}>{stats.totals.prReviewed}</span>
</div>
<BarChart
buckets={stats.buckets}
valueKey="prReviewed"
color={THEME.chartReviewed}
/>
{stats.truncated.prReviewed && (
<span style={styles.truncatedHint}>
Showing partial data (results truncated).
</span>
)}
</div>
</>
)}
</div>
</div>
);
};
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',
},
});
@@ -1,106 +0,0 @@
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<SyncStatus>('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 <div>Fetching contributors...</div>;
if (status === 'error') return <div>Failed to fetch contributors.</div>;
return <div>Done</div>;
};
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',
},
});
@@ -1,66 +0,0 @@
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<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<Leaderboard
contributorColumnLabel="Contributor"
countColumnLabel="PRs"
entries={entries}
loading={loading}
error={error}
/>
);
};
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,
});
@@ -1,66 +0,0 @@
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<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<Leaderboard
contributorColumnLabel="Contributor"
countColumnLabel="Reviews"
entries={entries}
loading={loading}
error={error}
/>
);
};
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,
});
@@ -1,20 +0,0 @@
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<number> {
const data = await githubGraphql<Response>(QUERY, { owner, name });
return data.repository?.mentionableUsers.totalCount ?? 0;
}
@@ -1,52 +0,0 @@
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<GqlContributor> } | 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<Response>(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,
};
}
@@ -1,21 +0,0 @@
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<ContributorRow[]> {
return chunkedBatchCreate('createContributors', items, {
id: true,
ghLogin: true,
name: true,
githubId: true,
avatarUrl: true,
contributions: true,
}) as Promise<ContributorRow[]>;
}
@@ -1,27 +0,0 @@
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<ContributorRow | null> {
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;
}
@@ -1,52 +0,0 @@
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<ContributorSearchResult[]> {
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,
}));
}
@@ -1,376 +0,0 @@
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<T> = { node: T };
type Connection<T> = {
edges: Edge<T>[];
pageInfo: { hasNextPage: boolean; endCursor: string | null };
};
async function paginateUntil<T>(
fetchPage: (cursor: string | null) => Promise<Connection<T>>,
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<ContributorInfo | null> => {
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<ContributorStatsPayload>,
): Promise<ContributorStatsResponse> => {
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<string, number>();
buckets.forEach((b, i) => bucketIndex.set(b.key, i));
const client = getClient();
const mergedResult = await paginateUntil<MergedPrNode>(
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<MergedPrNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.mergedAt) return false;
return new Date(n.mergedAt).getTime() < rangeStartMs;
},
);
const authoredResult = await paginateUntil<MergedPrNode>(
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<MergedPrNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.mergedAt) return false;
return new Date(n.mergedAt).getTime() < rangeStartMs;
},
);
const reviewedResult = await paginateUntil<ReviewNode>(
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<ReviewNode>) ?? {
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,
},
});
@@ -1,28 +0,0 @@
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<CountContributorsPayload>) =>
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,
},
});
@@ -1,70 +0,0 @@
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<FetchContributorsPayload>) => {
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,
},
});
@@ -1,43 +0,0 @@
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<SearchContributorsPayload>,
): 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,
},
});
@@ -1,257 +0,0 @@
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<T> = { node: T };
type Connection<T> = {
edges: Edge<T>[];
pageInfo: { hasNextPage: boolean; endCursor: string | null };
};
async function paginateUntil<T>(
fetchPage: (cursor: string | null) => Promise<Connection<T>>,
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<string, LeaderboardEntry>();
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<TopContributorsPayload>,
): Promise<TopContributorsResponse> => {
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<PrNode>(
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<PrNode>) ?? {
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<ReviewNode>(
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<ReviewNode>) ?? {
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,
},
});
@@ -1,47 +0,0 @@
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<GhUser | null | undefined>,
): ContributorCanonical[] {
const seen = new Map<string, ContributorCanonical>();
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()];
}
@@ -1,72 +0,0 @@
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,
},
],
});
@@ -1,10 +0,0 @@
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;
};
@@ -1,30 +0,0 @@
const BOT_LOGIN_SUFFIX = '[bot]';
const KNOWN_BOT_LOGINS = new Set<string>([
'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);
}
@@ -1,51 +0,0 @@
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,
},
],
});
@@ -1,25 +0,0 @@
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',
},
});
@@ -1,23 +0,0 @@
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,
},
});
@@ -1,109 +0,0 @@
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<SyncStatus>('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 <div>Fetching issues...</div>;
if (status === 'error') return <div>Failed to fetch issues.</div>;
return <div>Done</div>;
};
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',
},
});
@@ -1,26 +0,0 @@
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<number> {
try {
const data = await githubGraphql<Response>(QUERY, { owner, name });
return data.repository?.issues.totalCount ?? 0;
} catch {
return 0;
}
}
@@ -1,66 +0,0 @@
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<GqlIssue> } | 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<Response>(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 };
}
}
@@ -1,27 +0,0 @@
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<IssueRow[]> {
return chunkedBatchCreate('createIssues', items, {
id: true,
githubNumber: true,
uniqueIdentifier: true,
title: true,
state: true,
repo: true,
}) as Promise<IssueRow[]>;
}
@@ -1,40 +0,0 @@
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<IssueRow | null> {
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;
}
@@ -1,24 +0,0 @@
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<CountIssuesPayload>) =>
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,
},
});
@@ -1,93 +0,0 @@
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<FetchIssuesPayload>) => {
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<string, string>();
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,
},
});
@@ -1,11 +0,0 @@
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,
});
@@ -1,63 +0,0 @@
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,
};
}
@@ -1,134 +0,0 @@
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,
},
],
});
@@ -1,13 +0,0 @@
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;
};

Some files were not shown because too many files have changed in this diff Show More