Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a15dcc0f2 |
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"postgres": {
|
||||
"type": "stdio",
|
||||
"command": "bash",
|
||||
"args": ["-c", "source packages/twenty-server/.env && npx -y @modelcontextprotocol/server-postgres \"$PG_DATABASE_URL\""],
|
||||
"env": {}
|
||||
},
|
||||
"playwright": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest", "--no-sandbox", "--headless"],
|
||||
"env": {}
|
||||
},
|
||||
"context7": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@upstash/context7-mcp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Twenty is an open-source CRM built with modern technologies in a monorepo structure. The codebase is organized as an Nx workspace with multiple packages.
|
||||
|
||||
## Key Commands
|
||||
|
||||
### Development
|
||||
```bash
|
||||
# Start development environment (frontend + backend + worker)
|
||||
yarn start
|
||||
|
||||
# Individual package development
|
||||
npx nx start twenty-front # Start frontend dev server
|
||||
npx nx start twenty-server # Start backend server
|
||||
npx nx run twenty-server:worker # Start background worker
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Preferred: run a single test file (fast)
|
||||
npx jest path/to/test.test.ts --config=packages/PROJECT/jest.config.mjs
|
||||
|
||||
# Run all tests for a package
|
||||
npx nx test twenty-front # Frontend unit tests
|
||||
npx nx test twenty-server # Backend unit tests
|
||||
npx nx run twenty-server:test:integration:with-db-reset # Integration tests with DB reset
|
||||
# To run an indivual test or a pattern of tests, use the following command:
|
||||
cd packages/{workspace} && npx jest "pattern or filename"
|
||||
|
||||
# Storybook
|
||||
npx nx storybook:build twenty-front
|
||||
npx nx storybook:test twenty-front
|
||||
|
||||
# When testing the UI end to end, click on "Continue with Email" and use the prefilled credentials.
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
```bash
|
||||
# Linting (diff with main - fastest, always prefer this)
|
||||
npx nx lint:diff-with-main twenty-front
|
||||
npx nx lint:diff-with-main twenty-server
|
||||
npx nx lint:diff-with-main twenty-front --configuration=fix # Auto-fix
|
||||
|
||||
# Linting (full project - slower, use only when needed)
|
||||
npx nx lint twenty-front
|
||||
npx nx lint twenty-server
|
||||
|
||||
# Type checking
|
||||
npx nx typecheck twenty-front
|
||||
npx nx typecheck twenty-server
|
||||
|
||||
# Format code
|
||||
npx nx fmt twenty-front
|
||||
npx nx fmt twenty-server
|
||||
```
|
||||
|
||||
### Build
|
||||
```bash
|
||||
# Build packages (twenty-shared must be built first)
|
||||
npx nx build twenty-shared
|
||||
npx nx build twenty-front
|
||||
npx nx build twenty-server
|
||||
```
|
||||
|
||||
### Database Operations
|
||||
```bash
|
||||
# Database management
|
||||
npx nx database:reset twenty-server # Reset database
|
||||
npx nx run twenty-server:database:init:prod # Initialize database
|
||||
npx nx run twenty-server:database:migrate:prod # Run instance commands (fast only)
|
||||
|
||||
# Generate an instance command (fast or slow)
|
||||
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
|
||||
```
|
||||
|
||||
### Database Inspection (Postgres MCP)
|
||||
|
||||
A read-only Postgres MCP server is configured in `.mcp.json`. Use it to:
|
||||
- Inspect workspace data, metadata, and object definitions while developing
|
||||
- Verify migration results (columns, types, constraints) after running migrations
|
||||
- Explore the multi-tenant schema structure (core, metadata, workspace-specific schemas)
|
||||
- Debug issues by querying raw data to confirm whether a bug is frontend, backend, or data-level
|
||||
- Inspect metadata tables to debug GraphQL schema generation issues
|
||||
|
||||
This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above.
|
||||
|
||||
### GraphQL
|
||||
```bash
|
||||
# Generate GraphQL types (run after schema changes)
|
||||
npx nx run twenty-front:graphql:generate
|
||||
npx nx run twenty-front:graphql:generate --configuration=metadata
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Tech Stack
|
||||
- **Frontend**: React 18, TypeScript, Jotai (state management), Linaria (styling), Vite
|
||||
- **Backend**: NestJS, TypeORM, PostgreSQL, Redis, GraphQL (with GraphQL Yoga)
|
||||
- **Monorepo**: Nx workspace managed with Yarn 4
|
||||
|
||||
### Package Structure
|
||||
```
|
||||
packages/
|
||||
├── twenty-front/ # React frontend application
|
||||
├── twenty-server/ # NestJS backend API
|
||||
├── twenty-ui/ # Shared UI components library
|
||||
├── twenty-shared/ # Common types and utilities
|
||||
├── twenty-emails/ # Email templates with React Email
|
||||
├── twenty-website/ # Next.js documentation website
|
||||
├── twenty-zapier/ # Zapier integration
|
||||
└── twenty-e2e-testing/ # Playwright E2E tests
|
||||
```
|
||||
|
||||
### Key Development Principles
|
||||
- **Functional components only** (no class components)
|
||||
- **Named exports only** (no default exports)
|
||||
- **Types over interfaces** (except when extending third-party interfaces)
|
||||
- **String literals over enums** (except for GraphQL enums)
|
||||
- **No 'any' type allowed** — strict TypeScript enforced
|
||||
- **Event handlers preferred over useEffect** for state updates
|
||||
- **Props down, events up** — unidirectional data flow
|
||||
- **Composition over inheritance**
|
||||
- **No abbreviations** in variable names (`user` not `u`, `fieldMetadata` not `fm`)
|
||||
|
||||
### Naming Conventions
|
||||
- **Variables/functions**: camelCase
|
||||
- **Constants**: SCREAMING_SNAKE_CASE
|
||||
- **Types/Classes**: PascalCase (suffix component props with `Props`, e.g. `ButtonProps`)
|
||||
- **Files/directories**: kebab-case with descriptive suffixes (`.component.tsx`, `.service.ts`, `.entity.ts`, `.dto.ts`, `.module.ts`)
|
||||
- **TypeScript generics**: descriptive names (`TData` not `T`)
|
||||
|
||||
### File Structure
|
||||
- Components under 300 lines, services under 500 lines
|
||||
- Components in their own directories with tests and stories
|
||||
- Use `index.ts` barrel exports for clean imports
|
||||
- Import order: external libraries first, then internal (`@/`), then relative
|
||||
|
||||
### Comments
|
||||
- Use short-form comments (`//`), not JSDoc blocks
|
||||
- Explain WHY (business logic), not WHAT
|
||||
- Do not comment obvious code
|
||||
- Multi-line comments use multiple `//` lines, not `/** */`
|
||||
|
||||
### State Management
|
||||
- **Jotai** for global state: atoms for primitive state, selectors for derived state, atom families for dynamic collections
|
||||
- Component-specific state with React hooks (`useState`, `useReducer` for complex logic)
|
||||
- GraphQL cache managed by Apollo Client
|
||||
- Use functional state updates: `setState(prev => prev + 1)`
|
||||
|
||||
### Backend Architecture
|
||||
- **NestJS modules** for feature organization
|
||||
- **TypeORM** for database ORM with PostgreSQL
|
||||
- **GraphQL** API with code-first approach
|
||||
- **Redis** for caching and session management
|
||||
- **BullMQ** for background job processing
|
||||
|
||||
### Database & Upgrade Commands
|
||||
- **PostgreSQL** as primary database
|
||||
- **Redis** for caching and sessions
|
||||
- **ClickHouse** for analytics (when enabled)
|
||||
- When changing entity files, generate an **instance command** (`database:migrate:generate --name <name> --type <fast|slow>`)
|
||||
- **Fast** instance commands handle schema changes; **slow** ones add a `runDataMigration` step for data backfills
|
||||
- **Workspace commands** iterate over all active/suspended workspaces for per-workspace upgrades
|
||||
- Commands use `@RegisteredInstanceCommand` and `@RegisteredWorkspaceCommand` decorators for automatic discovery
|
||||
- Include both `up` and `down` logic in instance commands
|
||||
- Never delete or rewrite committed instance command `up`/`down` logic
|
||||
- See `packages/twenty-server/docs/UPGRADE_COMMANDS.md` for full documentation
|
||||
|
||||
### Utility Helpers
|
||||
Use existing helpers from `twenty-shared` instead of manual type guards:
|
||||
- `isDefined()`, `isNonEmptyString()`, `isNonEmptyArray()`
|
||||
|
||||
## Development Workflow
|
||||
|
||||
IMPORTANT: Use Context7 for code generation, setup or configuration steps, or library/API documentation. Automatically use the Context7 MCP tools to resolve library IDs and get library docs without waiting for explicit requests.
|
||||
|
||||
### Before Making Changes
|
||||
1. Always run linting (`lint:diff-with-main`) and type checking after code changes
|
||||
2. Test changes with relevant test suites (prefer single-file test runs)
|
||||
3. Ensure instance commands are generated for entity changes (`database:migrate:generate`)
|
||||
4. Check that GraphQL schema changes are backward compatible
|
||||
5. Run `graphql:generate` after any GraphQL schema changes
|
||||
|
||||
### Code Style Notes
|
||||
- Use **Linaria** for styling with zero-runtime CSS-in-JS (styled-components pattern)
|
||||
- Follow **Nx** workspace conventions for imports
|
||||
- Use **Lingui** for internationalization
|
||||
- Apply security first, then formatting (sanitize before format)
|
||||
|
||||
### Testing Strategy
|
||||
- **Test behavior, not implementation** — focus on user perspective
|
||||
- **Test pyramid**: 70% unit, 20% integration, 10% E2E
|
||||
- Query by user-visible elements (text, roles, labels) over test IDs
|
||||
- Use `@testing-library/user-event` for realistic interactions
|
||||
- Descriptive test names: "should [behavior] when [condition]"
|
||||
- Clear mocks between tests with `jest.clearAllMocks()`
|
||||
|
||||
## Dev Environment Setup
|
||||
|
||||
All dev environments (Claude Code web, Cursor, local) use one script:
|
||||
|
||||
```bash
|
||||
bash packages/twenty-utils/setup-dev-env.sh
|
||||
```
|
||||
|
||||
This handles everything: starts Postgres + Redis (auto-detects local services vs Docker), creates databases, and copies `.env` files. Idempotent — safe to run multiple times.
|
||||
|
||||
- `--docker` — force Docker mode (uses `packages/twenty-docker/docker-compose.dev.yml`)
|
||||
- `--down` — stop services
|
||||
- `--reset` — wipe data and restart fresh
|
||||
- **Skip the setup script** for tasks that only read code — architecture questions, code review, documentation, etc.
|
||||
|
||||
**Note:** CI workflows (GitHub Actions) manage services via Actions service containers and run setup steps individually — they don't use this script.
|
||||
|
||||
## Important Files
|
||||
- `nx.json` - Nx workspace configuration with task definitions
|
||||
- `tsconfig.base.json` - Base TypeScript configuration
|
||||
- `package.json` - Root package with workspace definitions
|
||||
- `.cursor/rules/` - Detailed development guidelines and best practices
|
||||
@@ -12,7 +12,7 @@ This directory contains Twenty's development guidelines and best practices in th
|
||||
### Core Guidelines
|
||||
- **architecture.mdc** - Project overview, technology stack, and infrastructure setup (Always Applied)
|
||||
- **nx-rules.mdc** - Nx workspace guidelines and best practices (Auto-attached to Nx files)
|
||||
- **server-migrations.mdc** - Upgrade command guidelines (instance commands and workspace commands) for `twenty-server` (Auto-attached to server entities and upgrade command files)
|
||||
- **server-migrations.mdc** - Backend migration and TypeORM guidelines for `twenty-server` (Auto-attached to server entities and migration files)
|
||||
- **creating-syncable-entity.mdc** - Comprehensive guide for creating new syncable entities (with universalIdentifier and applicationId) in the workspace migration system (Agent-requested for metadata-modules and workspace-migration files)
|
||||
|
||||
### Code Quality
|
||||
@@ -81,8 +81,11 @@ npx nx run twenty-server:typecheck # Type checking
|
||||
npx nx run twenty-server:test # Run unit tests
|
||||
npx nx run twenty-server:test:integration:with-db-reset # Run integration tests
|
||||
|
||||
# Upgrade commands (instance + workspace)
|
||||
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
|
||||
# Migrations
|
||||
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/[name] -d src/database/typeorm/core/core.datasource.ts
|
||||
|
||||
# Workspace
|
||||
npx nx run twenty-server:command workspace:sync-metadata -f # Sync metadata
|
||||
```
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
@@ -55,8 +55,7 @@ If feature descriptions are not provided or need enhancement, research the codeb
|
||||
- Services: Look for `*.service.ts` files
|
||||
|
||||
**For Database/ORM Changes:**
|
||||
- Instance commands (fast/slow): `packages/twenty-server/src/database/commands/upgrade-version-command/`
|
||||
- Legacy TypeORM migrations: `packages/twenty-server/src/database/typeorm/`
|
||||
- Migrations: `packages/twenty-server/src/database/typeorm/`
|
||||
- Entities: `packages/twenty-server/src/entities/`
|
||||
|
||||
### Research Commands
|
||||
|
||||
@@ -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,46 +1,29 @@
|
||||
---
|
||||
description: Guidelines for generating and managing upgrade commands (instance commands and workspace commands) in twenty-server
|
||||
description: Guidelines for generating and managing TypeORM migrations in twenty-server
|
||||
globs: [
|
||||
"packages/twenty-server/src/**/*.entity.ts",
|
||||
"packages/twenty-server/src/database/commands/upgrade-version-command/**/*.ts"
|
||||
"packages/twenty-server/src/database/typeorm/**/*.ts"
|
||||
]
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
## Upgrade Commands (twenty-server)
|
||||
## Server Migrations (twenty-server)
|
||||
|
||||
The upgrade system uses two types of commands instead of raw TypeORM migrations:
|
||||
- **Instance commands** — schema and data migrations that run once at the instance level.
|
||||
- **Workspace commands** — commands that iterate over all active/suspended workspaces.
|
||||
|
||||
See `packages/twenty-server/docs/UPGRADE_COMMANDS.md` for full documentation.
|
||||
|
||||
### Instance Commands
|
||||
|
||||
- **When changing a `*.entity.ts` file**, generate an instance command:
|
||||
- **When changing an entity, always generate a migration**
|
||||
- If you modify a `*.entity.ts` file in `packages/twenty-server/src`, you **must** generate a corresponding TypeORM migration instead of manually editing the database schema.
|
||||
- Use the Nx + TypeORM command from the project root:
|
||||
|
||||
```bash
|
||||
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
|
||||
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/common/[name] -d src/database/typeorm/core/core.datasource.ts
|
||||
```
|
||||
|
||||
- **Fast commands** (`--type fast`, default) are for schema-only changes that must run immediately. They implement `FastInstanceCommand` with `up`/`down` methods and use the `@RegisteredInstanceCommand` decorator.
|
||||
- Replace `[name]` with a descriptive, kebab-case migration name that reflects the change (for example, `add-agent-turn-evaluation`).
|
||||
|
||||
- **Slow commands** (`--type slow`) add a `runDataMigration` method for potentially long-running data backfills that execute before `up`. They only run when `--include-slow` is passed. Use the decorator with `{ type: 'slow' }`.
|
||||
- **Prefer generated migrations over manual edits**
|
||||
- Let TypeORM infer schema changes from the updated entities; only adjust the generated migration file manually if absolutely necessary (for example, for data backfills or complex constraints).
|
||||
- Keep schema changes (DDL) in these generated migrations and avoid mixing in heavy data migrations unless there is a strong reason and clear comments.
|
||||
|
||||
- The generator auto-registers the command in `instance-commands.constant.ts` — do not edit that file manually.
|
||||
|
||||
- **Keep commands consistent and reversible**: include both `up` and `down` logic. Do not delete or rewrite existing, committed commands unless on a pre-release branch.
|
||||
|
||||
### Workspace Commands
|
||||
|
||||
- Use the `@RegisteredWorkspaceCommand` decorator alongside nest-commander's `@Command` decorator.
|
||||
- Extend `ActiveOrSuspendedWorkspaceCommandRunner` and implement `runOnWorkspace`.
|
||||
- The base class provides `--dry-run`, `--verbose`, and workspace filter options automatically.
|
||||
|
||||
### Execution Order
|
||||
|
||||
Within a given version, commands run in this order (timestamp-sorted within each group):
|
||||
1. Instance fast commands
|
||||
2. Instance slow commands (only with `--include-slow`)
|
||||
3. Workspace commands
|
||||
- **Keep migrations consistent and reversible**
|
||||
- Ensure the generated migration includes both `up` and `down` logic that correctly applies and reverts the entity change when possible.
|
||||
- Do not delete or rewrite existing, committed migrations unless you are explicitly working on a pre-release branch where history rewrites are allowed by team conventions.
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Deploy Twenty App
|
||||
description: Build and deploy a Twenty app to a remote instance
|
||||
|
||||
inputs:
|
||||
api-url:
|
||||
description: Base URL of the target Twenty instance (e.g. https://my.twenty.instance)
|
||||
required: true
|
||||
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
|
||||
steps:
|
||||
- name: Enable Corepack
|
||||
shell: bash
|
||||
run: corepack enable
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '${{ inputs.app-path }}/.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
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ~/.twenty
|
||||
node -e "
|
||||
const fs = require('fs'), path = require('path'), os = require('os');
|
||||
fs.writeFileSync(path.join(os.homedir(), '.twenty', 'config.json'), JSON.stringify({
|
||||
version: 1,
|
||||
remotes: { target: { apiUrl: process.env.API_URL, apiKey: process.env.API_KEY } }
|
||||
}, null, 2));
|
||||
"
|
||||
env:
|
||||
API_URL: ${{ inputs.api-url }}
|
||||
API_KEY: ${{ inputs.api-key }}
|
||||
|
||||
- name: Deploy
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.app-path }}
|
||||
run: yarn twenty deploy --remote target
|
||||
@@ -1,53 +0,0 @@
|
||||
name: Install Twenty App
|
||||
description: Install (or upgrade) a Twenty app on a specific workspace
|
||||
|
||||
inputs:
|
||||
api-url:
|
||||
description: Base URL of the target Twenty instance (e.g. https://my.twenty.instance)
|
||||
required: true
|
||||
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
|
||||
steps:
|
||||
- name: Enable Corepack
|
||||
shell: bash
|
||||
run: corepack enable
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '${{ inputs.app-path }}/.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
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ~/.twenty
|
||||
node -e "
|
||||
const fs = require('fs'), path = require('path'), os = require('os');
|
||||
fs.writeFileSync(path.join(os.homedir(), '.twenty', 'config.json'), JSON.stringify({
|
||||
version: 1,
|
||||
remotes: { target: { apiUrl: process.env.API_URL, apiKey: process.env.API_KEY } }
|
||||
}, null, 2));
|
||||
"
|
||||
env:
|
||||
API_URL: ${{ inputs.api-url }}
|
||||
API_KEY: ${{ inputs.api-key }}
|
||||
|
||||
- name: Install
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.app-path }}
|
||||
run: yarn twenty install --remote target
|
||||
@@ -1,47 +0,0 @@
|
||||
name: Spawn Twenty App Dev Test
|
||||
description: >
|
||||
Starts a Twenty all-in-one test instance (server, worker, database, redis)
|
||||
using the twentycrm/twenty-app-dev Docker image on port 2021.
|
||||
The server is available at http://localhost:2021 with seeded demo data.
|
||||
|
||||
inputs:
|
||||
twenty-version:
|
||||
description: 'Twenty Docker Hub image tag for twenty-app-dev (e.g., "latest" or "v1.20.0").'
|
||||
required: false
|
||||
default: 'latest'
|
||||
|
||||
outputs:
|
||||
server-url:
|
||||
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
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Start twenty-app-dev-test container
|
||||
shell: bash
|
||||
run: |
|
||||
docker run -d \
|
||||
--name twenty-app-dev-test \
|
||||
-p 2021:2021 \
|
||||
-e NODE_PORT=2021 \
|
||||
-e SERVER_URL=http://localhost:2021 \
|
||||
twentycrm/twenty-app-dev:${{ inputs.twenty-version }}
|
||||
|
||||
echo "Waiting for Twenty test instance to become healthy…"
|
||||
TIMEOUT=180
|
||||
ELAPSED=0
|
||||
until curl -sf http://localhost:2021/healthz > /dev/null 2>&1; do
|
||||
if [ "$ELAPSED" -ge "$TIMEOUT" ]; then
|
||||
echo "::error::Twenty did not become healthy within ${TIMEOUT}s"
|
||||
docker logs twenty-app-dev-test 2>&1 | tail -80
|
||||
exit 1
|
||||
fi
|
||||
sleep 3
|
||||
ELAPSED=$((ELAPSED + 3))
|
||||
echo " … waited ${ELAPSED}s"
|
||||
done
|
||||
echo "Twenty test instance is ready at http://localhost:2021 (took ~${ELAPSED}s)"
|
||||
@@ -251,14 +251,6 @@ jobs:
|
||||
rm -f /tmp/current-server.pid
|
||||
fi
|
||||
|
||||
- name: Flush Redis between server runs
|
||||
run: |
|
||||
# Clear all Redis caches to prevent stale data from the current branch
|
||||
# server contaminating the main branch server. Both servers share the
|
||||
# same Redis instance, and CoreEntityCacheService/WorkspaceCacheService
|
||||
# persist cached entities across process restarts.
|
||||
redis-cli -h localhost -p 6379 FLUSHALL || echo "::warning::Failed to flush Redis"
|
||||
|
||||
- name: Checkout main branch
|
||||
run: |
|
||||
git stash
|
||||
|
||||
@@ -25,10 +25,12 @@ jobs:
|
||||
packages/twenty-sdk/**
|
||||
packages/twenty-client-sdk/**
|
||||
packages/twenty-shared/**
|
||||
packages/twenty-server/**
|
||||
!packages/create-twenty-app/package.json
|
||||
!packages/twenty-sdk/package.json
|
||||
!packages/twenty-client-sdk/package.json
|
||||
!packages/twenty-shared/package.json
|
||||
!packages/twenty-server/package.json
|
||||
create-app-e2e-hello-world:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
@@ -53,8 +55,6 @@ jobs:
|
||||
- 6379:6379
|
||||
env:
|
||||
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
@@ -142,24 +142,27 @@ jobs:
|
||||
- name: Setup server environment
|
||||
run: npx nx reset:env:e2e-testing-server twenty-server
|
||||
|
||||
- name: Create databases
|
||||
- name: Build server
|
||||
run: npx nx build twenty-server
|
||||
|
||||
- name: Create and setup database
|
||||
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
|
||||
npx nx run twenty-server:database:reset
|
||||
|
||||
- name: Start server
|
||||
run: nohup npx nx start:ci twenty-server &
|
||||
|
||||
- name: Wait for server to be ready
|
||||
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
|
||||
run: |
|
||||
npx nx start twenty-server &
|
||||
echo "Waiting for server to be ready..."
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
|
||||
|
||||
- name: Authenticate with twenty-server
|
||||
env:
|
||||
SEED_API_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik'
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
|
||||
npx --no-install twenty remote add --api-key $SEED_API_KEY --api-url http://localhost:3000
|
||||
|
||||
- name: Deploy scaffolded app
|
||||
run: |
|
||||
@@ -186,6 +189,8 @@ jobs:
|
||||
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
|
||||
|
||||
- name: Run scaffolded app integration test
|
||||
env:
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
yarn test
|
||||
|
||||
@@ -23,10 +23,12 @@ jobs:
|
||||
packages/twenty-sdk/**
|
||||
packages/twenty-client-sdk/**
|
||||
packages/twenty-shared/**
|
||||
packages/twenty-server/**
|
||||
!packages/create-twenty-app/package.json
|
||||
!packages/twenty-sdk/package.json
|
||||
!packages/twenty-client-sdk/package.json
|
||||
!packages/twenty-shared/package.json
|
||||
!packages/twenty-server/package.json
|
||||
create-app-e2e-minimal:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
@@ -51,8 +53,6 @@ jobs:
|
||||
- 6379:6379
|
||||
env:
|
||||
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
@@ -136,26 +136,41 @@ jobs:
|
||||
- name: Setup server environment
|
||||
run: npx nx reset:env:e2e-testing-server twenty-server
|
||||
|
||||
- name: Create databases
|
||||
- name: Build server
|
||||
run: npx nx build twenty-server
|
||||
|
||||
- name: Create and setup database
|
||||
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
|
||||
npx nx run twenty-server:database:reset
|
||||
|
||||
- name: Start server
|
||||
run: nohup npx nx start:ci twenty-server &
|
||||
|
||||
- name: Wait for server to be ready
|
||||
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
|
||||
run: |
|
||||
npx nx start twenty-server &
|
||||
echo "Waiting for server to be ready..."
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
|
||||
|
||||
- name: Authenticate with twenty-server
|
||||
env:
|
||||
SEED_API_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik'
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
|
||||
npx --no-install twenty remote add --api-key $SEED_API_KEY --api-url http://localhost:3000
|
||||
|
||||
- name: Run scaffolded app integration test (deploys, installs, and verifies the app)
|
||||
- name: Deploy scaffolded app
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty deploy
|
||||
|
||||
- name: Install scaffolded app
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty install
|
||||
|
||||
- name: Run scaffolded app integration test
|
||||
env:
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
yarn test
|
||||
|
||||
@@ -25,10 +25,12 @@ jobs:
|
||||
packages/twenty-sdk/**
|
||||
packages/twenty-client-sdk/**
|
||||
packages/twenty-shared/**
|
||||
packages/twenty-server/**
|
||||
!packages/create-twenty-app/package.json
|
||||
!packages/twenty-sdk/package.json
|
||||
!packages/twenty-client-sdk/package.json
|
||||
!packages/twenty-shared/package.json
|
||||
!packages/twenty-server/package.json
|
||||
create-app-e2e-postcard:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
@@ -53,8 +55,6 @@ jobs:
|
||||
- 6379:6379
|
||||
env:
|
||||
PUBLISHABLE_PACKAGES: twenty-client-sdk twenty-sdk create-twenty-app
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
@@ -140,24 +140,27 @@ jobs:
|
||||
- name: Setup server environment
|
||||
run: npx nx reset:env:e2e-testing-server twenty-server
|
||||
|
||||
- name: Create databases
|
||||
- name: Build server
|
||||
run: npx nx build twenty-server
|
||||
|
||||
- name: Create and setup database
|
||||
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
|
||||
npx nx run twenty-server:database:reset
|
||||
|
||||
- name: Start server
|
||||
run: nohup npx nx start:ci twenty-server &
|
||||
|
||||
- name: Wait for server to be ready
|
||||
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
|
||||
run: |
|
||||
npx nx start twenty-server &
|
||||
echo "Waiting for server to be ready..."
|
||||
timeout 60 bash -c 'until curl -s http://localhost:3000/health; do sleep 2; done'
|
||||
|
||||
- name: Authenticate with twenty-server
|
||||
env:
|
||||
SEED_API_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik'
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
npx --no-install twenty remote add --api-key ${{ env.TWENTY_API_KEY }} --api-url ${{ env.TWENTY_API_URL }}
|
||||
npx --no-install twenty remote add --api-key $SEED_API_KEY --api-url http://localhost:3000
|
||||
|
||||
- name: Deploy scaffolded app
|
||||
run: |
|
||||
@@ -184,6 +187,8 @@ jobs:
|
||||
echo "$EXEC_OUTPUT" | grep -q 'Created company.*Hello World.*with id'
|
||||
|
||||
- name: Run scaffolded app integration test
|
||||
env:
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
run: |
|
||||
cd /tmp/e2e-test-workspace/test-app
|
||||
yarn test
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
name: CI Example App Hello World
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
packages/twenty-apps/examples/hello-world/**
|
||||
packages/twenty-sdk/**
|
||||
packages/twenty-client-sdk/**
|
||||
packages/twenty-shared/**
|
||||
!packages/twenty-sdk/package.json
|
||||
!packages/twenty-client-sdk/package.json
|
||||
!packages/twenty-shared/package.json
|
||||
|
||||
example-app-hello-world:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build SDK packages
|
||||
run: npx nx build twenty-sdk
|
||||
|
||||
- name: Setup server environment
|
||||
run: npx nx reset:env:e2e-testing-server twenty-server
|
||||
|
||||
- name: Create databases
|
||||
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 &
|
||||
|
||||
- name: Wait for server to be ready
|
||||
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
|
||||
|
||||
- name: Run integration tests
|
||||
working-directory: packages/twenty-apps/examples/hello-world
|
||||
run: npx vitest run
|
||||
|
||||
ci-example-app-hello-world-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, example-app-hello-world]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -1,94 +0,0 @@
|
||||
name: CI Example App Postcard
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
changed-files-check:
|
||||
uses: ./.github/workflows/changed-files.yaml
|
||||
with:
|
||||
files: |
|
||||
packages/twenty-apps/examples/postcard/**
|
||||
packages/twenty-sdk/**
|
||||
packages/twenty-client-sdk/**
|
||||
packages/twenty-shared/**
|
||||
!packages/twenty-sdk/package.json
|
||||
!packages/twenty-client-sdk/package.json
|
||||
!packages/twenty-shared/package.json
|
||||
|
||||
example-app-postcard:
|
||||
needs: changed-files-check
|
||||
if: needs.changed-files-check.outputs.any_changed == 'true'
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build SDK packages
|
||||
run: npx nx build twenty-sdk
|
||||
|
||||
- name: Setup server environment
|
||||
run: npx nx reset:env:e2e-testing-server twenty-server
|
||||
|
||||
- name: Create databases
|
||||
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 &
|
||||
|
||||
- name: Wait for server to be ready
|
||||
run: npx wait-on http://localhost:3000/healthz --timeout 120000 --interval 1000
|
||||
|
||||
- name: Run integration tests
|
||||
working-directory: packages/twenty-apps/examples/postcard
|
||||
run: npx vitest run
|
||||
|
||||
ci-example-app-postcard-status-check:
|
||||
if: always() && !cancelled()
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changed-files-check, example-app-postcard]
|
||||
steps:
|
||||
- name: Fail job if any needs failed
|
||||
if: contains(needs.*.result, 'failure')
|
||||
run: exit 1
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=6144'
|
||||
NODE_OPTIONS: '--max-old-space-size=4096'
|
||||
TASK_CACHE_KEY: front-task-${{ matrix.task }}
|
||||
strategy:
|
||||
matrix:
|
||||
|
||||
@@ -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,8 +69,7 @@ jobs:
|
||||
ports:
|
||||
- 6379:6379
|
||||
env:
|
||||
TWENTY_API_URL: http://localhost:3000
|
||||
TWENTY_API_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik
|
||||
NODE_ENV: test
|
||||
steps:
|
||||
- name: Fetch custom Github Actions and base branch history
|
||||
uses: actions/checkout@v4
|
||||
@@ -79,26 +77,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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/
|
||||
|
||||
@@ -71,10 +71,13 @@ npx nx build twenty-server
|
||||
# Database management
|
||||
npx nx database:reset twenty-server # Reset database
|
||||
npx nx run twenty-server:database:init:prod # Initialize database
|
||||
npx nx run twenty-server:database:migrate:prod # Run instance commands (fast only)
|
||||
npx nx run twenty-server:database:migrate:prod # Run migrations
|
||||
|
||||
# Generate an instance command (fast or slow)
|
||||
npx nx run twenty-server:database:migrate:generate --name <name> --type <fast|slow>
|
||||
# Generate migration (replace [name] with kebab-case descriptive name)
|
||||
npx nx run twenty-server:typeorm migration:generate src/database/typeorm/core/migrations/common/[name] -d src/database/typeorm/core/core.datasource.ts
|
||||
|
||||
# Sync metadata
|
||||
npx nx run twenty-server:command workspace:sync-metadata
|
||||
```
|
||||
|
||||
### Database Inspection (Postgres MCP)
|
||||
@@ -84,7 +87,7 @@ A read-only Postgres MCP server is configured in `.mcp.json`. Use it to:
|
||||
- Verify migration results (columns, types, constraints) after running migrations
|
||||
- Explore the multi-tenant schema structure (core, metadata, workspace-specific schemas)
|
||||
- Debug issues by querying raw data to confirm whether a bug is frontend, backend, or data-level
|
||||
- Inspect metadata tables to debug GraphQL schema generation issues
|
||||
- Inspect metadata tables to debug GraphQL schema generation or `workspace:sync-metadata` issues
|
||||
|
||||
This server is read-only — for write operations (reset, migrations, sync), use the CLI commands above.
|
||||
|
||||
@@ -158,17 +161,14 @@ packages/
|
||||
- **Redis** for caching and session management
|
||||
- **BullMQ** for background job processing
|
||||
|
||||
### Database & Upgrade Commands
|
||||
### Database & Migrations
|
||||
- **PostgreSQL** as primary database
|
||||
- **Redis** for caching and sessions
|
||||
- **ClickHouse** for analytics (when enabled)
|
||||
- When changing entity files, generate an **instance command** (`database:migrate:generate --name <name> --type <fast|slow>`)
|
||||
- **Fast** instance commands handle schema changes; **slow** ones add a `runDataMigration` step for data backfills
|
||||
- **Workspace commands** iterate over all active/suspended workspaces for per-workspace upgrades
|
||||
- Commands use `@RegisteredInstanceCommand` and `@RegisteredWorkspaceCommand` decorators for automatic discovery
|
||||
- Include both `up` and `down` logic in instance commands
|
||||
- Never delete or rewrite committed instance command `up`/`down` logic
|
||||
- See `packages/twenty-server/docs/UPGRADE_COMMANDS.md` for full documentation
|
||||
- Always generate migrations when changing entity files
|
||||
- Migration names must be kebab-case (e.g. `add-agent-turn-evaluation`)
|
||||
- Include both `up` and `down` logic in migrations
|
||||
- Never delete or rewrite committed migrations
|
||||
|
||||
### Utility Helpers
|
||||
Use existing helpers from `twenty-shared` instead of manual type guards:
|
||||
@@ -181,7 +181,7 @@ IMPORTANT: Use Context7 for code generation, setup or configuration steps, or li
|
||||
### Before Making Changes
|
||||
1. Always run linting (`lint:diff-with-main`) and type checking after code changes
|
||||
2. Test changes with relevant test suites (prefer single-file test runs)
|
||||
3. Ensure instance commands are generated for entity changes (`database:migrate:generate`)
|
||||
3. Ensure database migrations are generated for entity changes
|
||||
4. Check that GraphQL schema changes are backward compatible
|
||||
5. Run `graphql:generate` after any GraphQL schema changes
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.png" height="28" alt="Greptile" /></a>
|
||||
|
||||
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.png" height="28" alt="Sentry" /></a>
|
||||
|
||||
<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,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "2.0.0",
|
||||
"version": "0.8.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
+5
-11
@@ -6,16 +6,9 @@ on:
|
||||
- main
|
||||
pull_request: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
TWENTY_VERSION: latest
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -23,11 +16,12 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Spawn Twenty test instance
|
||||
- name: Spawn Twenty instance
|
||||
id: twenty
|
||||
uses: twentyhq/twenty/.github/actions/spawn-twenty-app-dev-test@main
|
||||
uses: twentyhq/twenty/.github/actions/spawn-twenty-docker-image@main
|
||||
with:
|
||||
twenty-version: ${{ env.TWENTY_VERSION }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
@@ -36,7 +30,7 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: yarn
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
@@ -45,4 +39,4 @@ jobs:
|
||||
run: yarn test
|
||||
env:
|
||||
TWENTY_API_URL: ${{ steps.twenty.outputs.server-url }}
|
||||
TWENTY_API_KEY: ${{ steps.twenty.outputs.api-key }}
|
||||
TWENTY_API_KEY: ${{ steps.twenty.outputs.access-token }}
|
||||
@@ -1,42 +0,0 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
TWENTY_DEPLOY_URL: http://localhost:3000
|
||||
|
||||
concurrency:
|
||||
group: cd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy-and-install:
|
||||
if: >-
|
||||
github.event_name == 'push' ||
|
||||
(github.event_name == 'pull_request' && github.event.label.name == 'deploy')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Deploy
|
||||
uses: twentyhq/twenty/.github/actions/deploy-twenty-app@main
|
||||
with:
|
||||
api-url: ${{ env.TWENTY_DEPLOY_URL }}
|
||||
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
|
||||
|
||||
- name: Install
|
||||
uses: twentyhq/twenty/.github/actions/install-twenty-app@main
|
||||
with:
|
||||
api-url: ${{ env.TWENTY_DEPLOY_URL }}
|
||||
api-key: ${{ secrets.TWENTY_DEPLOY_API_KEY }}
|
||||
@@ -7,7 +7,9 @@
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"keywords": [],
|
||||
"keywords": [
|
||||
"twenty-app"
|
||||
],
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"scripts": {
|
||||
"twenty": "twenty",
|
||||
|
||||
+70
@@ -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'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
-46
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { beforeAll } from 'vitest';
|
||||
|
||||
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
|
||||
const TEST_CONFIG_DIR = path.join(os.tmpdir(), '.twenty-sdk-test');
|
||||
|
||||
const assertServerIsReachable = async () => {
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(`${TWENTY_API_URL}/healthz`);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Twenty server is not reachable at ${TWENTY_API_URL}. ` +
|
||||
'Make sure the server is running before executing integration tests.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server at ${TWENTY_API_URL} returned ${response.status}`);
|
||||
}
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
await assertServerIsReachable();
|
||||
|
||||
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
|
||||
|
||||
const configFile = {
|
||||
remotes: {
|
||||
local: {
|
||||
apiUrl: process.env.TWENTY_API_URL,
|
||||
apiKey: process.env.TWENTY_API_KEY,
|
||||
},
|
||||
},
|
||||
defaultRemote: 'local',
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(TEST_CONFIG_DIR, 'config.json'),
|
||||
JSON.stringify(configFile, null, 2),
|
||||
);
|
||||
});
|
||||
@@ -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,11 @@ 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_KEY:
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ1c2VySWQiOiIyMDIwMjAyMC1lNmI1LTQ2ODAtOGEzMi1iODIwOTczNzE1NmIiLCJ3b3Jrc3BhY2VJZCI6IjIwMjAyMDIwLTFjMjUtNGQwMi1iZjI1LTZhZWNjZjdlYTQxOSIsIndvcmtzcGFjZU1lbWJlcklkIjoiMjAyMDIwMjAtNDYzZi00MzViLTgyOGMtMTA3ZTAwN2EyNzExIiwidXNlcldvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWU3Yy00M2Q5LWE1ZGItNjg1YjUwNjlkODE2IiwidHlwZSI6IkFDQ0VTUyIsImF1dGhQcm92aWRlciI6InBhc3N3b3JkIiwiaWF0IjoxNzUxMjgxNzA0LCJleHAiOjIwNjY4NTc3MDR9.HMGqCsVlOAPVUBhKSGlD1X86VoHKt4LIUtET3CGIdik',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -21,9 +21,7 @@ export const copyBaseApplicationProject = async ({
|
||||
console.log(chalk.gray('Generating application project...'));
|
||||
await fs.copy(join(__dirname, './constants/template'), appDirectory);
|
||||
|
||||
await renameDotfiles({ appDirectory });
|
||||
|
||||
await addEmptyPublicDirectory({ appDirectory });
|
||||
await renameGitignore({ appDirectory });
|
||||
|
||||
await generateUniversalIdentifiers({
|
||||
appDisplayName,
|
||||
@@ -34,31 +32,14 @@ export const copyBaseApplicationProject = async ({
|
||||
await updatePackageJson({ appName, appDirectory });
|
||||
};
|
||||
|
||||
// npm strips dotfiles/dotdirs (.gitignore, .github/) from published packages,
|
||||
// so we store them without the leading dot and rename after copying.
|
||||
const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
|
||||
const renames = [
|
||||
{ from: 'gitignore', to: '.gitignore' },
|
||||
{ from: 'github', to: '.github' },
|
||||
];
|
||||
const renameGitignore = async ({ appDirectory }: { appDirectory: string }) => {
|
||||
const gitignorePath = join(appDirectory, 'gitignore');
|
||||
|
||||
for (const { from, to } of renames) {
|
||||
const sourcePath = join(appDirectory, from);
|
||||
|
||||
if (await fs.pathExists(sourcePath)) {
|
||||
await fs.rename(sourcePath, join(appDirectory, to));
|
||||
}
|
||||
if (await fs.pathExists(gitignorePath)) {
|
||||
await fs.rename(gitignorePath, join(appDirectory, '.gitignore'));
|
||||
}
|
||||
};
|
||||
|
||||
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',
|
||||
|
||||
+5
-1
@@ -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',
|
||||
|
||||
+5
-1
@@ -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',
|
||||
|
||||
+5
-1
@@ -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',
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -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';
|
||||
|
||||
|
||||
+5
-1
@@ -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,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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 71 KiB |
BIN
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,
|
||||
);
|
||||
}
|
||||
-73
@@ -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();
|
||||
});
|
||||
});
|
||||
-124
@@ -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');
|
||||
});
|
||||
});
|
||||
-121
@@ -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;
|
||||
}
|
||||
-55
@@ -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);
|
||||
}
|
||||
-45
@@ -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 };
|
||||
}
|
||||
-157
@@ -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;
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
export type GitHubUser = {
|
||||
login: string;
|
||||
id: number;
|
||||
avatar_url?: string | null;
|
||||
};
|
||||
-15
@@ -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 };
|
||||
};
|
||||
-54
@@ -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' };
|
||||
}
|
||||
-282
@@ -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>
|
||||
);
|
||||
-23
@@ -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;
|
||||
-24
@@ -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,
|
||||
},
|
||||
});
|
||||
-22
@@ -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,
|
||||
},
|
||||
});
|
||||
-23
@@ -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,
|
||||
},
|
||||
});
|
||||
-24
@@ -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,
|
||||
},
|
||||
});
|
||||
-23
@@ -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,
|
||||
},
|
||||
});
|
||||
-23
@@ -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,
|
||||
},
|
||||
});
|
||||
-693
@@ -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',
|
||||
},
|
||||
});
|
||||
-106
@@ -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',
|
||||
},
|
||||
});
|
||||
-66
@@ -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,
|
||||
});
|
||||
-66
@@ -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,
|
||||
});
|
||||
-20
@@ -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;
|
||||
}
|
||||
-52
@@ -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,
|
||||
};
|
||||
}
|
||||
-21
@@ -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[]>;
|
||||
}
|
||||
-27
@@ -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;
|
||||
}
|
||||
-52
@@ -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,
|
||||
}));
|
||||
}
|
||||
-376
@@ -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,
|
||||
},
|
||||
});
|
||||
-28
@@ -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,
|
||||
},
|
||||
});
|
||||
-70
@@ -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,
|
||||
},
|
||||
});
|
||||
-43
@@ -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,
|
||||
},
|
||||
});
|
||||
-257
@@ -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,
|
||||
},
|
||||
});
|
||||
-47
@@ -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()];
|
||||
}
|
||||
-72
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
-10
@@ -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;
|
||||
};
|
||||
-30
@@ -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);
|
||||
}
|
||||
-51
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
-25
@@ -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',
|
||||
},
|
||||
});
|
||||
-23
@@ -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,
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user