Compare commits

..
2683 changed files with 50429 additions and 110934 deletions
+3 -5
View File
@@ -25,7 +25,6 @@ jobs:
packages/twenty-server/**
packages/twenty-front/src/generated/**
packages/twenty-front/src/generated-metadata/**
packages/twenty-front/src/generated-admin/**
packages/twenty-client-sdk/**
packages/twenty-emails/**
packages/twenty-shared/**
@@ -166,14 +165,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
+1 -2
View File
@@ -1,8 +1,7 @@
**/**/.env
.DS_Store
/.idea
.claude/
.cursor/debug-*.log
.claude/settings.json
**/**/node_modules/
.cache
+89 -122
View File
@@ -4,161 +4,123 @@
</a>
</p>
<h2 align="center" >The #1 Open-Source CRM</h2>
<h2 align="center" >The #1 Open-Source CRM </h2>
<p align="center"><a href="https://twenty.com">🌐 Website</a> · <a href="https://docs.twenty.com">📚 Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.png" width="12" height="12"/> Figma</a></p>
<br />
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.png" width="12" height="12"/> Figma</a></p>
<p align="center">
<a href="https://www.twenty.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/github-cover-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/github-cover-light.png" />
<img src="./packages/twenty-website/public/images/readme/github-cover-light.png" alt="Twenty banner" />
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/github-cover-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/github-cover-light.png" />
<img src="./packages/twenty-website/public/images/readme/github-cover-light.png" alt="Cover" />
</picture>
</a>
</p>
<br />
# Why Twenty
Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.
<a href="https://twenty.com/why-twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
<br />
# Installation
### <img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
See:
🚀 [Self-hosting](https://docs.twenty.com/developers/self-host/capabilities/docker-compose)
🖥️ [Local Setup](https://docs.twenty.com/developers/contribute/capabilities/local-setup)
The fastest way to get started. Sign up at [twenty.com](https://twenty.com) and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.
# Why Twenty
### <img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
We built Twenty for three reasons:
Scaffold a new app with the Twenty CLI:
**CRMs are too expensive, and users are trapped.** Companies use locked-in customer data to hike prices. It shouldn't be that way.
```bash
npx create-twenty-app my-app
```
**A fresh start is required to build a better experience.** We can learn from past mistakes and craft a cohesive experience inspired by new UX patterns from tools like Notion, Airtable or Linear.
Define objects, fields, and views as code:
```ts
import { defineObject, FieldType } from 'twenty-sdk/define';
export default defineObject({
nameSingular: 'deal',
namePlural: 'deals',
labelSingular: 'Deal',
labelPlural: 'Deals',
fields: [
{ name: 'name', label: 'Name', type: FieldType.TEXT },
{ name: 'amount', label: 'Amount', type: FieldType.CURRENCY },
{ name: 'closeDate', label: 'Close Date', type: FieldType.DATE_TIME },
],
});
```
Then ship it to your workspace:
```bash
npx twenty deploy
```
See the [app development guide](https://docs.twenty.com/developers/extend/apps/getting-started) for objects, views, agents, and logic functions.
### <img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.com/developers/self-host/capabilities/docker-compose), or contribute locally via the [local setup guide](https://docs.twenty.com/developers/contribute/capabilities/local-setup).
**We believe in open-source and community.** Hundreds of developers are already building Twenty together. Once we have plugin capabilities, a whole ecosystem will grow around it.
<br />
<br />
# Everything you need
# What You Can Do With Twenty
Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.
Please feel free to flag any specific needs you have by creating an issue.
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
Below are a few features we have implemented to date:
<table align="center">
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-build-apps-light.png" alt="Create your apps" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-version-control-light.png" alt="Stay on top with version control" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-all-tools-light.png" alt="All the tools you need to build anything" />
</picture>
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-tools-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-tools-light.png" alt="Customize your layouts" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
</td>
</tr>
<tr>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-ai-agents-light.png" alt="AI agents and chats" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
</td>
<td width="50%">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-light.png" />
<img src="./packages/twenty-website/public/images/readme/v2-crm-tools-light.png" alt="Plus all the tools of a good CRM" />
</picture>
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
</td>
</tr>
</table>
+ [Personalize layouts with filters, sort, group by, kanban and table views](#personalize-layouts-with-filters-sort-group-by-kanban-and-table-views)
+ [Customize your objects and fields](#customize-your-objects-and-fields)
+ [Create and manage permissions with custom roles](#create-and-manage-permissions-with-custom-roles)
+ [Automate workflow with triggers and actions](#automate-workflow-with-triggers-and-actions)
+ [Emails, calendar events, files, and more](#emails-calendar-events-files-and-more)
## Personalize layouts with filters, sort, group by, kanban and table views
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/views-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/views-light.png" />
<img src="./packages/twenty-website/public/images/readme/views-light.png" alt="Companies Kanban Views" />
</picture>
</p>
## Customize your objects and fields
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/data-model-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/data-model-light.png" />
<img src="./packages/twenty-website/public/images/readme/data-model-light.png" alt="Setting Custom Objects" />
</picture>
</p>
## Create and manage permissions with custom roles
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/permissions-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/permissions-light.png" />
<img src="./packages/twenty-website/public/images/readme/permissions-light.png" alt="Permissions" />
</picture>
</p>
## Automate workflow with triggers and actions
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/workflows-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/workflows-light.png" />
<img src="./packages/twenty-website/public/images/readme/workflows-light.png" alt="Workflows" />
</picture>
</p>
## Emails, calendar events, files, and more
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/plus-other-features-dark.png" />
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/plus-other-features-light.png" />
<img src="./packages/twenty-website/public/images/readme/plus-other-features-light.png" alt="Other Features" />
</picture>
</p>
<br />
# Stack
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
- <a href="https://nx.dev/"><img src="./packages/twenty-website/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
- <a href="https://nestjs.com/"><img src="./packages/twenty-website/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
- <a href="https://reactjs.org/"><img src="./packages/twenty-website/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
- [TypeScript](https://www.typescriptlang.org/)
- [Nx](https://nx.dev/)
- [NestJS](https://nestjs.com/), with [BullMQ](https://bullmq.io/), [PostgreSQL](https://www.postgresql.org/), [Redis](https://redis.io/)
- [React](https://reactjs.org/), with [Jotai](https://jotai.org/), [Linaria](https://linaria.dev/) and [Lingui](https://lingui.dev/)
# Thanks
<p align="center">
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.png" height="28" alt="Chromatic" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.png" height="28" alt="Greptile" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.png" height="28" alt="Sentry" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.png" height="28" alt="Crowdin" /></a>
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.png" height="30" alt="Chromatic" /></a>
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.png" height="30" alt="Greptile" /></a>
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.png" height="30" alt="Sentry" /></a>
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.png" height="30" alt="Crowdin" /></a>
<a href="https://e2b.dev/"><img src="./packages/twenty-website/public/images/readme/e2b.svg" height="30" alt="E2B" /></a>
</p>
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
@@ -166,4 +128,9 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
# Join the Community
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
- Star the repo
- Subscribe to releases (watch -> custom -> releases)
- Follow us on [Twitter](https://twitter.com/twentycrm) or [LinkedIn](https://www.linkedin.com/company/twenty/)
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
- Improve translations on [Crowdin](https://twenty.crowdin.com/twenty)
- [Contributions](https://github.com/twentyhq/twenty/contribute) are, of course, most welcome!
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-twenty-app",
"version": "2.0.0",
"version": "1.22.0",
"description": "Command-line interface to create Twenty application",
"main": "dist/cli.cjs",
"bin": "dist/cli.cjs",
@@ -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,
@@ -149,25 +149,6 @@ describe('copyBaseApplicationProject', () => {
);
});
it('should create an empty public directory in the scaffolded project', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
appDisplayName: 'My Test App',
appDescription: 'A test application',
appDirectory: testAppDirectory,
});
const publicDirectoryPath = join(testAppDirectory, 'public');
expect(await fs.pathExists(publicDirectoryPath)).toBe(true);
const publicDirectoryStats = await fs.stat(publicDirectoryPath);
expect(publicDirectoryStats.isDirectory()).toBe(true);
const publicDirectoryContents = await fs.readdir(publicDirectoryPath);
expect(publicDirectoryContents).toHaveLength(0);
});
it('should handle empty description', async () => {
await copyBaseApplicationProject({
appName: 'my-test-app',
@@ -23,8 +23,6 @@ export const copyBaseApplicationProject = async ({
await renameDotfiles({ appDirectory });
await addEmptyPublicDirectory({ appDirectory });
await generateUniversalIdentifiers({
appDisplayName,
appDescription,
@@ -51,14 +49,6 @@ const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
}
};
const addEmptyPublicDirectory = async ({
appDirectory,
}: {
appDirectory: string;
}) => {
await fs.ensureDir(join(appDirectory, 'public'));
};
const generateUniversalIdentifiers = async ({
appDisplayName,
appDescription,
@@ -1,5 +1,5 @@
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
import { defineApplication } from 'twenty-sdk/define';
import { defineApplication } from 'twenty-sdk';
export default defineApplication({
universalIdentifier: 'ac1d2ed1-8835-4bd4-9043-28b46fdda465',
@@ -1,4 +1,8 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: 'da15cfc6-3657-457d-8757-4ba11b5bb6e1',
@@ -1,4 +1,8 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: '505532f5-1fc5-4a58-8074-ba9b48650dbc',
@@ -1,4 +1,8 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: 'be15e062-b065-48b4-979c-65b9a50e0cb1',
@@ -1,4 +1,8 @@
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
import {
defineField,
FieldType,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-sdk';
export default defineField({
universalIdentifier: 'c90ae72d-4ddf-4f22-882f-eef98c91e40e',
@@ -2,7 +2,7 @@ import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
import { OAuthApplicationVariables } from 'src/logic-functions/get-oauth-application-variables';
import { VERIFY_PAGE_PATH } from 'src/logic-functions/get-verify-page';
import { defineFrontComponent } from 'twenty-sdk/define';
import { defineFrontComponent } from 'twenty-sdk';
const StyledContainer = styled.div`
display: flex;
@@ -1,4 +1,4 @@
import { defineLogicFunction, RoutePayload } from "twenty-sdk/define";
import { defineLogicFunction, RoutePayload } from "twenty-sdk";
import { MetadataApiClient } from 'twenty-sdk/clients';
export const OAUTH_TOKEN_PAIRS_PATH = '/oauth/token-pairs';
@@ -1,4 +1,4 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { defineLogicFunction } from 'twenty-sdk';
export type OAuthApplicationVariables = {
apolloClientId: string;
@@ -1,4 +1,4 @@
import { defineLogicFunction } from "twenty-sdk/define";
import { defineLogicFunction } from "twenty-sdk";
export const VERIFY_PAGE_PATH = '/oauth/verify';
@@ -1,4 +1,8 @@
import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordUpdateEvent } from 'twenty-sdk/define';
import {
defineLogicFunction,
type DatabaseEventPayload,
type ObjectRecordUpdateEvent,
} from 'twenty-sdk';
import { CoreApiClient } from 'twenty-sdk/clients';
type CompanyRecord = {
@@ -1,4 +1,4 @@
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk/define';
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Post install logic function executed successfully!', payload.previousVersion);
@@ -1,4 +1,4 @@
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk/define';
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
console.log('Pre install logic function executed successfully!', payload.previousVersion);
@@ -1,4 +1,4 @@
import { defineRole } from 'twenty-sdk/define';
import { defineRole } from 'twenty-sdk';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b8faae3f-e174-43fa-ab94-715712ae26cb';
@@ -1,4 +1,4 @@
import { type ApplicationConfig } from 'twenty-sdk/define';
import { type ApplicationConfig } from 'twenty-sdk';
const config: ApplicationConfig = {
universalIdentifier: 'a4df0c0f-c65e-44e5-8436-24814182d4ac',
@@ -1,4 +1,4 @@
import { Object } from 'twenty-sdk/define';
import { Object } from 'twenty-sdk';
@Object({
universalIdentifier: 'd1831348-b4a4-4426-9c0b-0af19e7a9c27',
@@ -1,4 +1,4 @@
import { type FunctionConfig } from 'twenty-sdk/define';
import { type FunctionConfig } from 'twenty-sdk';
import type { ProcessResult } from './types';
import { WebhookHandler } from './webhook-handler';
@@ -1,32 +0,0 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn
# codegen
generated
# dev
/dist/
.twenty
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*
# typescript
*.tsbuildinfo
*.d.ts
@@ -1 +0,0 @@
24.5.0
@@ -1,19 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off"
},
"ignorePatterns": ["node_modules", "dist"],
"rules": {
"no-unused-vars": "off",
"typescript/no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_"
}
],
"typescript/no-explicit-any": "off"
}
}
@@ -1 +0,0 @@
nodeLinker: node-modules
@@ -1,254 +0,0 @@
# GitHub Connector
Sync pull requests, issues, contributors and project items from GitHub into
Twenty, and react to GitHub webhook events in real time.
This app showcases how to build a non-trivial third-party connector with the
Twenty SDK: custom objects with rich relationships, navigation menu items,
table views, a dashboard page layout, logic functions for periodic syncs, an
HTTP webhook handler, and authenticated GraphQL/REST calls against an
external provider.
![GitHub Connector marketplace listing in Settings → Apps with About / Content / Permissions / Settings tabs](public/screenshots/app-listing.png)
![GitHub Dashboard with PR / review counters, weekly histograms, and top-contributor leaderboards](public/screenshots/github-dashboard.png)
![Pull Requests view with the Fetch Pull Requests command](public/screenshots/pull-requests-view.png)
![Contributor detail page with the Contributor Stats panel showing PRs authored, merged and reviewed over time](public/screenshots/contributor-stats.png)
## What it adds to your workspace
### Custom objects
Six custom objects, each with fields, relationships and table views:
- `pullRequest`
- `pullRequestReview`
- `pullRequestReviewEvent`
- `issue`
- `projectItem`
- `contributor`
### Navigation
A top-level **GitHub** folder in the left sidebar with:
- Pull Requests
- Issues
- Project Items
- Contributors
- Pull Request Reviews
- Pull Request Review Events
- GitHub Dashboard (a page layout that aggregates PR activity over time and
surfaces top contributors)
### Logic functions
| Function | Trigger |
| --------------------------------- | ------------------------------------------------ |
| `count-prs` | HTTP `POST /github/count-prs` |
| `fetch-prs` | HTTP `POST /github/fetch-prs` |
| `count-issues` | HTTP `POST /github/count-issues` |
| `fetch-issues` | HTTP `POST /github/fetch-issues` |
| `count-contributors` | HTTP `POST /github/count-contributors` |
| `fetch-contributors` | HTTP `POST /github/fetch-contributors` |
| `count-project-items` | HTTP `POST /github/count-project-items` |
| `fetch-project-items` | HTTP `POST /github/fetch-project-items` |
| `handle-github-webhook` | HTTP `POST /github/webhook` (no auth, signed) |
| `search-contributors` | HTTP `POST /contributors/search` |
| `contributor-stats` | HTTP `POST /contributors/stats` |
| `top-contributors` | HTTP `POST /contributors/top` |
| `recompute-pull-request-reviews` | HTTP `POST /pull-request-reviews/recompute` |
### Front components
Seven front components surface the connector inside the Twenty UI:
- **Fetch Pull Requests** — command on the Pull Request object
- **Fetch Issues** — command on the Issue object
- **Fetch Contributors** — command on the Contributor object
- **Fetch Project Items** — command on the Project Item object
- **Contributor Stats** — panel on the Contributor object that renders a
bar chart of PRs authored / merged / reviewed over the selected period
- **Top PR Authors** — dashboard widget that ranks the top 20 PR authors
over the last 90 days
- **Top Reviewers** — dashboard widget that ranks the top 20 PR reviewers
over the last 90 days
## Install
You have two options. Use **dev mode** for a tight edit/test loop while
iterating on the app, or **install** for a one-shot deploy.
### Option A — Live development (`yarn twenty dev`)
Use this when you want every code change to be re-synced into your local
Twenty server automatically.
```bash
cd packages/twenty-apps/community/github-connector
yarn install
# Register your local Twenty server as a remote (interactive prompt).
# When asked for the URL use http://localhost:2021 and paste an API key
# from Settings -> Developers in the Twenty UI.
yarn twenty remote add
# Build, install, and watch for changes.
yarn twenty dev
```
The first `yarn twenty dev` run installs the app on the remote and starts
watching `src/`. Edit any file and the change is re-synced within seconds.
### Option B — One-shot install
```bash
cd packages/twenty-apps/community/github-connector
yarn install
yarn twenty remote add # same prompts as above
yarn twenty install # builds and installs once
```
## Configure authentication
Once the app is installed, open the Twenty UI and go to
**Settings → Apps → GitHub Connector**. You only need one of the two auth
methods.
### Option 1 — Personal Access Token (recommended for trying it out)
| Variable | Required | Notes |
| --------------- | -------- | -------------------------------------------------------------------- |
| `GITHUB_TOKEN` | yes | Fine-grained PAT (`github_pat_…`). See permissions below. |
Create a fine-grained PAT at
<https://github.com/settings/personal-access-tokens>:
1. **Resource owner**: the org (or user) that owns the repos in
`GITHUB_REPOS` and the projects in `GITHUB_PROJECTS`. Org-owned tokens
must be approved by an org admin before they can read org resources.
2. **Repository access**: pick the specific repos (or "All repositories").
3. **Repository permissions** — set to **Read-only**:
- `Contents`
- `Issues`
- `Pull requests`
- `Metadata` (selected automatically)
4. **Organization permissions** — only if you want to sync GitHub Projects
(v2): set `Projects` to **Read-only**.
5. Generate, then copy the `github_pat_…` value.
Classic PATs are intentionally not supported — fine-grained tokens are
scoped per-repo/per-org and avoid the all-or-nothing `repo` scope.
When `GITHUB_TOKEN` is set, it always wins regardless of any GitHub App
config below.
### Option 2 — GitHub App (recommended for production / org-wide installs)
| Variable | Required | Notes |
| ---------------------------- | -------- | ---------------------------------------------------------------------- |
| `GITHUB_APP_ID` | yes | Numeric App ID from the GitHub App settings page. |
| `GITHUB_APP_PRIVATE_KEY` | yes | PEM private key (BEGIN/END PRIVATE KEY block). Newlines are tolerant. |
| `GITHUB_APP_INSTALLATION_ID` | yes | The installation id of the App on your org/user. |
To create one:
1. <https://github.com/settings/apps/new> (or
`https://github.com/organizations/<org>/settings/apps/new`).
2. Grant the App these **repository permissions**: `Contents: Read`,
`Issues: Read`, `Pull Requests: Read`, `Metadata: Read`. For Projects v2
add `Organization → Projects: Read`.
3. Generate a private key (downloads a `.pem`).
4. Install the App on your org/user — the URL bar of the post-install page
contains the installation id, e.g. `.../installations/12345678`.
5. Paste the App ID, the PEM contents, and the installation ID into the
variables above.
The connector exchanges the App credentials for a short-lived installation
token (cached in-memory until shortly before expiry) and uses it for all
GitHub calls.
### Common variables
| Variable | Required | Notes |
| ------------------------- | -------- | --------------------------------------------------------------------------------------- |
| `GITHUB_REPOS` | yes | Comma-separated `owner/repo` list, e.g. `octocat/hello-world,octo-org/octo-repo`. |
| `GITHUB_PROJECTS` | no | Comma-separated GitHub Projects (v2). See format below. |
| `GITHUB_WEBHOOK_SECRET` | no | Shared secret to verify `X-Hub-Signature-256`. When unset, signatures are not verified. |
`GITHUB_PROJECTS` accepts entries in either of these forms:
- `owner/number` — e.g. `twentyhq/24,octo/3`. Owner can be an org or a user.
- Full project URL — e.g. `https://github.com/orgs/twentyhq/projects/24` or
`https://github.com/users/octocat/projects/3`.
## Running a sync
In the Twenty UI, open any of the GitHub objects (e.g. **Pull Requests**) and
trigger the matching command from the command palette (`Cmd/Ctrl+K`):
| View | Command | Reads from |
| ----------------- | --------------------- | ----------------- |
| Pull Requests | Fetch Pull Requests | `GITHUB_REPOS` |
| Issues | Fetch Issues | `GITHUB_REPOS` |
| Contributors | Fetch Contributors | `GITHUB_REPOS` |
| Project Items | Fetch Project Items | `GITHUB_PROJECTS` |
Each command iterates over every entry in the relevant variable and shows a
progress bar.
## Webhooks
Point a GitHub repo or App webhook at the public URL of your Twenty server,
path `POST /github/webhook`. Recommended event subscriptions:
- Pull requests
- Pull request reviews
- Issues
- Project (v2) items
Set the same value as `GITHUB_WEBHOOK_SECRET` on both sides to enable HMAC
verification. For local testing, expose your dev server with
[smee.io](https://smee.io/) or `ngrok` and use that URL as the webhook URL on
GitHub.
> **Heads up — raw body required for signatures.** HMAC verification needs
> the original request body bytes. The Twenty SDK currently parses JSON
> requests before handing them to logic functions, so when the runtime
> delivers an already-parsed body the connector logs a warning and rejects
> the delivery instead of silently accepting it. Until the SDK exposes the
> raw bytes for HTTP routes, either leave `GITHUB_WEBHOOK_SECRET` unset (and
> rely on a hard-to-guess `/github/webhook` URL plus IP allow-listing), or
> terminate signature verification at a reverse proxy in front of Twenty.
## How auth resolution works
`src/modules/github/connector/auth.ts` returns a token using the following
order:
1. `GITHUB_TOKEN` (fine-grained PAT, `github_pat_…`) if present. Classic
PATs are rejected at startup.
2. Cached installation token, if still valid.
3. Fresh installation token minted from the GitHub App credentials.
This makes the example easy to try in 30 seconds with a PAT, while still
demonstrating the production-grade GitHub App flow.
## Tests
The app ships with a small integration test suite that runs against a local
`twenty-app-dev-test` container.
```bash
docker run -d --name twenty-app-dev-test \
-p 2021:2021 twentycrm/twenty-app-dev:v2.0.3
cd packages/twenty-apps/community/github-connector
yarn test
```
The suite installs the app into the container, then asserts that every
object/field/logic-function is wired up and that webhook signature
verification behaves correctly.
@@ -1,35 +0,0 @@
{
"name": "github-connector",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^24.5.0",
"npm": "please-use-yarn",
"yarn": ">=4.0.2"
},
"keywords": [
"twenty-app"
],
"packageManager": "yarn@4.9.2",
"scripts": {
"twenty": "twenty",
"lint": "oxlint -c .oxlintrc.json .",
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"twenty-client-sdk": "2.0.0",
"twenty-sdk": "2.0.0"
},
"devDependencies": {
"@types/node": "^24.7.2",
"@types/react": "^19.0.0",
"oxlint": "^0.16.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1",
"vitest": "^3.1.1"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

@@ -1,54 +0,0 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const apiUrl = process.env.TWENTY_API_URL!;
const apiKey = process.env.TWENTY_API_KEY!;
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}
@@ -1,128 +0,0 @@
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
const metadata = () =>
new MetadataApiClient({
headers: {
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
});
export async function findObjectByName(name: string) {
const client = metadata();
const result = await client.query({
objects: {
__args: {
filter: { isCustom: { is: true } },
paging: { first: 50 },
},
edges: {
node: {
nameSingular: true,
fields: {
__args: { paging: { first: 500 } },
edges: { node: { name: true, type: true } },
},
},
},
},
});
return result.objects?.edges?.find((e) => e.node.nameSingular === name)?.node;
}
type ExecutionResult = {
data: unknown;
status: string;
duration: number;
error: unknown;
};
export async function findLogicFunctionId(
universalIdentifier: string,
): Promise<string> {
const client = metadata();
const result = await client.query({
findManyLogicFunctions: {
id: true,
universalIdentifier: true,
},
});
const fn = result.findManyLogicFunctions?.find(
(f) => f.universalIdentifier === universalIdentifier,
);
if (!fn) {
throw new Error(`Logic function ${universalIdentifier} not found`);
}
return fn.id;
}
export async function executeLogicFunction(
id: string,
payload: Record<string, unknown>,
): Promise<ExecutionResult> {
const client = metadata();
const result = await client.mutation({
executeOneLogicFunction: {
__args: { input: { id, payload } },
data: true,
status: true,
duration: true,
error: true,
},
});
return result.executeOneLogicFunction as ExecutionResult;
}
const BASE_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2021';
export async function callRoute(
path: string,
body: Record<string, unknown> | string,
options: {
method?: string;
auth?: boolean;
headers?: Record<string, string>;
} = {},
): Promise<{ status: number; data: unknown }> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers ?? {}),
};
if (options.auth) {
headers['Authorization'] = `Bearer ${process.env.TWENTY_API_KEY}`;
}
const res = await fetch(`${BASE_URL}/s${path}`, {
method: options.method ?? 'POST',
headers,
body: typeof body === 'string' ? body : JSON.stringify(body),
});
let data: unknown = null;
try {
data = await res.json();
} catch {
data = await res.text().catch(() => null);
}
return { status: res.status, data };
}
export async function findInstalledApp(universalIdentifier: string) {
const client = metadata();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
return result.findManyApplications?.find(
(app) => app.universalIdentifier === universalIdentifier,
);
}
@@ -1,73 +0,0 @@
import { beforeAll, describe, expect, it } from 'vitest';
import {
executeLogicFunction,
findLogicFunctionId,
} from './helpers/metadata';
const COUNT_PRS_FN_UI = '082227ae-2acc-4320-8d31-62ad6c443da6';
const COUNT_ISSUES_FN_UI = 'd8cc32bf-6be9-44fc-920a-8bba510f045f';
const COUNT_PROJECT_ITEMS_FN_UI = 'f7a3e1b2-5c4d-4e6f-8a9b-0d1c2e3f4a5b';
const COUNT_CONTRIBUTORS_FN_UI = 'fe0a6f00-0d63-4cb9-9b3c-1d8186181830';
const HANDLE_WEBHOOK_FN_UI = '22b199b3-2851-4a4f-99fd-4e79c188fe7d';
const fnIds: Record<string, string> = {};
beforeAll(async () => {
fnIds.prs = await findLogicFunctionId(COUNT_PRS_FN_UI);
fnIds.issues = await findLogicFunctionId(COUNT_ISSUES_FN_UI);
fnIds.projectItems = await findLogicFunctionId(COUNT_PROJECT_ITEMS_FN_UI);
fnIds.contributors = await findLogicFunctionId(COUNT_CONTRIBUTORS_FN_UI);
fnIds.webhook = await findLogicFunctionId(HANDLE_WEBHOOK_FN_UI);
});
describe('logic functions are wired up', () => {
it('count-prs is reachable and returns the expected payload shape', async () => {
const result = await executeLogicFunction(fnIds.prs, {
body: { repos: ['fixture-org/fixture-repo'] },
});
expect(['SUCCESS', 'ERROR']).toContain(result.status);
if (result.status === 'SUCCESS') {
const data = result.data as {
totalPages: number;
repos: Array<{ owner: string; repo: string; pages: number }>;
};
expect(typeof data.totalPages).toBe('number');
expect(Array.isArray(data.repos)).toBe(true);
}
});
it('count-issues is reachable', async () => {
const result = await executeLogicFunction(fnIds.issues, {
body: { repos: ['fixture-org/fixture-repo'] },
});
expect(['SUCCESS', 'ERROR']).toContain(result.status);
});
it('count-project-items is reachable', async () => {
const result = await executeLogicFunction(fnIds.projectItems, {
body: { projects: [{ owner: 'fixture-org', number: 9999999 }] },
});
expect(['SUCCESS', 'ERROR']).toContain(result.status);
});
it('count-contributors iterates configured repos and returns the per-repo split', async () => {
const result = await executeLogicFunction(fnIds.contributors, {
body: { repos: ['fixture-org/fixture-repo'] },
});
expect(['SUCCESS', 'ERROR']).toContain(result.status);
if (result.status === 'SUCCESS') {
const data = result.data as {
totalPages: number;
repos: Array<{ owner: string; repo: string; pages: number }>;
};
expect(typeof data.totalPages).toBe('number');
expect(Array.isArray(data.repos)).toBe(true);
expect('orgMembers' in (data as Record<string, unknown>)).toBe(false);
}
});
it('handle-github-webhook is registered (but rejects unsigned requests gracefully)', async () => {
expect(fnIds.webhook).toBeDefined();
});
});
@@ -1,124 +0,0 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/modules/shared/universal-identifiers';
import { describe, expect, it } from 'vitest';
import { findInstalledApp, findObjectByName } from './helpers/metadata';
describe('App installation', () => {
it('finds the installed GitHub Connector app', async () => {
const app = await findInstalledApp(APPLICATION_UNIVERSAL_IDENTIFIER);
expect(app).toBeDefined();
expect(app?.name).toMatch(/github/i);
});
});
describe('Contributor object', () => {
it('exists with the expected GitHub-only fields', async () => {
const obj = await findObjectByName('contributor');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('name');
expect(names).toContain('ghLogin');
expect(names).toContain('githubId');
expect(names).toContain('avatarUrl');
expect(names).toContain('contributions');
expect(names).not.toContain('isCoreTeam');
expect(names).not.toContain('discordId');
});
});
describe('PullRequest object', () => {
it('exists with the expected fields and relations', async () => {
const obj = await findObjectByName('pullRequest');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('name');
expect(names).toContain('githubNumber');
expect(names).toContain('uniqueIdentifier');
expect(names).toContain('url');
expect(names).toContain('state');
expect(names).toContain('mergedAt');
expect(names).toContain('closedAt');
expect(names).toContain('githubCreatedAt');
expect(names).toContain('author');
expect(names).toContain('merger');
expect(names).toContain('reviews');
expect(names).toContain('projectItems');
});
});
describe('PullRequestReviewEvent object', () => {
it('exists with the expected fields and relations', async () => {
const obj = await findObjectByName('pullRequestReviewEvent');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('title');
expect(names).toContain('githubReviewId');
expect(names).toContain('state');
expect(names).toContain('submittedAt');
expect(names).toContain('reviewer');
expect(names).toContain('pullRequest');
expect(names).toContain('review');
});
});
describe('PullRequestReview object', () => {
it('exists with the expected fields and relations', async () => {
const obj = await findObjectByName('pullRequestReview');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('title');
expect(names).toContain('reviewKey');
expect(names).toContain('state');
expect(names).toContain('firstSubmittedAt');
expect(names).toContain('lastSubmittedAt');
expect(names).toContain('eventCount');
expect(names).toContain('reviewer');
expect(names).toContain('pullRequest');
expect(names).toContain('reviewEvents');
});
});
describe('Issue object', () => {
it('exists with the expected fields and relations', async () => {
const obj = await findObjectByName('issue');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('title');
expect(names).toContain('githubNumber');
expect(names).toContain('uniqueIdentifier');
expect(names).toContain('githubUrl');
expect(names).toContain('state');
expect(names).toContain('labels');
expect(names).toContain('githubCreatedAt');
expect(names).toContain('closedAt');
expect(names).toContain('repo');
expect(names).toContain('author');
expect(names).toContain('projectItems');
});
});
describe('ProjectItem object', () => {
it('exists with the expected fields and relations', async () => {
const obj = await findObjectByName('projectItem');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('name');
expect(names).toContain('githubProjectItemId');
expect(names).toContain('status');
expect(names).toContain('sprint');
expect(names).toContain('assignees');
expect(names).toContain('priority');
expect(names).toContain('mainAssignee');
expect(names).toContain('linkedIssue');
expect(names).toContain('linkedPullRequest');
expect(names).toContain('githubUrl');
expect(names).toContain('repo');
});
});
@@ -1,121 +0,0 @@
import { describe, expect, it } from 'vitest';
import { createHmac } from 'crypto';
import {
getRawBodyForSignature,
verifyGitHubSignature,
} from 'src/modules/github/connector/webhook-signature';
const SECRET = 'super-secret-shared-string';
function sign(body: string): string {
return `sha256=${createHmac('sha256', SECRET).update(body).digest('hex')}`;
}
describe('verifyGitHubSignature', () => {
it('accepts a valid signature', () => {
const body = '{"action":"opened","number":42}';
const result = verifyGitHubSignature({
rawBody: body,
signatureHeader: sign(body),
secret: SECRET,
});
expect(result.ok).toBe(true);
});
it('rejects a tampered body', () => {
const body = '{"action":"opened","number":42}';
const signature = sign(body);
const result = verifyGitHubSignature({
rawBody: body.replace('42', '43'),
signatureHeader: signature,
secret: SECRET,
});
expect(result.ok).toBe(false);
});
it('rejects a wrong secret', () => {
const body = '{"action":"opened","number":42}';
const result = verifyGitHubSignature({
rawBody: body,
signatureHeader: sign(body),
secret: 'other-secret',
});
expect(result.ok).toBe(false);
});
it('rejects a missing header', () => {
const result = verifyGitHubSignature({
rawBody: 'anything',
signatureHeader: undefined,
secret: SECRET,
});
expect(result).toMatchObject({
ok: false,
reason: 'missing X-Hub-Signature-256 header',
});
});
it('rejects a header without sha256= prefix', () => {
const result = verifyGitHubSignature({
rawBody: 'anything',
signatureHeader: 'sha1=deadbeef',
secret: SECRET,
});
expect(result).toMatchObject({
ok: false,
reason: 'malformed signature header',
});
});
it('rejects when length differs', () => {
const result = verifyGitHubSignature({
rawBody: 'anything',
signatureHeader: 'sha256=tooshort',
secret: SECRET,
});
expect(result).toMatchObject({
ok: false,
reason: 'signature length mismatch',
});
});
});
describe('getRawBodyForSignature', () => {
it('returns the string as-is for string body', () => {
expect(
getRawBodyForSignature({ body: '{"a":1}', isBase64Encoded: false }),
).toBe('{"a":1}');
});
it('decodes base64 bodies', () => {
const original = '{"a":1}';
const b64 = Buffer.from(original, 'utf8').toString('base64');
expect(getRawBodyForSignature({ body: b64, isBase64Encoded: true })).toBe(
original,
);
});
it('returns null for parsed object bodies (raw bytes lost)', () => {
expect(getRawBodyForSignature({ body: { a: 1 } })).toBeNull();
});
it('returns empty string for null/undefined', () => {
expect(getRawBodyForSignature({ body: null })).toBe('');
});
});
describe('verifyGitHubSignature with parsed body', () => {
it('rejects with a clear reason when the runtime parsed the JSON', () => {
const result = verifyGitHubSignature({
rawBody: null,
signatureHeader: 'sha256=deadbeef',
secret: SECRET,
});
expect(result).toMatchObject({
ok: false,
reason:
'raw request body is unavailable (the runtime parsed it as JSON); HMAC cannot be verified',
});
});
});
@@ -1,66 +0,0 @@
import { defineApplication } from 'twenty-sdk/define';
import {
APP_ABOUT_DESCRIPTION,
APP_DESCRIPTION,
APP_DISPLAY_NAME,
APPLICATION_UNIVERSAL_IDENTIFIER,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/modules/shared/universal-identifiers';
export default defineApplication({
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
displayName: APP_DISPLAY_NAME,
description: APP_DESCRIPTION,
aboutDescription: APP_ABOUT_DESCRIPTION,
icon: 'IconBrandGithub',
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
screenshots: [
'public/screenshots/app-listing.png',
'public/screenshots/github-dashboard.png',
'public/screenshots/pull-requests-view.png',
'public/screenshots/contributor-stats.png',
],
applicationVariables: {
GITHUB_TOKEN: {
universalIdentifier: 'fb1d2e91-3a75-4c89-9d6b-1e2f7a4c5d8e',
description:
'Fine-grained Personal Access Token (github_pat_…) from https://github.com/settings/personal-access-tokens — needs Read-only access to Contents, Issues, Pull requests and Metadata (and Organization → Projects for Projects v2). Classic PATs are not supported. When set, takes precedence over the GitHub App credentials below.',
isSecret: true,
},
GITHUB_APP_ID: {
universalIdentifier: '2a196d91-4c1b-4f8d-bc34-6693fcdaa771',
description:
'GitHub App ID. Used together with GITHUB_APP_PRIVATE_KEY and GITHUB_APP_INSTALLATION_ID when GITHUB_TOKEN is unset.',
isSecret: false,
},
GITHUB_APP_PRIVATE_KEY: {
universalIdentifier: 'c5591dd4-f653-4b92-bec9-970ddc8e10cc',
description: 'GitHub App PEM private key (BEGIN/END PRIVATE KEY block).',
isSecret: true,
},
GITHUB_APP_INSTALLATION_ID: {
universalIdentifier: '0c39a59a-ee2e-49a9-88c7-3fbe6cb04ad0',
description: 'GitHub App installation ID for your org.',
isSecret: false,
},
GITHUB_WEBHOOK_SECRET: {
universalIdentifier: 'b9f3c2d8-1e7a-4d56-9c8b-3a2f1e5d7c9a',
description:
'Shared secret used to verify the X-Hub-Signature-256 HMAC on incoming GitHub webhooks. When unset, signature verification is skipped (use only in dev/test).',
isSecret: true,
},
GITHUB_REPOS: {
universalIdentifier: '7d1e9c84-2f63-4a58-9b0d-5e8a3c1f7b29',
description:
'Comma-separated list of `owner/repo` to sync (e.g. `twentyhq/twenty,octo/hello`). Used by the manual fetch routes.',
isSecret: false,
},
GITHUB_PROJECTS: {
universalIdentifier: 'e3a8c7d2-4b95-4e1f-8a6c-9d2b5f7e1c84',
description:
'Comma-separated list of GitHub Projects (v2) to sync. Each entry is `owner/number` (e.g. `twentyhq/24,octo/3`). Owner can be an organization or a user. Full URLs like `https://github.com/orgs/twentyhq/projects/24` or `https://github.com/users/octo/projects/3` are also accepted.',
isSecret: false,
},
},
});
@@ -1,16 +0,0 @@
import { defineRole } from 'twenty-sdk/define';
import {
APP_DISPLAY_NAME,
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
} from 'src/modules/shared/universal-identifiers';
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default function role`,
description: `${APP_DISPLAY_NAME} default function role`,
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: false,
});
@@ -1,122 +0,0 @@
import { createSign } from 'crypto';
export function normalizePem(raw: string): string {
let pem = raw.replace(/\\n/g, '\n').trim();
if (!pem.includes('\n')) {
const beginMatch = pem.match(/^(-----BEGIN [A-Z ]+-----)/);
const endMatch = pem.match(/(-----END [A-Z ]+-----)$/);
if (beginMatch && endMatch) {
const header = beginMatch[1];
const footer = endMatch[1];
const body = pem.slice(header.length, pem.length - footer.length);
const lines = body.match(/.{1,64}/g) ?? [];
pem = [header, ...lines, footer].join('\n');
}
}
return pem;
}
function base64url(input: Buffer | string): string {
const buf = typeof input === 'string' ? Buffer.from(input) : input;
return buf.toString('base64url');
}
function signJwt(appId: string, privateKey: string): string {
const now = Math.floor(Date.now() / 1000);
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const payload = base64url(
JSON.stringify({ iat: now - 60, exp: now + 600, iss: appId }),
);
const signature = createSign('RSA-SHA256')
.update(`${header}.${payload}`)
.sign(privateKey, 'base64url');
return `${header}.${payload}.${signature}`;
}
type CachedToken = { token: string; expiresAt: number };
let cached: CachedToken | null = null;
const TOKEN_MARGIN_MS = 5 * 60 * 1000;
async function requestInstallationToken(
appId: string,
privateKey: string,
installationId: string,
): Promise<CachedToken> {
const jwt = signJwt(appId, privateKey);
const res = await fetch(
`https://api.github.com/app/installations/${installationId}/access_tokens`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${jwt}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
},
);
if (!res.ok) {
const body = await res.text();
throw new Error(
`GitHub App token exchange failed (${res.status}): ${body}`,
);
}
const data = (await res.json()) as {
token: string;
expires_at: string;
};
return {
token: data.token,
expiresAt: new Date(data.expires_at).getTime(),
};
}
function assertFineGrainedPat(token: string): void {
if (token.startsWith('github_pat_')) return;
throw new Error(
'GITHUB_TOKEN must be a fine-grained Personal Access Token (starts with `github_pat_`). Classic PATs are not supported — create one at https://github.com/settings/personal-access-tokens.',
);
}
export async function getGitHubToken(): Promise<string> {
const pat = process.env.GITHUB_TOKEN?.trim();
if (pat) {
assertFineGrainedPat(pat);
return pat;
}
if (cached && Date.now() < cached.expiresAt - TOKEN_MARGIN_MS) {
return cached.token;
}
const appId = process.env.GITHUB_APP_ID;
const rawKey = process.env.GITHUB_APP_PRIVATE_KEY;
const installationId = process.env.GITHUB_APP_INSTALLATION_ID;
if (!appId || !rawKey || !installationId) {
const missing = [
!appId && 'GITHUB_APP_ID',
!rawKey && 'GITHUB_APP_PRIVATE_KEY',
!installationId && 'GITHUB_APP_INSTALLATION_ID',
]
.filter(Boolean)
.join(', ');
throw new Error(
`Missing GitHub credentials. Set GITHUB_TOKEN (fine-grained PAT) or all of: ${missing}.`,
);
}
const privateKey = normalizePem(rawKey);
cached = await requestInstallationToken(appId, privateKey, installationId);
return cached.token;
}
@@ -1,55 +0,0 @@
export type GithubRepo = { owner: string; repo: string };
export function parseGithubRepo(entry: string): GithubRepo | null {
const trimmed = entry.trim();
if (!trimmed) return null;
const match = trimmed.match(/^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/);
if (!match) return null;
return { owner: match[1], repo: match[2] };
}
export function getGithubRepos(): string[] {
const raw = process.env.GITHUB_REPOS ?? '';
return raw
.split(',')
.map(parseGithubRepo)
.filter((r): r is GithubRepo => r !== null)
.map((r) => `${r.owner}/${r.repo}`);
}
export type GithubProject = { owner: string; number: number };
export function parseGithubProject(entry: string): GithubProject | null {
const trimmed = entry.trim();
if (!trimmed) return null;
const urlMatch = trimmed.match(
/github\.com\/(?:orgs|users)\/([^/]+)\/projects\/(\d+)/i,
);
if (urlMatch) {
const owner = urlMatch[1];
const number = Number.parseInt(urlMatch[2], 10);
if (owner && Number.isFinite(number) && number > 0) {
return { owner, number };
}
}
const shortMatch = trimmed.match(/^([^/\s]+)\/(\d+)$/);
if (shortMatch) {
const owner = shortMatch[1];
const number = Number.parseInt(shortMatch[2], 10);
if (owner && Number.isFinite(number) && number > 0) {
return { owner, number };
}
}
return null;
}
export function getGithubProjects(): GithubProject[] {
const raw = process.env.GITHUB_PROJECTS ?? '';
return raw
.split(',')
.map(parseGithubProject)
.filter((p): p is GithubProject => p !== null);
}
@@ -1,45 +0,0 @@
import {
getGithubRepos,
parseGithubRepo,
} from 'src/modules/github/connector/config';
export type RepoCount = {
owner: string;
repo: string;
totalCount: number;
pages: number;
};
export type CountAcrossReposResult = {
totalPages: number;
repos: RepoCount[];
};
const PAGE_SIZE = 100;
export async function countAcrossRepos(
bodyRepos: string[] | undefined,
count: (owner: string, repo: string) => Promise<number>,
logTag: string,
): Promise<CountAcrossReposResult> {
const repos =
bodyRepos && bodyRepos.length > 0 ? bodyRepos : getGithubRepos();
const results: RepoCount[] = [];
let totalPages = 0;
for (const fullRepo of repos) {
const parsed = parseGithubRepo(fullRepo);
if (!parsed) {
console.warn(`[${logTag}] Skipping malformed repo entry: ${fullRepo}`);
continue;
}
const { owner, repo } = parsed;
const totalCount = await count(owner, repo);
const pages = Math.max(Math.ceil(totalCount / PAGE_SIZE), 1);
results.push({ owner, repo, totalCount, pages });
totalPages += pages;
}
return { totalPages, repos: results };
}
@@ -1,157 +0,0 @@
import { getGitHubToken } from 'src/modules/github/connector/auth';
const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql';
const LOW_REMAINING_THRESHOLD = 200;
export class GitHubRateLimitError extends Error {
constructor(
message: string,
public readonly kind: 'primary' | 'secondary',
public readonly remaining: number | null,
public readonly limit: number | null,
public readonly resetAt: Date | null,
public readonly retryAfterSeconds: number | null,
) {
super(message);
this.name = 'GitHubRateLimitError';
}
}
function readRateLimitHeaders(res: Response) {
const num = (h: string) => {
const v = res.headers.get(h);
return v === null ? null : Number(v);
};
const reset = num('x-ratelimit-reset');
return {
limit: num('x-ratelimit-limit'),
remaining: num('x-ratelimit-remaining'),
used: num('x-ratelimit-used'),
resource: res.headers.get('x-ratelimit-resource'),
resetAt:
reset === null || Number.isNaN(reset) ? null : new Date(reset * 1000),
retryAfter: num('retry-after'),
};
}
export async function githubGraphql<T>(
query: string,
variables: Record<string, unknown>,
): Promise<T> {
const queryName = query.match(/query\s*\(/)
? query.slice(0, 60).replace(/\s+/g, ' ').trim()
: 'mutation';
console.log(
`[github-gql] Executing: ${queryName} vars=${JSON.stringify(Object.keys(variables))}`,
);
const token = await getGitHubToken();
const res = await fetch(GITHUB_GRAPHQL_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables }),
});
const rl = readRateLimitHeaders(res);
if (rl.remaining !== null && rl.limit !== null) {
const resetIn = rl.resetAt
? Math.max(0, Math.round((rl.resetAt.getTime() - Date.now()) / 1000))
: null;
const isLow =
rl.remaining <= LOW_REMAINING_THRESHOLD ||
rl.remaining / rl.limit < 0.1;
const tag = isLow
? '[github-gql][rate-limit-low]'
: '[github-gql][rate-limit]';
console.log(
`${tag} resource=${rl.resource ?? 'graphql'} remaining=${rl.remaining}/${rl.limit} used=${rl.used ?? '?'} resetIn=${resetIn ?? '?'}s`,
);
}
if (!res.ok) {
const body = await res.text();
const lower = body.toLowerCase();
const isSecondary =
res.status === 403 &&
(lower.includes('secondary rate limit') ||
lower.includes('abuse detection'));
const isPrimary =
(res.status === 403 || res.status === 429) &&
lower.includes('rate limit') &&
!isSecondary;
if (isPrimary || isSecondary) {
throw new GitHubRateLimitError(
`GitHub ${isSecondary ? 'secondary' : 'primary'} rate limit hit (${res.status}): ${body.slice(0, 200)}`,
isSecondary ? 'secondary' : 'primary',
rl.remaining,
rl.limit,
rl.resetAt,
rl.retryAfter,
);
}
throw new Error(
`GitHub GraphQL ${res.status} ${res.statusText}: ${body.slice(0, 500)}`,
);
}
const json = (await res.json()) as {
data: T;
errors?: Array<{ type?: string; message: string }>;
};
if (json.errors?.length) {
const isRateLimited = json.errors.some(
(e) => e.type === 'RATE_LIMITED' || /rate limit/i.test(e.message),
);
if (isRateLimited) {
throw new GitHubRateLimitError(
`GitHub GraphQL RATE_LIMITED: ${json.errors.map((e) => e.message).join(', ')}`,
'primary',
rl.remaining,
rl.limit,
rl.resetAt,
rl.retryAfter,
);
}
throw new Error(
`GraphQL errors: ${json.errors.map((e) => e.message).join(', ')}`,
);
}
return json.data;
}
export async function githubGraphqlOptional<T>(
query: string,
variables: Record<string, unknown>,
): Promise<T | null> {
try {
return await githubGraphql<T>(query, variables);
} catch (err) {
const msg = err instanceof Error ? err.message : '';
if (/Could not resolve to a (User|Organization)/i.test(msg)) return null;
throw err;
}
}
export type GithubPage<T> = {
totalCount: number;
pageInfo: { hasNextPage: boolean; endCursor: string | null };
nodes: T[];
};
export type Paged<TItems extends Record<string, unknown[]>> = TItems & {
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
export const EMPTY_PAGE = {
totalCount: 0,
hasMore: false,
endCursor: null,
} as const;
@@ -1,5 +0,0 @@
export type GitHubUser = {
login: string;
id: number;
avatar_url?: string | null;
};
@@ -1,15 +0,0 @@
import type { GitHubUser } from 'src/modules/github/connector/github-user';
import type { GitHubPullRequest } from 'src/modules/github/pull-request/types/github-pull-request';
import type { GitHubReview } from 'src/modules/github/pull-request-review-event/types/github-review';
import type { GitHubIssue } from 'src/modules/github/issue/types/github-issue';
import type { GitHubProjectV2Item } from 'src/modules/github/project-item/types/github-project-v2-item';
export type GitHubWebhookPayload = {
action: string;
pull_request?: GitHubPullRequest;
review?: GitHubReview;
issue?: GitHubIssue;
projects_v2_item?: GitHubProjectV2Item;
sender: GitHubUser;
repository?: { full_name: string };
};
@@ -1,54 +0,0 @@
import { createHmac, timingSafeEqual } from 'crypto';
export type SignatureVerificationResult =
| { ok: true }
| { ok: false; reason: string };
export function getRawBodyForSignature(event: {
body: unknown;
isBase64Encoded?: boolean;
}): string | null {
const raw = event.body;
if (raw == null) return '';
if (typeof raw === 'string') {
return event.isBase64Encoded
? Buffer.from(raw, 'base64').toString('utf8')
: raw;
}
return null;
}
export function verifyGitHubSignature({
rawBody,
signatureHeader,
secret,
}: {
rawBody: string | null;
signatureHeader: string | undefined;
secret: string;
}): SignatureVerificationResult {
if (rawBody === null) {
return {
ok: false,
reason:
'raw request body is unavailable (the runtime parsed it as JSON); HMAC cannot be verified',
};
}
if (!signatureHeader) {
return { ok: false, reason: 'missing X-Hub-Signature-256 header' };
}
const prefix = 'sha256=';
if (!signatureHeader.startsWith(prefix)) {
return { ok: false, reason: 'malformed signature header' };
}
const provided = signatureHeader.slice(prefix.length);
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
if (provided.length !== expected.length) {
return { ok: false, reason: 'signature length mismatch' };
}
const ok = timingSafeEqual(
Buffer.from(provided, 'utf8'),
Buffer.from(expected, 'utf8'),
);
return ok ? { ok: true } : { ok: false, reason: 'signature mismatch' };
}
@@ -1,282 +0,0 @@
import { type ReactNode } from 'react';
import { THEME } from 'src/modules/github/contributor/components/theme';
export type LeaderboardEntry = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: string | null;
count: number;
};
const ROW_HEIGHT = 32;
const CELL_PADDING_X = 8;
const RANK_COL_WIDTH = 36;
const COUNT_COL_WIDTH = 72;
const styles = {
root: {
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
minHeight: 0,
fontFamily: THEME.fontFamily,
color: THEME.fontPrimary,
background: THEME.bgPrimary,
boxSizing: 'border-box',
overflow: 'hidden',
} as const,
table: {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden',
width: '100%',
} as const,
headerRow: {
display: 'flex',
alignItems: 'stretch',
height: ROW_HEIGHT,
flexShrink: 0,
borderBottom: `1px solid ${THEME.borderLight}`,
background: THEME.bgPrimary,
} as const,
headerCell: {
display: 'flex',
alignItems: 'center',
height: '100%',
padding: `0 ${CELL_PADDING_X}px`,
borderRight: `1px solid ${THEME.borderLight}`,
color: THEME.fontTertiary,
fontSize: 12,
fontWeight: 500,
boxSizing: 'border-box',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
userSelect: 'none',
} as const,
headerCellLast: {
borderRight: 'none',
} as const,
row: {
display: 'flex',
alignItems: 'stretch',
height: ROW_HEIGHT,
flexShrink: 0,
width: '100%',
background: THEME.bgPrimary,
color: THEME.fontPrimary,
textDecoration: 'none',
boxSizing: 'border-box',
} as const,
cell: {
display: 'flex',
alignItems: 'center',
height: '100%',
padding: `0 ${CELL_PADDING_X}px`,
borderBottom: `1px solid ${THEME.borderLight}`,
borderRight: `1px solid ${THEME.borderLight}`,
fontSize: 13,
boxSizing: 'border-box',
minWidth: 0,
overflow: 'hidden',
transition: 'background-color 80ms ease',
} as const,
cellLast: {
borderRight: 'none',
} as const,
rankCell: {
width: RANK_COL_WIDTH,
flexShrink: 0,
justifyContent: 'flex-end',
color: THEME.fontTertiary,
fontSize: 12,
fontVariantNumeric: 'tabular-nums',
} as const,
contributorCell: {
flex: 1,
minWidth: 0,
gap: 6,
} as const,
countCell: {
width: COUNT_COL_WIDTH,
flexShrink: 0,
justifyContent: 'flex-end',
color: THEME.fontSecondary,
fontVariantNumeric: 'tabular-nums',
fontWeight: 500,
} as const,
avatar: {
width: 16,
height: 16,
borderRadius: '50%',
objectFit: 'cover',
background: THEME.bgTertiary,
flexShrink: 0,
} as const,
nameWrapper: {
display: 'flex',
alignItems: 'baseline',
gap: 6,
minWidth: 0,
flex: 1,
lineHeight: 1.2,
} as const,
name: {
fontSize: 13,
fontWeight: 500,
color: THEME.fontPrimary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const,
login: {
fontSize: 12,
fontWeight: 400,
color: THEME.fontTertiary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const,
empty: {
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: THEME.fontTertiary,
fontSize: 13,
padding: 16,
textAlign: 'center',
} as const,
};
type LeaderboardProps = {
contributorColumnLabel: string;
countColumnLabel: string;
entries: LeaderboardEntry[];
loading: boolean;
error: string | null;
emptyMessage?: string;
};
const renderState = (content: ReactNode) => (
<div style={styles.empty}>{content}</div>
);
const Row = ({
rank,
entry,
countLabel,
}: {
rank: number;
entry: LeaderboardEntry;
countLabel: string;
}) => {
const label = entry.name ?? entry.ghLogin ?? 'Unknown';
const profileHref = entry.ghLogin
? `https://github.com/${entry.ghLogin}`
: undefined;
const onEnter = (e: React.MouseEvent<HTMLElement>) => {
e.currentTarget.style.backgroundColor = THEME.bgSecondary;
};
const onLeave = (e: React.MouseEvent<HTMLElement>) => {
e.currentTarget.style.backgroundColor = THEME.bgPrimary;
};
const cells = (
<>
<div style={{ ...styles.cell, ...styles.rankCell }}>{rank}</div>
<div style={{ ...styles.cell, ...styles.contributorCell }}>
{entry.avatarUrl ? (
<img src={entry.avatarUrl} alt="" style={styles.avatar} />
) : (
<div style={styles.avatar} />
)}
<div style={styles.nameWrapper}>
<span style={styles.name}>{label}</span>
{entry.ghLogin && entry.ghLogin !== label && (
<span style={styles.login}>@{entry.ghLogin}</span>
)}
</div>
</div>
<div
style={{ ...styles.cell, ...styles.countCell, ...styles.cellLast }}
title={countLabel}
>
{entry.count}
</div>
</>
);
if (profileHref) {
return (
<a
href={profileHref}
target="_blank"
rel="noopener noreferrer"
style={{ ...styles.row, cursor: 'pointer' }}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
>
{cells}
</a>
);
}
return (
<div style={styles.row} onMouseEnter={onEnter} onMouseLeave={onLeave}>
{cells}
</div>
);
};
export const Leaderboard = ({
contributorColumnLabel,
countColumnLabel,
entries,
loading,
error,
emptyMessage = 'No activity in the last 90 days',
}: LeaderboardProps) => (
<div style={styles.root}>
<div style={styles.headerRow}>
<div style={{ ...styles.headerCell, ...styles.rankCell }}>#</div>
<div style={{ ...styles.headerCell, ...styles.contributorCell }}>
{contributorColumnLabel}
</div>
<div
style={{
...styles.headerCell,
...styles.countCell,
...styles.headerCellLast,
}}
>
{countColumnLabel}
</div>
</div>
{error ? (
renderState(error)
) : loading && entries.length === 0 ? (
renderState('Loading...')
) : entries.length === 0 ? (
renderState(emptyMessage)
) : (
<div style={styles.table}>
{entries.map((entry, i) => (
<Row
key={entry.id}
rank={i + 1}
entry={entry}
countLabel={countColumnLabel}
/>
))}
</div>
)}
</div>
);
@@ -1,23 +0,0 @@
export const THEME = {
fontFamily:
'var(--t-font-family, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)',
borderRadius: 'var(--t-border-radius-sm, 4px)',
fontPrimary: 'var(--t-font-color-primary)',
fontSecondary: 'var(--t-font-color-secondary)',
fontTertiary: 'var(--t-font-color-tertiary)',
fontLight: 'var(--t-font-color-light)',
borderLight: 'var(--t-border-color-light)',
borderMedium: 'var(--t-border-color-medium)',
bgPrimary: 'var(--t-background-primary)',
bgSecondary: 'var(--t-background-secondary)',
bgTertiary: 'var(--t-background-tertiary)',
bgTransparentLight: 'var(--t-background-transparent-light)',
bgTransparentLighter: 'var(--t-background-transparent-lighter)',
chartAuthored: 'var(--t-color-purple)',
chartMerged: 'var(--t-color-green)',
chartReviewed: 'var(--t-color-blue)',
} as const;
@@ -1,24 +0,0 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
import { MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/main-assignee-on-project-item.field';
export const ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'b35aed82-2c01-4f37-b3f9-cf67dc269d2c';
export default defineField({
universalIdentifier:
ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'assignedProjectItems',
label: 'Assigned Items',
icon: 'IconLayoutKanban',
relationTargetObjectMetadataUniversalIdentifier:
PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,22 +0,0 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object';
import { ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/fields/author-on-issue.field';
export const AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'31f185c0-d2a2-47e2-9132-8cbac05f174a';
export default defineField({
universalIdentifier: AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'authoredIssues',
label: 'Authored Issues',
icon: 'IconBug',
relationTargetObjectMetadataUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,23 +0,0 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object';
import { AUTHOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/author-on-pull-request.field';
export const AUTHORED_PRS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'c3d4e5f6-2b3c-4d4e-9f6a-7b8c9d0e1f2a';
export default defineField({
universalIdentifier: AUTHORED_PRS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'authoredPrs',
label: 'Authored PRs',
icon: 'IconGitPullRequest',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
AUTHOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,24 +0,0 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object';
import { MERGER_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/merger-on-pull-request.field';
export const MERGED_PULL_REQUESTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'02ec0165-fb5a-46f9-b7c7-a0fd605befa2';
export default defineField({
universalIdentifier:
MERGED_PULL_REQUESTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'mergedPullRequests',
label: 'Merged Pull Requests',
icon: 'IconGitMerge',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
MERGER_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,23 +0,0 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object';
import { REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/fields/reviewer-on-review-event.field';
export const REVIEW_EVENTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'e4f5a6b7-8c9d-4e0f-a1b2-c3d4e5f6a7b8';
export default defineField({
universalIdentifier: REVIEW_EVENTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'reviewEvents',
label: 'Review Events',
icon: 'IconEye',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,23 +0,0 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/objects/pull-request-review.object';
import { REVIEWER_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/fields/reviewer-on-review.field';
export const REVIEWS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'564e7f97-74dc-4e9b-a4bc-ed4215ab9ac7';
export default defineField({
universalIdentifier: REVIEWS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'reviews',
label: 'Reviews',
icon: 'IconEye',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
REVIEWER_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,693 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
objectMetadataItem,
useRecordId,
} from 'twenty-sdk/front-component';
import { THEME } from 'src/modules/github/contributor/components/theme';
import { callAppRoute } from 'src/modules/shared/call-app-route';
type Period = 'week' | 'month' | '3months' | 'year';
type Bucket = {
key: string;
label: string;
start: string;
end: string;
prAuthored: number;
prMerged: number;
prReviewed: number;
};
type ContributorInfo = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: string | null;
};
type StatsResponse = {
contributor: ContributorInfo;
period: Period;
granularity: 'day' | 'week' | 'month';
buckets: Bucket[];
totals: { prAuthored: number; prMerged: number; prReviewed: number };
truncated: {
prAuthored: boolean;
prMerged: boolean;
prReviewed: boolean;
};
error?: string;
};
type SearchResponse = {
contributors: ContributorInfo[];
};
const readSerializedValue = (
e: React.SyntheticEvent<HTMLElement>,
): string | undefined => {
const obj = e as { detail?: { value?: string }; value?: string };
if (typeof obj.detail?.value === 'string') return obj.detail.value;
if (typeof obj.value === 'string') return obj.value;
return undefined;
};
const onValueChange =
(fn: (value: string) => void) =>
(e: React.SyntheticEvent<HTMLElement>) => {
const v = readSerializedValue(e);
if (typeof v === 'string') fn(v);
};
const PERIOD_OPTIONS: Array<{ value: Period; label: string }> = [
{ value: 'week', label: 'Last week' },
{ value: 'month', label: 'Last month' },
{ value: '3months', label: 'Last 3 months' },
{ value: 'year', label: 'Last year' },
];
const WIDGET_HEADER_HEIGHT = 24;
const styles = {
root: {
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
minHeight: 0,
boxSizing: 'border-box',
padding: 12,
gap: 12,
fontFamily: THEME.fontFamily,
color: THEME.fontPrimary,
background: THEME.bgSecondary,
overflow: 'hidden',
} as const,
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
flexWrap: 'wrap',
padding: '8px 12px',
background: THEME.bgPrimary,
border: `1px solid ${THEME.borderLight}`,
borderRadius: THEME.borderRadius,
boxSizing: 'border-box',
flexShrink: 0,
} as const,
contributor: {
display: 'flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
} as const,
avatar: {
width: 24,
height: 24,
borderRadius: '50%',
objectFit: 'cover',
background: THEME.bgTertiary,
flexShrink: 0,
} as const,
smallAvatar: {
width: 16,
height: 16,
borderRadius: '50%',
objectFit: 'cover',
background: THEME.bgTertiary,
flexShrink: 0,
} as const,
nameCol: {
display: 'flex',
alignItems: 'baseline',
minWidth: 0,
gap: 6,
lineHeight: 1.2,
} as const,
name: {
fontSize: 14,
fontWeight: 500,
color: THEME.fontPrimary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const,
login: {
fontSize: 12,
fontWeight: 400,
color: THEME.fontTertiary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const,
controls: {
display: 'flex',
alignItems: 'center',
gap: 6,
flexWrap: 'wrap',
} as const,
select: {
fontFamily: THEME.fontFamily,
fontSize: 13,
height: 28,
lineHeight: '26px',
padding: '0 24px 0 10px',
border: `1px solid ${THEME.borderMedium}`,
borderRadius: THEME.borderRadius,
background: `${THEME.bgPrimary} url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 20 20' fill='gray'><path d='M10 13l-5-5h10z'/></svg>") no-repeat right 8px center`,
color: THEME.fontPrimary,
cursor: 'pointer',
outline: 'none',
appearance: 'none',
WebkitAppearance: 'none',
MozAppearance: 'none',
} as const,
buttonNeutral: {
fontFamily: THEME.fontFamily,
fontSize: 13,
fontWeight: 500,
height: 28,
lineHeight: '26px',
padding: '0 10px',
border: `1px solid ${THEME.borderMedium}`,
borderRadius: THEME.borderRadius,
background: THEME.bgPrimary,
color: THEME.fontPrimary,
cursor: 'pointer',
outline: 'none',
} as const,
body: {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
gap: 12,
} as const,
chartCard: {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
border: `1px solid ${THEME.borderLight}`,
borderRadius: THEME.borderRadius,
padding: 8,
background: THEME.bgPrimary,
boxSizing: 'border-box',
} as const,
chartHeader: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: WIDGET_HEADER_HEIGHT,
padding: '0 4px',
flexShrink: 0,
} as const,
chartTitle: {
fontSize: 13,
fontWeight: 500,
color: THEME.fontPrimary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const,
chartTotal: {
fontSize: 13,
fontWeight: 500,
color: THEME.fontTertiary,
fontVariantNumeric: 'tabular-nums',
flexShrink: 0,
} as const,
chartContainer: {
flex: '1 1 0%',
minHeight: 90,
width: '100%',
position: 'relative',
marginTop: 4,
} as const,
centered: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
color: THEME.fontTertiary,
fontSize: 13,
textAlign: 'center',
padding: 16,
} as const,
searchWrapper: {
display: 'flex',
flexDirection: 'column',
gap: 8,
flex: 1,
minHeight: 0,
} as const,
searchInput: {
fontFamily: THEME.fontFamily,
fontSize: 13,
height: 28,
padding: '0 10px',
border: `1px solid ${THEME.borderMedium}`,
borderRadius: THEME.borderRadius,
outline: 'none',
background: THEME.bgPrimary,
color: THEME.fontPrimary,
} as const,
searchList: {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
overflowY: 'auto',
border: `1px solid ${THEME.borderLight}`,
borderRadius: THEME.borderRadius,
background: THEME.bgPrimary,
} as const,
searchItem: {
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 10px',
cursor: 'pointer',
borderBottom: `1px solid ${THEME.borderLight}`,
fontSize: 13,
color: THEME.fontPrimary,
} as const,
truncatedHint: {
fontSize: 11,
color: THEME.fontTertiary,
marginTop: 4,
paddingLeft: 4,
} as const,
};
type BarChartProps = {
buckets: Bucket[];
valueKey: 'prAuthored' | 'prMerged' | 'prReviewed';
color: string;
};
const CHART_PADDING = { top: 6, right: 4, bottom: 16, left: 22 };
const BarChart = ({ buckets, valueKey, color }: BarChartProps) => {
const yMax = useMemo(() => {
const m = buckets.reduce((acc, b) => Math.max(acc, b[valueKey]), 0);
if (m === 0) return 1;
return m <= 5 ? m : Math.ceil(m / 5) * 5;
}, [buckets, valueKey]);
const yTicks = useMemo(() => [0, Math.round(yMax / 2), yMax], [yMax]);
const n = buckets.length;
const labelEvery = (() => {
if (n <= 6) return 1;
if (n <= 14) return 2;
return Math.ceil(n / 6);
})();
return (
<div style={styles.chartContainer}>
<div
style={{
position: 'absolute',
left: 0,
top: CHART_PADDING.top,
bottom: CHART_PADDING.bottom,
width: CHART_PADDING.left,
}}
>
{yTicks.map((t) => {
const bottomPct = (t / yMax) * 100;
return (
<div
key={t}
style={{
position: 'absolute',
right: 4,
bottom: `${bottomPct}%`,
transform: 'translateY(50%)',
fontSize: 10,
color: THEME.fontTertiary,
lineHeight: 1,
fontVariantNumeric: 'tabular-nums',
}}
>
{t}
</div>
);
})}
</div>
<div
style={{
position: 'absolute',
left: CHART_PADDING.left,
right: CHART_PADDING.right,
top: CHART_PADDING.top,
bottom: CHART_PADDING.bottom,
}}
>
{yTicks.map((t, i) => {
const bottomPct = (t / yMax) * 100;
return (
<div
key={t}
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: `${bottomPct}%`,
borderTop: `1px ${i === 0 ? 'solid' : 'dotted'} ${THEME.borderLight}`,
pointerEvents: 'none',
}}
/>
);
})}
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'flex-end',
gap: 2,
}}
>
{buckets.map((b, i) => {
const v = b[valueKey];
const heightPct = (v / yMax) * 100;
const showLabel = i % labelEvery === 0 || i === n - 1;
return (
<div
key={b.key}
title={`${b.label}: ${v}`}
style={{
flex: '1 1 0',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-end',
position: 'relative',
minWidth: 0,
}}
>
<div
style={{
width: '70%',
maxWidth: 22,
height: `${heightPct}%`,
minHeight: v > 0 ? 2 : 0,
background: color,
borderRadius: '2px 2px 0 0',
}}
/>
{showLabel && (
<div
style={{
position: 'absolute',
top: '100%',
marginTop: 4,
fontSize: 10,
color: THEME.fontTertiary,
whiteSpace: 'nowrap',
lineHeight: 1,
pointerEvents: 'none',
fontVariantNumeric: 'tabular-nums',
}}
>
{b.label}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
};
const ContributorStats = () => {
const recordId = useRecordId();
const [period, setPeriod] = useState<Period>('month');
const [selectedId, setSelectedId] = useState<string | null>(recordId);
const [stats, setStats] = useState<StatsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResponse['contributors']>([]);
const [searchLoading, setSearchLoading] = useState(false);
useEffect(() => {
setSelectedId(recordId);
}, [recordId]);
useEffect(() => {
if (selectedId) return;
let cancelled = false;
setSearchLoading(true);
const handle = setTimeout(async () => {
try {
const res = (await callAppRoute('/contributors/search', {
query: searchQuery,
limit: 20,
})) as SearchResponse;
if (!cancelled) setSearchResults(res.contributors ?? []);
} catch (err) {
if (!cancelled) {
enqueueSnackbar({
message:
err instanceof Error ? err.message : 'Failed to search contributors',
variant: 'error',
});
}
} finally {
if (!cancelled) setSearchLoading(false);
}
}, 200);
return () => {
cancelled = true;
clearTimeout(handle);
};
}, [selectedId, searchQuery]);
useEffect(() => {
if (!selectedId) {
setStats(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
(async () => {
try {
const res = (await callAppRoute('/contributors/stats', {
contributorId: selectedId,
period,
})) as StatsResponse;
if (cancelled) return;
if (res.error) {
setError(res.error);
setStats(null);
} else {
setStats(res);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load stats');
setStats(null);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedId, period]);
const renderHeader = (contributor: ContributorInfo | null) => (
<div style={styles.header}>
<div style={styles.contributor}>
{contributor?.avatarUrl ? (
<img src={contributor.avatarUrl} alt="" style={styles.avatar} />
) : (
<div style={styles.avatar} />
)}
<div style={styles.nameCol}>
<span style={styles.name}>
{contributor?.name ?? contributor?.ghLogin ?? 'Contributor stats'}
</span>
{contributor?.ghLogin &&
contributor.ghLogin !== contributor.name && (
<span style={styles.login}>@{contributor.ghLogin}</span>
)}
</div>
</div>
<div style={styles.controls}>
{selectedId && !recordId && (
<button
type="button"
onClick={() => setSelectedId(null)}
style={styles.buttonNeutral}
>
Change
</button>
)}
<select
value={period}
onChange={onValueChange((v) => setPeriod(v as Period))}
style={styles.select}
>
{PERIOD_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
);
if (!selectedId) {
return (
<div style={styles.root}>
{renderHeader(null)}
<div style={styles.searchWrapper}>
<input
value={searchQuery}
onInput={onValueChange(setSearchQuery)}
onChange={onValueChange(setSearchQuery)}
placeholder="Search a contributor by name or GitHub login..."
style={styles.searchInput}
/>
<div style={styles.searchList}>
{searchLoading && searchResults.length === 0 && (
<div style={{ ...styles.searchItem, color: THEME.fontTertiary }}>
Searching...
</div>
)}
{!searchLoading && searchResults.length === 0 && (
<div style={{ ...styles.searchItem, color: THEME.fontTertiary }}>
No contributors found.
</div>
)}
{searchResults.map((eng) => (
<div
key={eng.id}
role="button"
tabIndex={0}
onClick={() => setSelectedId(eng.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') setSelectedId(eng.id);
}}
style={styles.searchItem}
>
{eng.avatarUrl ? (
<img src={eng.avatarUrl} alt="" style={styles.smallAvatar} />
) : (
<div style={styles.smallAvatar} />
)}
<div style={styles.nameCol}>
<span style={styles.name}>
{eng.name ?? eng.ghLogin ?? 'Unknown'}
</span>
{eng.ghLogin && eng.ghLogin !== eng.name && (
<span style={styles.login}>@{eng.ghLogin}</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
return (
<div style={styles.root}>
{renderHeader(stats?.contributor ?? null)}
<div style={styles.body}>
{loading && !stats && (
<div style={styles.centered}>Loading contributor stats...</div>
)}
{error && <div style={styles.centered}>{error}</div>}
{stats && (
<>
<div style={styles.chartCard}>
<div style={styles.chartHeader}>
<span style={styles.chartTitle}>PRs authored (merged)</span>
<span style={styles.chartTotal}>{stats.totals.prAuthored}</span>
</div>
<BarChart
buckets={stats.buckets}
valueKey="prAuthored"
color={THEME.chartAuthored}
/>
{stats.truncated.prAuthored && (
<span style={styles.truncatedHint}>
Showing partial data (results truncated).
</span>
)}
</div>
<div style={styles.chartCard}>
<div style={styles.chartHeader}>
<span style={styles.chartTitle}>PRs merged</span>
<span style={styles.chartTotal}>{stats.totals.prMerged}</span>
</div>
<BarChart
buckets={stats.buckets}
valueKey="prMerged"
color={THEME.chartMerged}
/>
{stats.truncated.prMerged && (
<span style={styles.truncatedHint}>
Showing partial data (results truncated).
</span>
)}
</div>
<div style={styles.chartCard}>
<div style={styles.chartHeader}>
<span style={styles.chartTitle}>PRs reviewed</span>
<span style={styles.chartTotal}>{stats.totals.prReviewed}</span>
</div>
<BarChart
buckets={stats.buckets}
valueKey="prReviewed"
color={THEME.chartReviewed}
/>
{stats.truncated.prReviewed && (
<span style={styles.truncatedHint}>
Showing partial data (results truncated).
</span>
)}
</div>
</>
)}
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: '6c2f1a8d-4b9e-4f7a-9c5d-1e8b2a3d4c5f',
name: 'Contributor Stats',
description:
'Displays time-bucketed charts of PRs authored (merged only), merged and reviewed by a contributor over the last week, month, 3 months or year.',
component: ContributorStats,
command: {
universalIdentifier: '7d3f2b9e-5c0a-4e8b-ad6e-2f9c3b4d5e6a',
label: 'Contributor Stats',
icon: 'IconChartBar',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'contributor',
},
});
@@ -1,106 +0,0 @@
import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
objectMetadataItem,
unmountFrontComponent,
updateProgress,
} from 'twenty-sdk/front-component';
import { callAppRoute } from 'src/modules/shared/call-app-route';
type CountResponse = {
totalPages: number;
repos: Array<{ owner: string; repo: string; totalCount: number; pages: number }>;
};
type FetchPageResponse = {
contributorCount: number;
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
type SyncStatus = 'syncing' | 'done' | 'error';
const FetchContributors = () => {
const [status, setStatus] = useState<SyncStatus>('syncing');
useEffect(() => {
const run = async () => {
try {
const counts = (await callAppRoute(
'/github/count-contributors',
{},
)) as CountResponse;
if (counts.repos.length === 0) {
throw new Error(
'No repos resolved. Set GITHUB_REPOS in the application variables.',
);
}
const totalPages = Math.max(counts.totalPages, 1);
let pagesProcessed = 0;
let totalSynced = 0;
for (const { owner, repo } of counts.repos) {
let cursor: string | null = null;
let hasMore = true;
while (hasMore) {
const data = (await callAppRoute('/github/fetch-contributors', {
owner,
repo,
cursor,
})) as FetchPageResponse;
totalSynced += data.contributorCount;
hasMore = data.hasMore && data.contributorCount > 0;
cursor = data.endCursor;
pagesProcessed++;
updateProgress(
Math.min(Math.round((pagesProcessed / totalPages) * 100), 99),
);
}
}
updateProgress(100);
enqueueSnackbar({
message: `Synced ${totalSynced} contributors`,
variant: 'success',
});
setStatus('done');
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to fetch contributors';
enqueueSnackbar({ message, variant: 'error' });
setStatus('error');
} finally {
unmountFrontComponent();
}
};
run();
}, []);
if (status === 'syncing') return <div>Fetching contributors...</div>;
if (status === 'error') return <div>Failed to fetch contributors.</div>;
return <div>Done</div>;
};
export default defineFrontComponent({
universalIdentifier: '08f40f82-24ed-4f3e-8c99-695151e90e38',
name: 'Fetch Contributors',
description:
'Fetches contributors from every configured GitHub repo (GITHUB_REPOS) and upserts them as Contributor records.',
isHeadless: true,
component: FetchContributors,
command: {
universalIdentifier: '4640992f-c2c9-4bba-b5df-9c8f05dc9e80',
label: 'Fetch Contributors',
icon: 'IconUsers',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'contributor',
},
});
@@ -1,66 +0,0 @@
import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import {
Leaderboard,
type LeaderboardEntry,
} from 'src/modules/github/contributor/components/leaderboard';
import { callAppRoute } from 'src/modules/shared/call-app-route';
type Response = {
topAuthors: LeaderboardEntry[];
};
const TopPRAuthors = () => {
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
(async () => {
try {
const res = (await callAppRoute('/contributors/top', {
days: 90,
limit: 20,
kind: 'authors',
})) as Response;
if (!cancelled) setEntries(res.topAuthors ?? []);
} catch (err) {
if (!cancelled) {
const message =
err instanceof Error ? err.message : 'Failed to load top authors';
setError(message);
enqueueSnackbar({ message, variant: 'error' });
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<Leaderboard
contributorColumnLabel="Contributor"
countColumnLabel="PRs"
entries={entries}
loading={loading}
error={error}
/>
);
};
export const TOP_PR_AUTHORS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'a1d4f7e2-9b3c-4e8a-bf21-5d6c8a9b2e3f';
export default defineFrontComponent({
universalIdentifier: TOP_PR_AUTHORS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Top PR Authors',
description: 'Leaderboard of the top 20 pull-request authors over the last 90 days.',
component: TopPRAuthors,
});
@@ -1,66 +0,0 @@
import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import {
Leaderboard,
type LeaderboardEntry,
} from 'src/modules/github/contributor/components/leaderboard';
import { callAppRoute } from 'src/modules/shared/call-app-route';
type Response = {
topReviewers: LeaderboardEntry[];
};
const TopReviewers = () => {
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
(async () => {
try {
const res = (await callAppRoute('/contributors/top', {
days: 90,
limit: 20,
kind: 'reviewers',
})) as Response;
if (!cancelled) setEntries(res.topReviewers ?? []);
} catch (err) {
if (!cancelled) {
const message =
err instanceof Error ? err.message : 'Failed to load top reviewers';
setError(message);
enqueueSnackbar({ message, variant: 'error' });
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<Leaderboard
contributorColumnLabel="Contributor"
countColumnLabel="Reviews"
entries={entries}
loading={loading}
error={error}
/>
);
};
export const TOP_REVIEWERS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'b2e5f8a3-ac4d-4f9b-8032-6e7d9bac3f4a';
export default defineFrontComponent({
universalIdentifier: TOP_REVIEWERS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Top Reviewers',
description: 'Leaderboard of the top 20 pull-request reviewers over the last 90 days.',
component: TopReviewers,
});
@@ -1,20 +0,0 @@
import { githubGraphql } from 'src/modules/github/connector/github-client';
const QUERY = `
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
mentionableUsers { totalCount }
}
}`;
type Response = {
repository: { mentionableUsers: { totalCount: number } } | null;
};
export async function countContributors(
owner: string,
name: string,
): Promise<number> {
const data = await githubGraphql<Response>(QUERY, { owner, name });
return data.repository?.mentionableUsers.totalCount ?? 0;
}
@@ -1,52 +0,0 @@
import {
EMPTY_PAGE,
type GithubPage,
githubGraphql,
} from 'src/modules/github/connector/github-client';
const QUERY = `
query($owner: String!, $name: String!, $cursor: String) {
repository(owner: $owner, name: $name) {
mentionableUsers(first: 100, after: $cursor) {
totalCount
pageInfo { hasNextPage endCursor }
nodes {
login
databaseId
avatarUrl
}
}
}
}`;
export type GqlContributor = {
login: string;
databaseId: number;
avatarUrl: string;
};
type Response = {
repository: { mentionableUsers: GithubPage<GqlContributor> } | null;
};
export async function fetchContributors(
owner: string,
name: string,
cursor: string | null = null,
): Promise<{
contributors: GqlContributor[];
totalCount: number;
hasMore: boolean;
endCursor: string | null;
}> {
const data = await githubGraphql<Response>(QUERY, { owner, name, cursor });
const conn = data.repository?.mentionableUsers;
if (!conn) return { contributors: [], ...EMPTY_PAGE };
return {
contributors: conn.nodes,
totalCount: conn.totalCount,
hasMore: conn.pageInfo.hasNextPage,
endCursor: conn.pageInfo.endCursor,
};
}
@@ -1,21 +0,0 @@
import type { ContributorRow } from 'src/modules/github/contributor/types/contributor-row';
import { chunkedBatchCreate } from 'src/modules/shared/twenty-client';
export async function batchUpsertContributors(
items: Array<{
ghLogin: string;
name: string;
githubId: number;
avatarUrl?: { primaryLinkLabel: string; primaryLinkUrl: string; secondaryLinks: null } | null;
contributions?: number;
}>,
): Promise<ContributorRow[]> {
return chunkedBatchCreate('createContributors', items, {
id: true,
ghLogin: true,
name: true,
githubId: true,
avatarUrl: true,
contributions: true,
}) as Promise<ContributorRow[]>;
}
@@ -1,27 +0,0 @@
import type { ContributorRow } from 'src/modules/github/contributor/types/contributor-row';
import { getClient } from 'src/modules/shared/twenty-client';
export async function findContributorByGhLogin(
ghLogin: string,
): Promise<ContributorRow | null> {
const client = getClient();
const res = await client.query({
contributors: {
__args: {
filter: { ghLogin: { eq: ghLogin } },
first: 1,
},
edges: {
node: {
id: true,
ghLogin: true,
name: true,
githubId: true,
},
},
},
});
const edges = res.contributors?.edges;
return (edges?.[0]?.node as ContributorRow | undefined) ?? null;
}
@@ -1,52 +0,0 @@
import { getClient } from 'src/modules/shared/twenty-client';
export type ContributorSearchResult = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: string | null;
};
export async function searchContributors(
query: string,
limit: number,
): Promise<ContributorSearchResult[]> {
const client = getClient();
const filter =
query.length === 0
? undefined
: {
or: [
{ name: { ilike: `%${query}%` } },
{ ghLogin: { ilike: `%${query}%` } },
],
};
const res = await client.query({
contributors: {
__args: {
...(filter ? { filter } : {}),
orderBy: [{ name: 'AscNullsLast' }],
first: limit,
},
edges: {
node: {
id: true,
name: true,
ghLogin: true,
avatarUrl: { primaryLinkUrl: true },
},
},
},
});
const edges = res.contributors?.edges ?? [];
return edges.map((e) => ({
id: e.node.id,
name: e.node.name ?? null,
ghLogin: e.node.ghLogin ?? null,
avatarUrl: e.node.avatarUrl?.primaryLinkUrl ?? null,
}));
}
@@ -1,376 +0,0 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { getClient } from 'src/modules/shared/twenty-client';
export type StatsPeriod = 'week' | 'month' | '3months' | 'year';
type Granularity = 'day' | 'week' | 'month';
type Bucket = {
key: string;
label: string;
start: string;
end: string;
prAuthored: number;
prMerged: number;
prReviewed: number;
};
type Totals = {
prAuthored: number;
prMerged: number;
prReviewed: number;
};
type ContributorInfo = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: string | null;
};
type ContributorStatsPayload = {
contributorId?: string;
period?: StatsPeriod;
};
type ContributorStatsResponse =
| {
contributor: ContributorInfo;
period: StatsPeriod;
granularity: Granularity;
buckets: Bucket[];
totals: Totals;
truncated: {
prAuthored: boolean;
prMerged: boolean;
prReviewed: boolean;
};
}
| { error: string };
const PAGE_SIZE = 100;
const MAX_PAGES = 30;
const PERIOD_CONFIG: Record<
StatsPeriod,
{ granularity: Granularity; rangeMs: number; bucketCount: number }
> = {
week: { granularity: 'day', rangeMs: 7 * 24 * 3600 * 1000, bucketCount: 7 },
month: { granularity: 'day', rangeMs: 30 * 24 * 3600 * 1000, bucketCount: 30 },
'3months': {
granularity: 'week',
rangeMs: 13 * 7 * 24 * 3600 * 1000,
bucketCount: 13,
},
year: {
granularity: 'month',
rangeMs: 365 * 24 * 3600 * 1000,
bucketCount: 12,
},
};
const startOfUtcDay = (d: Date): Date =>
new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
const startOfUtcWeek = (d: Date): Date => {
const day = startOfUtcDay(d);
const dow = day.getUTCDay();
const diff = (dow + 6) % 7;
day.setUTCDate(day.getUTCDate() - diff);
return day;
};
const startOfUtcMonth = (d: Date): Date =>
new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
const addBucket = (start: Date, granularity: Granularity, n: number): Date => {
const out = new Date(start);
if (granularity === 'day') out.setUTCDate(out.getUTCDate() + n);
else if (granularity === 'week') out.setUTCDate(out.getUTCDate() + 7 * n);
else out.setUTCMonth(out.getUTCMonth() + n);
return out;
};
const bucketStartFor = (d: Date, granularity: Granularity): Date => {
if (granularity === 'day') return startOfUtcDay(d);
if (granularity === 'week') return startOfUtcWeek(d);
return startOfUtcMonth(d);
};
const formatBucketLabel = (start: Date, granularity: Granularity): string => {
const month = start.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
if (granularity === 'month') {
return `${month} ${String(start.getUTCFullYear()).slice(2)}`;
}
return `${month} ${start.getUTCDate()}`;
};
const buildBuckets = (
now: Date,
period: StatsPeriod,
): { buckets: Bucket[]; rangeStart: Date; granularity: Granularity } => {
const { granularity, bucketCount } = PERIOD_CONFIG[period];
const lastBucketStart = bucketStartFor(now, granularity);
const firstBucketStart = addBucket(
lastBucketStart,
granularity,
-(bucketCount - 1),
);
const buckets: Bucket[] = [];
for (let i = 0; i < bucketCount; i++) {
const start = addBucket(firstBucketStart, granularity, i);
const end = addBucket(start, granularity, 1);
buckets.push({
key: start.toISOString(),
label: formatBucketLabel(start, granularity),
start: start.toISOString(),
end: end.toISOString(),
prAuthored: 0,
prMerged: 0,
prReviewed: 0,
});
}
return { buckets, rangeStart: firstBucketStart, granularity };
};
type Edge<T> = { node: T };
type Connection<T> = {
edges: Edge<T>[];
pageInfo: { hasNextPage: boolean; endCursor: string | null };
};
async function paginateUntil<T>(
fetchPage: (cursor: string | null) => Promise<Connection<T>>,
isOlderThanRange: (item: T) => boolean,
): Promise<{ items: T[]; truncated: boolean }> {
const items: T[] = [];
let cursor: string | null = null;
for (let page = 0; page < MAX_PAGES; page++) {
const conn = await fetchPage(cursor);
let stop = false;
for (const edge of conn.edges) {
if (isOlderThanRange(edge.node)) {
stop = true;
break;
}
items.push(edge.node);
}
if (stop) return { items, truncated: false };
if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor) {
return { items, truncated: false };
}
cursor = conn.pageInfo.endCursor;
}
return { items, truncated: true };
}
type MergedPrNode = { mergedAt: string | null };
type ReviewNode = { firstSubmittedAt: string | null };
const fetchContributorInfo = async (
contributorId: string,
): Promise<ContributorInfo | null> => {
const client = getClient();
const res = await client.query({
contributors: {
__args: { filter: { id: { eq: contributorId } }, first: 1 },
edges: {
node: {
id: true,
name: true,
ghLogin: true,
avatarUrl: { primaryLinkUrl: true },
},
},
},
});
const node = res.contributors?.edges?.[0]?.node;
if (!node) return null;
return {
id: node.id,
name: node.name ?? null,
ghLogin: node.ghLogin ?? null,
avatarUrl: node.avatarUrl?.primaryLinkUrl ?? null,
};
};
const handler = async (
event: RoutePayload<ContributorStatsPayload>,
): Promise<ContributorStatsResponse> => {
const contributorId = event.body?.contributorId;
const period: StatsPeriod = event.body?.period ?? 'month';
if (!contributorId) {
return { error: 'contributorId is required' };
}
if (!(period in PERIOD_CONFIG)) {
return { error: `Unsupported period: ${period}` };
}
const contributor = await fetchContributorInfo(contributorId);
if (!contributor) {
return { error: 'Contributor not found' };
}
const now = new Date();
const { buckets, rangeStart, granularity } = buildBuckets(now, period);
const rangeStartMs = rangeStart.getTime();
const bucketIndex = new Map<string, number>();
buckets.forEach((b, i) => bucketIndex.set(b.key, i));
const client = getClient();
const mergedResult = await paginateUntil<MergedPrNode>(
async (cursor) => {
const res = await client.query({
pullRequests: {
__args: {
filter: {
and: [
{ mergerId: { eq: contributorId } },
{ state: { eq: 'MERGED' } },
],
},
orderBy: [{ mergedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: { node: { mergedAt: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequests as Connection<MergedPrNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.mergedAt) return false;
return new Date(n.mergedAt).getTime() < rangeStartMs;
},
);
const authoredResult = await paginateUntil<MergedPrNode>(
async (cursor) => {
const res = await client.query({
pullRequests: {
__args: {
filter: {
and: [
{ authorId: { eq: contributorId } },
{ state: { eq: 'MERGED' } },
],
},
orderBy: [{ mergedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: { node: { mergedAt: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequests as Connection<MergedPrNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.mergedAt) return false;
return new Date(n.mergedAt).getTime() < rangeStartMs;
},
);
const reviewedResult = await paginateUntil<ReviewNode>(
async (cursor) => {
const res = await client.query({
pullRequestReviews: {
__args: {
filter: { reviewerId: { eq: contributorId } },
orderBy: [{ firstSubmittedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: { node: { firstSubmittedAt: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequestReviews as Connection<ReviewNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.firstSubmittedAt) return false;
return new Date(n.firstSubmittedAt).getTime() < rangeStartMs;
},
);
const totals: Totals = { prAuthored: 0, prMerged: 0, prReviewed: 0 };
for (const pr of authoredResult.items) {
if (!pr.mergedAt) continue;
const d = new Date(pr.mergedAt);
if (d.getTime() < rangeStartMs) continue;
const key = bucketStartFor(d, granularity).toISOString();
const idx = bucketIndex.get(key);
if (idx === undefined) continue;
buckets[idx].prAuthored++;
totals.prAuthored++;
}
for (const pr of mergedResult.items) {
if (!pr.mergedAt) continue;
const d = new Date(pr.mergedAt);
if (d.getTime() < rangeStartMs) continue;
const key = bucketStartFor(d, granularity).toISOString();
const idx = bucketIndex.get(key);
if (idx === undefined) continue;
buckets[idx].prMerged++;
totals.prMerged++;
}
for (const r of reviewedResult.items) {
if (!r.firstSubmittedAt) continue;
const d = new Date(r.firstSubmittedAt);
if (d.getTime() < rangeStartMs) continue;
const key = bucketStartFor(d, granularity).toISOString();
const idx = bucketIndex.get(key);
if (idx === undefined) continue;
buckets[idx].prReviewed++;
totals.prReviewed++;
}
return {
contributor,
period,
granularity,
buckets,
totals,
truncated: {
prAuthored: authoredResult.truncated,
prMerged: mergedResult.truncated,
prReviewed: reviewedResult.truncated,
},
};
};
export default defineLogicFunction({
universalIdentifier: 'a3c9e1b6-2f47-4d8a-9b0f-7e6d1a2c3b4f',
name: 'contributor-stats',
description:
'Returns time-bucketed counts of PRs authored (merged only), merged and reviewed by a contributor over the selected period.',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/contributors/stats',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -1,28 +0,0 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { countAcrossRepos } from 'src/modules/github/connector/count-across-repos';
import { countContributors } from 'src/modules/github/contributor/graphql/github/count-contributors';
type CountContributorsPayload = {
repos?: string[];
};
const handler = async (event: RoutePayload<CountContributorsPayload>) =>
countAcrossRepos(
event.body?.repos,
countContributors,
'count-contributors',
);
export default defineLogicFunction({
universalIdentifier: 'fe0a6f00-0d63-4cb9-9b3c-1d8186181830',
name: 'count-contributors',
description:
'Counts contributors across configured repos and returns the per-repo page split.',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/github/count-contributors',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -1,70 +0,0 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import {
fetchContributors,
type GqlContributor,
} from 'src/modules/github/contributor/graphql/github/fetch-contributors';
import { batchUpsertContributors } from 'src/modules/github/contributor/graphql/mutations/batch-upsert';
import { isFixtureAllowed } from 'src/modules/shared/fixtures';
export type FetchContributorsFixturePage = {
contributors: GqlContributor[];
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
type FetchContributorsPayload = {
owner: string;
repo: string;
cursor?: string | null;
fixturePage?: FetchContributorsFixturePage;
};
const handler = async (event: RoutePayload<FetchContributorsPayload>) => {
const { owner, repo, cursor = null, fixturePage } = event.body ?? {};
if (!owner || !repo) {
return { error: 'owner and repo are required' };
}
const result =
fixturePage && isFixtureAllowed()
? fixturePage
: await fetchContributors(owner, repo, cursor);
const { contributors, totalCount, hasMore, endCursor } = result;
if (contributors.length === 0) {
return { contributorCount: 0, totalCount, hasMore: false, endCursor: null };
}
const contributorData = contributors.map((c: GqlContributor) => ({
ghLogin: c.login,
name: c.login,
githubId: c.databaseId ?? 0,
avatarUrl: c.avatarUrl
? { primaryLinkLabel: c.login, primaryLinkUrl: c.avatarUrl, secondaryLinks: null }
: null,
}));
await batchUpsertContributors(contributorData);
return {
contributorCount: contributors.length,
totalCount,
hasMore,
endCursor,
};
};
export default defineLogicFunction({
universalIdentifier: 'ed9ba981-4172-4924-a18f-51fc6dcbcb23',
name: 'fetch-contributors',
description:
'Fetches one page of contributors via GitHub GraphQL API and batch upserts them into the workspace.',
timeoutSeconds: 300,
handler,
httpRouteTriggerSettings: {
path: '/github/fetch-contributors',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -1,43 +0,0 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import {
searchContributors,
type ContributorSearchResult,
} from 'src/modules/github/contributor/graphql/queries/search-contributors';
type SearchContributorsPayload = {
query?: string;
limit?: number;
};
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 50;
const handler = async (
event: RoutePayload<SearchContributorsPayload>,
): Promise<{ contributors: ContributorSearchResult[] }> => {
const queryInput = event.body?.query;
const rawQuery = typeof queryInput === 'string' ? queryInput.trim() : '';
const limitInput = event.body?.limit;
const limitNumber =
typeof limitInput === 'number' && Number.isFinite(limitInput)
? limitInput
: DEFAULT_LIMIT;
const limit = Math.min(Math.max(Math.floor(limitNumber), 1), MAX_LIMIT);
const contributors = await searchContributors(rawQuery, limit);
return { contributors };
};
export default defineLogicFunction({
universalIdentifier: 'b4d8f2a7-3e58-4f9b-ac1d-8f7e2b3c4d5e',
name: 'search-contributors',
description:
'Searches contributors by name or GitHub login for use in front-end pickers.',
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/contributors/search',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -1,257 +0,0 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { isBotLogin } from 'src/modules/github/contributor/utils/is-bot-login';
import { getClient } from 'src/modules/shared/twenty-client';
type ContributorRef = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: string | null;
};
type LeaderboardEntry = ContributorRef & {
count: number;
};
type TopContributorsKind = 'authors' | 'reviewers' | 'both';
type TopContributorsPayload = {
days?: number;
limit?: number;
kind?: TopContributorsKind;
};
type TopContributorsResponse = {
days: number;
limit: number;
topAuthors: LeaderboardEntry[];
topReviewers: LeaderboardEntry[];
truncated: { authors: boolean; reviewers: boolean };
};
const PAGE_SIZE = 100;
const MAX_PAGES = 50;
const DEFAULT_DAYS = 90;
const DEFAULT_LIMIT = 10;
const MAX_LIMIT = 50;
const MAX_DAYS = 365;
type Edge<T> = { node: T };
type Connection<T> = {
edges: Edge<T>[];
pageInfo: { hasNextPage: boolean; endCursor: string | null };
};
async function paginateUntil<T>(
fetchPage: (cursor: string | null) => Promise<Connection<T>>,
isOlderThanRange: (item: T) => boolean,
): Promise<{ items: T[]; truncated: boolean }> {
const items: T[] = [];
let cursor: string | null = null;
for (let page = 0; page < MAX_PAGES; page++) {
const conn = await fetchPage(cursor);
let stop = false;
for (const edge of conn.edges) {
if (isOlderThanRange(edge.node)) {
stop = true;
break;
}
items.push(edge.node);
}
if (stop) return { items, truncated: false };
if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor) {
return { items, truncated: false };
}
cursor = conn.pageInfo.endCursor;
}
return { items, truncated: true };
}
type AuthorInfo = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: { primaryLinkUrl: string | null } | null;
};
type PrNode = {
githubCreatedAt: string | null;
author: AuthorInfo | null;
};
type ReviewNode = {
firstSubmittedAt: string | null;
reviewer: AuthorInfo | null;
};
const tally = (
items: { contributor: AuthorInfo | null }[],
limit: number,
): LeaderboardEntry[] => {
const counts = new Map<string, LeaderboardEntry>();
for (const item of items) {
const c = item.contributor;
if (!c) continue;
if (isBotLogin(c.ghLogin)) continue;
const existing = counts.get(c.id);
if (existing) {
existing.count += 1;
} else {
counts.set(c.id, {
id: c.id,
name: c.name ?? null,
ghLogin: c.ghLogin ?? null,
avatarUrl: c.avatarUrl?.primaryLinkUrl ?? null,
count: 1,
});
}
}
return Array.from(counts.values())
.sort((a, b) => b.count - a.count || (a.ghLogin ?? '').localeCompare(b.ghLogin ?? ''))
.slice(0, limit);
};
const handler = async (
event: RoutePayload<TopContributorsPayload>,
): Promise<TopContributorsResponse> => {
const daysInput = event.body?.days;
const limitInput = event.body?.limit;
const days = Math.min(
Math.max(
Math.floor(
typeof daysInput === 'number' && Number.isFinite(daysInput)
? daysInput
: DEFAULT_DAYS,
),
1,
),
MAX_DAYS,
);
const limit = Math.min(
Math.max(
Math.floor(
typeof limitInput === 'number' && Number.isFinite(limitInput)
? limitInput
: DEFAULT_LIMIT,
),
1,
),
MAX_LIMIT,
);
const kindInput = event.body?.kind;
const kind: TopContributorsKind =
kindInput === 'authors' || kindInput === 'reviewers' ? kindInput : 'both';
const sinceMs = Date.now() - days * 24 * 3600 * 1000;
const client = getClient();
const authoredResult: { items: PrNode[]; truncated: boolean } =
kind === 'reviewers' ? { items: [], truncated: false } : await paginateUntil<PrNode>(
async (cursor) => {
const res = await client.query({
pullRequests: {
__args: {
orderBy: [{ githubCreatedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: {
node: {
githubCreatedAt: true,
author: {
id: true,
name: true,
ghLogin: true,
avatarUrl: { primaryLinkUrl: true },
},
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequests as Connection<PrNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.githubCreatedAt) return false;
return new Date(n.githubCreatedAt).getTime() < sinceMs;
},
);
const reviewedResult: { items: ReviewNode[]; truncated: boolean } =
kind === 'authors' ? { items: [], truncated: false } : await paginateUntil<ReviewNode>(
async (cursor) => {
const res = await client.query({
pullRequestReviews: {
__args: {
orderBy: [{ firstSubmittedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: {
node: {
firstSubmittedAt: true,
reviewer: {
id: true,
name: true,
ghLogin: true,
avatarUrl: { primaryLinkUrl: true },
},
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequestReviews as Connection<ReviewNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.firstSubmittedAt) return false;
return new Date(n.firstSubmittedAt).getTime() < sinceMs;
},
);
const topAuthors = tally(
authoredResult.items.map((pr) => ({ contributor: pr.author })),
limit,
);
const topReviewers = tally(
reviewedResult.items.map((r) => ({ contributor: r.reviewer })),
limit,
);
return {
days,
limit,
topAuthors,
topReviewers,
truncated: {
authors: authoredResult.truncated,
reviewers: reviewedResult.truncated,
},
};
};
export default defineLogicFunction({
universalIdentifier: 'd5b9c4a2-7e3f-4a1b-9c8d-2f4e6a7b8c9d',
name: 'top-contributors',
description:
'Returns the top contributors by pull-request authorship and review counts over a configurable time window.',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/contributors/top',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -1,47 +0,0 @@
import { type LinksFieldValue, toLinksField } from 'src/modules/shared/types';
export type GhUser = {
login: string;
id?: number;
databaseId?: number;
avatarUrl?: string | null;
avatar_url?: string | null;
};
export type ContributorCanonical = {
ghLogin: string;
name: string;
githubId: number;
avatarUrl: LinksFieldValue | null;
};
export function contributorFromGhUser(user: GhUser): ContributorCanonical {
const avatarUrl = user.avatarUrl ?? user.avatar_url ?? null;
return {
ghLogin: user.login,
name: user.login,
githubId: user.id ?? user.databaseId ?? 0,
avatarUrl: avatarUrl ? toLinksField(avatarUrl, user.login) : null,
};
}
export function dedupeContributors(
users: Array<GhUser | null | undefined>,
): ContributorCanonical[] {
const seen = new Map<string, ContributorCanonical>();
for (const u of users) {
if (!u || !u.login) continue;
const existing = seen.get(u.login);
if (!existing) {
seen.set(u.login, contributorFromGhUser(u));
continue;
}
if (!existing.avatarUrl) {
const avatarUrl = u.avatarUrl ?? u.avatar_url ?? null;
if (avatarUrl) {
existing.avatarUrl = toLinksField(avatarUrl, u.login);
}
}
}
return [...seen.values()];
}
@@ -1,72 +0,0 @@
import { defineObject, FieldType } from 'twenty-sdk/define';
export const CONTRIBUTOR_UNIVERSAL_IDENTIFIER =
'8e9464f0-fdc9-487a-9963-c7accac0d4bb';
export const CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'e2e77e0e-5300-44ce-ab1c-9ad0593b5d1a';
export const CONTRIBUTOR_GH_LOGIN_FIELD_UNIVERSAL_IDENTIFIER =
'a1d3c7b2-4e5f-4a8b-9c6d-2e1f0b3a4c5d';
export const CONTRIBUTOR_GITHUB_ID_FIELD_UNIVERSAL_IDENTIFIER =
'f7a8b9c0-1d2e-4f3a-8b5c-6d7e8f9a0b1c';
export const CONTRIBUTOR_AVATAR_URL_FIELD_UNIVERSAL_IDENTIFIER =
'450a612c-f7a3-4bba-aa36-e22339eb0720';
export const CONTRIBUTOR_CONTRIBUTIONS_FIELD_UNIVERSAL_IDENTIFIER =
'66f73e49-6edb-48fa-812b-c3e684ef6340';
export const CONTRIBUTOR_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'bb8d2a87-b41c-578b-98de-2e37dba12a14';
export default defineObject({
universalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
nameSingular: 'contributor',
namePlural: 'contributors',
labelSingular: 'Contributor',
labelPlural: 'Contributors',
icon: 'IconUsers',
labelIdentifierFieldMetadataUniversalIdentifier:
CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER,
name: 'name',
type: FieldType.TEXT,
label: 'Name',
icon: 'IconUser',
},
{
universalIdentifier: CONTRIBUTOR_GH_LOGIN_FIELD_UNIVERSAL_IDENTIFIER,
name: 'ghLogin',
type: FieldType.TEXT,
label: 'GitHub Login',
icon: 'IconBrandGithub',
isUnique: true,
},
{
universalIdentifier: CONTRIBUTOR_GITHUB_ID_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubId',
type: FieldType.NUMBER,
label: 'GitHub ID',
icon: 'IconHash',
},
{
universalIdentifier: CONTRIBUTOR_AVATAR_URL_FIELD_UNIVERSAL_IDENTIFIER,
name: 'avatarUrl',
type: FieldType.LINKS,
label: 'Avatar',
icon: 'IconPhoto',
},
{
universalIdentifier: CONTRIBUTOR_CONTRIBUTIONS_FIELD_UNIVERSAL_IDENTIFIER,
name: 'contributions',
type: FieldType.NUMBER,
label: 'Contributions',
icon: 'IconTrendingUp',
defaultValue: 0,
},
],
});
@@ -1,10 +0,0 @@
import type { LinksFieldValue } from 'src/modules/shared/types';
export type ContributorRow = {
id: string;
ghLogin?: string | null;
name?: string | null;
githubId?: number | null;
avatarUrl?: LinksFieldValue | null;
contributions?: number | null;
};
@@ -1,30 +0,0 @@
const BOT_LOGIN_SUFFIX = '[bot]';
const KNOWN_BOT_LOGINS = new Set<string>([
'github-actions',
'dependabot',
'dependabot-preview',
'renovate',
'renovate-bot',
'cubic-dev-ai',
'greptile-apps',
'sentry',
'sentry-io',
'codecov',
'codecov-commenter',
'snyk-bot',
'mergify',
'allcontributors',
'imgbot',
'pre-commit-ci',
'semantic-release-bot',
'stale',
'web-flow',
]);
export function isBotLogin(login: string | null | undefined): boolean {
if (!login) return false;
const lower = login.toLowerCase();
if (lower.endsWith(BOT_LOGIN_SUFFIX)) return true;
return KNOWN_BOT_LOGINS.has(lower);
}
@@ -1,51 +0,0 @@
import {
defineView,
ViewKey,
ViewSortDirection,
ViewType,
} from 'twenty-sdk/define';
import {
CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER,
CONTRIBUTOR_GH_LOGIN_FIELD_UNIVERSAL_IDENTIFIER,
CONTRIBUTOR_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/contributor/objects/contributor.object';
export const ALL_CONTRIBUTORS_VIEW_UNIVERSAL_IDENTIFIER =
'e2b51f8e-97ea-49e0-b38d-a69d2191236f';
export default defineView({
universalIdentifier: ALL_CONTRIBUTORS_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All Contributors',
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconUsers',
key: ViewKey.INDEX,
position: 0,
fields: [
{
universalIdentifier: 'b53cb9dd-0e2e-415e-81af-80bedbed89bc',
fieldMetadataUniversalIdentifier:
CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 200,
},
{
universalIdentifier: '604a2f1d-ef20-4821-8742-fe3b5b83ffcf',
fieldMetadataUniversalIdentifier:
CONTRIBUTOR_GH_LOGIN_FIELD_UNIVERSAL_IDENTIFIER,
position: 1,
isVisible: true,
size: 150,
},
],
sorts: [
{
universalIdentifier: 'c0c54264-8de9-418b-b91a-cc4a2b94c539',
fieldMetadataUniversalIdentifier:
CONTRIBUTOR_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
direction: ViewSortDirection.DESC,
},
],
});
@@ -1,25 +0,0 @@
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';
import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/fields/authored-issues-on-contributor.field';
export const ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER =
'5ff7ab7f-bcfa-4e7a-9a33-0860ec692736';
export default defineField({
universalIdentifier: ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'author',
label: 'Author',
icon: 'IconUser',
relationTargetObjectMetadataUniversalIdentifier:
CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'authorId',
},
});
@@ -1,23 +0,0 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object';
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
import { LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/linked-issue-on-project-item.field';
export const PROJECT_ITEMS_ON_ISSUE_FIELD_UNIVERSAL_IDENTIFIER =
'8b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e';
export default defineField({
universalIdentifier: PROJECT_ITEMS_ON_ISSUE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'projectItems',
label: 'Project Items',
icon: 'IconLayoutKanban',
relationTargetObjectMetadataUniversalIdentifier:
PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -1,109 +0,0 @@
import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
objectMetadataItem,
unmountFrontComponent,
updateProgress,
} from 'twenty-sdk/front-component';
import { callAppRoute } from 'src/modules/shared/call-app-route';
import { retry } from 'src/modules/shared/retry';
type CountResponse = {
totalPages: number;
repos: Array<{ owner: string; repo: string; totalCount: number; pages: number }>;
};
type FetchPageResponse = {
issueCount: number;
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
type SyncStatus = 'syncing' | 'done' | 'error';
const FetchIssues = () => {
const [status, setStatus] = useState<SyncStatus>('syncing');
useEffect(() => {
const run = async () => {
try {
const counts = (await callAppRoute(
'/github/count-issues',
{},
)) as CountResponse;
if (counts.repos.length === 0) {
throw new Error(
'No repos resolved. Set GITHUB_REPOS in the application variables.',
);
}
const totalPages = Math.max(counts.totalPages, 1);
let pagesProcessed = 0;
let totalIssues = 0;
for (const { owner, repo } of counts.repos) {
let cursor: string | null = null;
let hasMore = true;
while (hasMore) {
const cursorTag = cursor ? `@${cursor.slice(0, 8)}` : '';
const data = (await retry(
`fetch-issues ${owner}/${repo}${cursorTag}`,
() =>
callAppRoute('/github/fetch-issues', {
owner,
repo,
cursor,
}),
)) as FetchPageResponse;
totalIssues += data.issueCount;
hasMore = data.hasMore && data.issueCount > 0;
cursor = data.endCursor;
pagesProcessed++;
updateProgress(Math.min(Math.round((pagesProcessed / totalPages) * 100), 99));
}
}
updateProgress(100);
enqueueSnackbar({
message: `Fetched ${totalIssues} issues`,
variant: 'success',
});
setStatus('done');
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to fetch issues';
enqueueSnackbar({ message, variant: 'error' });
setStatus('error');
} finally {
unmountFrontComponent();
}
};
run();
}, []);
if (status === 'syncing') return <div>Fetching issues...</div>;
if (status === 'error') return <div>Failed to fetch issues.</div>;
return <div>Done</div>;
};
export default defineFrontComponent({
universalIdentifier: '9430e4fc-9ecb-428d-9bde-2babeb1f452f',
name: 'Fetch Issues',
description: 'Fetches issues from GitHub repos',
isHeadless: true,
component: FetchIssues,
command: {
universalIdentifier: 'c34f56aa-ff65-43a4-9db2-774945dbcc53',
label: 'Fetch Issues',
icon: 'IconBug',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'issue',
},
});
@@ -1,26 +0,0 @@
import { githubGraphql } from 'src/modules/github/connector/github-client';
const QUERY = `
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
issues(states: [OPEN, CLOSED]) {
totalCount
}
}
}`;
type Response = {
repository: { issues: { totalCount: number } } | null;
};
export async function countIssues(
owner: string,
name: string,
): Promise<number> {
try {
const data = await githubGraphql<Response>(QUERY, { owner, name });
return data.repository?.issues.totalCount ?? 0;
} catch {
return 0;
}
}
@@ -1,66 +0,0 @@
import {
EMPTY_PAGE,
type GithubPage,
githubGraphql,
} from 'src/modules/github/connector/github-client';
const QUERY = `
query($owner: String!, $name: String!, $cursor: String) {
repository(owner: $owner, name: $name) {
issues(first: 100, states: [OPEN, CLOSED], orderBy: {field: CREATED_AT, direction: DESC}, after: $cursor) {
totalCount
pageInfo { hasNextPage endCursor }
nodes {
number
title
url
state
createdAt
closedAt
author { login avatarUrl ... on User { databaseId } }
labels(first: 50) { nodes { name } }
}
}
}
}`;
export type GqlIssue = {
number: number;
title: string;
url: string;
state: 'OPEN' | 'CLOSED';
createdAt: string;
closedAt: string | null;
author: { login: string; avatarUrl?: string | null; databaseId?: number } | null;
labels: { nodes: Array<{ name: string }> };
};
type Response = {
repository: { issues: GithubPage<GqlIssue> } | null;
};
export async function fetchIssues(
owner: string,
name: string,
cursor: string | null = null,
): Promise<{
issues: GqlIssue[];
totalCount: number;
hasMore: boolean;
endCursor: string | null;
}> {
try {
const data = await githubGraphql<Response>(QUERY, { owner, name, cursor });
const conn = data.repository?.issues;
if (!conn) return { issues: [], ...EMPTY_PAGE };
return {
issues: conn.nodes,
totalCount: conn.totalCount,
hasMore: conn.pageInfo.hasNextPage,
endCursor: conn.pageInfo.endCursor,
};
} catch {
return { issues: [], ...EMPTY_PAGE };
}
}
@@ -1,27 +0,0 @@
import { chunkedBatchCreate } from 'src/modules/shared/twenty-client';
import type { LinksFieldValue } from 'src/modules/shared/types';
import type { IssueRow } from 'src/modules/github/issue/types/issue-row';
export async function batchUpsertIssues(
items: Array<{
title: string;
githubNumber: number;
uniqueIdentifier: string;
githubUrl: LinksFieldValue;
state: string;
labels: string[];
githubCreatedAt: string | null;
closedAt: string | null;
repo: string;
authorId: string | null;
}>,
): Promise<IssueRow[]> {
return chunkedBatchCreate('createIssues', items, {
id: true,
githubNumber: true,
uniqueIdentifier: true,
title: true,
state: true,
repo: true,
}) as Promise<IssueRow[]>;
}
@@ -1,40 +0,0 @@
import { getClient } from 'src/modules/shared/twenty-client';
import type { IssueRow } from 'src/modules/github/issue/types/issue-row';
export async function findIssueByNumberAndRepo(
githubNumber: number,
repo: string,
): Promise<IssueRow | null> {
const client = getClient();
const res = await client.query({
issues: {
__args: {
filter: {
and: [
{ githubNumber: { eq: githubNumber } },
{ repo: { eq: repo } },
],
},
first: 1,
},
edges: {
node: {
id: true,
title: true,
githubNumber: true,
uniqueIdentifier: true,
githubUrl: { primaryLinkLabel: true, primaryLinkUrl: true },
state: true,
labels: true,
githubCreatedAt: true,
closedAt: true,
repo: true,
authorId: true,
},
},
},
});
const node = res.issues?.edges?.[0]?.node;
return (node as IssueRow | undefined) ?? null;
}
@@ -1,24 +0,0 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { countAcrossRepos } from 'src/modules/github/connector/count-across-repos';
import { countIssues } from 'src/modules/github/issue/graphql/github/count-issues';
type CountIssuesPayload = {
repos?: string[];
};
const handler = async (event: RoutePayload<CountIssuesPayload>) =>
countAcrossRepos(event.body?.repos, countIssues, 'count-issues');
export default defineLogicFunction({
universalIdentifier: 'd8cc32bf-6be9-44fc-920a-8bba510f045f',
name: 'count-issues',
description:
'Counts total issue pages across configured repos using GraphQL totalCount',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/github/count-issues',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -1,93 +0,0 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import {
fetchIssues,
type GqlIssue,
} from 'src/modules/github/issue/graphql/github/fetch-issues';
import { batchUpsertContributors } from 'src/modules/github/contributor/graphql/mutations/batch-upsert';
import { batchUpsertIssues } from 'src/modules/github/issue/graphql/mutations/batch-upsert';
import { dedupeContributors } from 'src/modules/github/contributor/normalizers';
import { issueFromGraphql } from 'src/modules/github/issue/normalizers';
import { timed } from 'src/modules/shared/timing';
import { isFixtureAllowed } from 'src/modules/shared/fixtures';
export type FetchIssuesFixturePage = {
issues: GqlIssue[];
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
type FetchIssuesPayload = {
owner: string;
repo: string;
cursor?: string | null;
fixturePage?: FetchIssuesFixturePage;
};
const handler = async (event: RoutePayload<FetchIssuesPayload>) => {
const handlerStart = Date.now();
const { owner, repo, cursor = null, fixturePage } = event.body ?? {};
if (!owner || !repo) {
return { error: 'owner and repo are required' };
}
const tag = `${owner}/${repo}${cursor ? `@${cursor.slice(0, 8)}` : ''}`;
console.log(`[fetch-issues] start ${tag}${fixturePage ? ' (fixture)' : ''}`);
const result =
fixturePage && isFixtureAllowed()
? fixturePage
: await timed(`fetch-issues:github ${tag}`, () =>
fetchIssues(owner, repo, cursor),
);
const { issues, totalCount, hasMore, endCursor } = result;
if (issues.length === 0) {
console.log(`[fetch-issues] empty page for ${tag}`);
return { issueCount: 0, totalCount, hasMore: false, endCursor: null };
}
const fullRepo = `${owner}/${repo}`;
const contributorInputs = dedupeContributors(issues.map((i) => i.author));
const contributors = await timed(
`fetch-issues:upsertContributors ${tag} (${contributorInputs.length})`,
() => batchUpsertContributors(contributorInputs),
);
const idByLogin = new Map<string, string>();
for (const c of contributors) {
if (c.ghLogin) idByLogin.set(c.ghLogin, c.id);
}
const issueData = issues.map((issue) => ({
...issueFromGraphql(issue, fullRepo),
authorId: issue.author ? (idByLogin.get(issue.author.login) ?? null) : null,
}));
await timed(
`fetch-issues:upsertIssues ${tag} (${issueData.length})`,
() => batchUpsertIssues(issueData),
);
const totalMs = Date.now() - handlerStart;
console.log(
`[fetch-issues] done ${tag} in ${totalMs}ms (issues=${issues.length}, hasMore=${hasMore})`,
);
return { issueCount: issues.length, totalCount, hasMore, endCursor };
};
export default defineLogicFunction({
universalIdentifier: 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e',
name: 'fetch-issues',
description:
'Fetches one page of issues via GitHub GraphQL API and batch upserts them',
timeoutSeconds: 300,
handler,
httpRouteTriggerSettings: {
path: '/github/fetch-issues',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -1,11 +0,0 @@
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object';
import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: '7c4f8e1a-3b9d-4f2e-8a6c-1d5b7e9f3a2c',
position: 1,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER,
});
@@ -1,63 +0,0 @@
import type { LinksFieldValue } from 'src/modules/shared/types';
import { toLinksField } from 'src/modules/shared/types';
import type { GitHubIssue } from 'src/modules/github/issue/types/github-issue';
import type { GqlIssue } from 'src/modules/github/issue/graphql/github/fetch-issues';
export type IssueState = 'OPEN' | 'CLOSED';
export type IssueCanonical = {
title: string;
githubNumber: number;
uniqueIdentifier: string;
githubUrl: LinksFieldValue;
state: IssueState;
labels: string[];
githubCreatedAt: string | null;
closedAt: string | null;
repo: string;
};
export function deriveIssueState(state: string): IssueState {
return state.toUpperCase() === 'CLOSED' ? 'CLOSED' : 'OPEN';
}
export function buildIssueUniqueIdentifier(
repoFullName: string,
number: number,
): string {
return `${repoFullName}#${number}`;
}
export function issueFromWebhook(
issue: GitHubIssue,
repoFullName: string,
): IssueCanonical {
return {
title: issue.title,
githubNumber: issue.number,
uniqueIdentifier: buildIssueUniqueIdentifier(repoFullName, issue.number),
githubUrl: toLinksField(issue.html_url, issue.title),
state: deriveIssueState(issue.state),
labels: issue.labels.map((l) => l.name),
githubCreatedAt: issue.created_at,
closedAt: issue.closed_at,
repo: repoFullName,
};
}
export function issueFromGraphql(
issue: GqlIssue,
repoFullName: string,
): IssueCanonical {
return {
title: issue.title,
githubNumber: issue.number,
uniqueIdentifier: buildIssueUniqueIdentifier(repoFullName, issue.number),
githubUrl: toLinksField(issue.url, issue.title),
state: deriveIssueState(issue.state),
labels: issue.labels.nodes.map((l) => l.name),
githubCreatedAt: issue.createdAt,
closedAt: issue.closedAt,
repo: repoFullName,
};
}
@@ -1,134 +0,0 @@
import { defineObject, FieldType } from 'twenty-sdk/define';
export const ISSUE_UNIVERSAL_IDENTIFIER =
'3a4b5c6d-7e8f-4a9b-8c0d-1e2f3a4b5c6d';
export const ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER =
'4b5c6d7e-8f9a-4b0c-9d1e-2f3a4b5c6d7e';
export const ISSUE_GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER =
'5c6d7e8f-9a0b-4c1d-ae2f-3a4b5c6d7e8f';
export const ISSUE_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER =
'6d7e8f9a-0b1c-4d2e-bf3a-4b5c6d7e8f9a';
export const ISSUE_LABELS_FIELD_UNIVERSAL_IDENTIFIER =
'dfd35d92-f4a3-44c5-b758-494c0882fcc8';
export const ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'76364df9-6f5c-477d-bd99-97b6738df3bf';
export const ISSUE_CLOSED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'48ac555b-73b7-4ce9-a264-251c8e06b4e9';
export const ISSUE_REPO_FIELD_UNIVERSAL_IDENTIFIER =
'79c41cdc-912a-4e4d-b9de-3f2421bcb2fb';
export const ISSUE_UNIQUE_IDENTIFIER_FIELD_UNIVERSAL_IDENTIFIER =
'0ca04207-4d74-4298-8238-59cdf685862b';
enum IssueState {
OPEN = 'OPEN',
CLOSED = 'CLOSED',
}
export const ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER =
'1c2d3e4f-5a6b-4c7d-a48e-9f0a1b2c3d4e';
export const ISSUE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'440e9ba8-20d1-5843-91ef-3b7b699a59f4';
export default defineObject({
universalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
nameSingular: 'issue',
namePlural: 'issues',
labelSingular: 'Issue',
labelPlural: 'Issues',
icon: 'IconBug',
labelIdentifierFieldMetadataUniversalIdentifier:
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
name: 'title',
type: FieldType.TEXT,
label: 'Title',
icon: 'IconTextCaption',
},
{
universalIdentifier: ISSUE_GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubNumber',
type: FieldType.NUMBER,
label: 'Issue Number',
icon: 'IconHash',
},
{
universalIdentifier: ISSUE_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubUrl',
type: FieldType.LINKS,
label: 'GitHub URL',
icon: 'IconLink',
},
{
universalIdentifier: ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER,
name: 'state',
type: FieldType.SELECT,
label: 'State',
icon: 'IconCircleDot',
options: [
{
value: IssueState.OPEN,
label: 'Open',
position: 0,
color: 'green',
},
{
value: IssueState.CLOSED,
label: 'Closed',
position: 1,
color: 'red',
},
],
},
{
universalIdentifier: ISSUE_LABELS_FIELD_UNIVERSAL_IDENTIFIER,
name: 'labels',
type: FieldType.ARRAY,
label: 'Labels',
icon: 'IconTag',
},
{
universalIdentifier: ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubCreatedAt',
type: FieldType.DATE_TIME,
label: 'GitHub Created At',
icon: 'IconCalendarPlus',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: ISSUE_CLOSED_AT_FIELD_UNIVERSAL_IDENTIFIER,
name: 'closedAt',
type: FieldType.DATE_TIME,
label: 'Closed At',
icon: 'IconCalendarOff',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: ISSUE_REPO_FIELD_UNIVERSAL_IDENTIFIER,
name: 'repo',
type: FieldType.TEXT,
label: 'Repository',
icon: 'IconFolder',
},
{
universalIdentifier: ISSUE_UNIQUE_IDENTIFIER_FIELD_UNIVERSAL_IDENTIFIER,
name: 'uniqueIdentifier',
type: FieldType.TEXT,
label: 'Unique Identifier',
icon: 'IconFingerprint',
isUnique: true,
},
],
});
@@ -1,13 +0,0 @@
import type { GitHubUser } from 'src/modules/github/connector/github-user';
export type GitHubIssue = {
number: number;
title: string;
html_url: string;
state: 'open' | 'closed';
labels: Array<{ name: string }>;
created_at: string;
closed_at: string | null;
user: GitHubUser;
pull_request?: unknown;
};
@@ -1,15 +0,0 @@
import type { LinksFieldValue } from 'src/modules/shared/types';
export type IssueRow = {
id: string;
title?: string | null;
githubNumber?: number | null;
uniqueIdentifier?: string | null;
githubUrl?: LinksFieldValue | null;
state?: string | null;
labels?: string[] | null;
githubCreatedAt?: string | null;
closedAt?: string | null;
repo?: string | null;
authorId?: string | null;
};
@@ -1,87 +0,0 @@
import {
defineView,
ViewKey,
ViewSortDirection,
ViewType,
} from 'twenty-sdk/define';
import {
ISSUE_UNIVERSAL_IDENTIFIER,
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_LABELS_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_REPO_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/issue/objects/issue.object';
import { ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/fields/author-on-issue.field';
export const ALL_ISSUES_VIEW_UNIVERSAL_IDENTIFIER =
'b2c3d4e5-1111-4f6a-b7c8-d9e0f1a2b3c4';
export default defineView({
universalIdentifier: ALL_ISSUES_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All Issues',
objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconBug',
key: ViewKey.INDEX,
position: 0,
fields: [
{
universalIdentifier: '5b115bd1-489c-44e5-ac1b-7d508e06f165',
fieldMetadataUniversalIdentifier:
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 300,
},
{
universalIdentifier: '77ac1882-3a13-43e3-822e-c9f5f7d83a67',
fieldMetadataUniversalIdentifier:
ISSUE_GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER,
position: 1,
isVisible: true,
size: 100,
},
{
universalIdentifier: '4f0d17f2-c3a6-4431-a928-3812273592e2',
fieldMetadataUniversalIdentifier:
ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER,
position: 2,
isVisible: true,
size: 100,
},
{
universalIdentifier: '01673163-ccfa-4370-a1c3-48c33b6c4aa3',
fieldMetadataUniversalIdentifier:
ISSUE_LABELS_FIELD_UNIVERSAL_IDENTIFIER,
position: 3,
isVisible: true,
size: 200,
},
{
universalIdentifier: '6d932c97-8f92-4d93-968f-29ca34b27ac0',
fieldMetadataUniversalIdentifier:
ISSUE_REPO_FIELD_UNIVERSAL_IDENTIFIER,
position: 4,
isVisible: true,
size: 180,
},
{
universalIdentifier: 'dc293fd6-747c-4291-9426-5d7b53234936',
fieldMetadataUniversalIdentifier:
ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER,
position: 5,
isVisible: true,
size: 150,
},
],
sorts: [
{
universalIdentifier: '8e972333-b919-4c1b-ad7b-4fab8f018253',
fieldMetadataUniversalIdentifier:
ISSUE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
direction: ViewSortDirection.DESC,
},
],
});
@@ -1,260 +0,0 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import type { GitHubWebhookPayload } from 'src/modules/github/connector/webhook-payload';
import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item';
import { fetchProjectItemByNodeId } from 'src/modules/github/project-item/graphql/github/fetch-project-item-by-node-id';
import {
getRawBodyForSignature,
verifyGitHubSignature,
} from 'src/modules/github/connector/webhook-signature';
import { batchUpsertContributors } from 'src/modules/github/contributor/graphql/mutations/batch-upsert';
import { batchUpsertPullRequests } from 'src/modules/github/pull-request/graphql/mutations/batch-upsert';
import { batchUpsertReviewEvents } from 'src/modules/github/pull-request-review-event/graphql/mutations/batch-upsert';
import { batchUpsertConsolidatedReviews } from 'src/modules/github/pull-request-review/graphql/mutations/batch-upsert';
import { findReviewEventsForPair } from 'src/modules/github/pull-request-review-event/graphql/queries/find-events-for-pair';
import { buildConsolidatedRow } from 'src/modules/github/pull-request-review/utils/build-consolidated-row';
import type { ReviewEventState } from 'src/modules/github/pull-request-review/utils/consolidate-reviews';
import { batchUpsertIssues } from 'src/modules/github/issue/graphql/mutations/batch-upsert';
import { batchUpsertProjectItems } from 'src/modules/github/project-item/graphql/mutations/batch-upsert';
import { dedupeContributors } from 'src/modules/github/contributor/normalizers';
import { pullRequestFromWebhook } from 'src/modules/github/pull-request/normalizers';
import { reviewEventFromWebhook } from 'src/modules/github/pull-request-review-event/normalizers';
import { issueFromWebhook } from 'src/modules/github/issue/normalizers';
import { projectItemFromGraphql } from 'src/modules/github/project-item/normalizers';
async function upsertContributorsByLogin(
users: Array<{ login: string; id?: number } | null | undefined>,
): Promise<Map<string, string>> {
const inputs = dedupeContributors(users);
if (inputs.length === 0) return new Map();
const rows = await batchUpsertContributors(inputs);
const map = new Map<string, string>();
for (const r of rows) {
if (r.ghLogin) map.set(r.ghLogin, r.id);
}
return map;
}
async function handlePullRequestEvent(payload: GitHubWebhookPayload) {
const pr = payload.pull_request;
const repository = payload.repository;
if (!pr || !repository) {
return { skipped: true, reason: 'missing pull_request or repository' };
}
const idByLogin = await upsertContributorsByLogin([pr.user, pr.merged_by]);
const authorId = idByLogin.get(pr.user.login) ?? null;
const mergerId = pr.merged_by ? (idByLogin.get(pr.merged_by.login) ?? null) : null;
const canonical = pullRequestFromWebhook(pr, repository.full_name);
const [record] = await batchUpsertPullRequests([
{ ...canonical, authorId, mergerId },
]);
return {
processed: true,
pullRequestId: record?.id,
action: payload.action,
state: canonical.state,
};
}
async function handlePullRequestReviewEvent(payload: GitHubWebhookPayload) {
const review = payload.review;
const pr = payload.pull_request;
const repository = payload.repository;
if (!review || !pr || !repository) {
return {
skipped: true,
reason: 'missing review, pull_request, or repository',
};
}
const idByLogin = await upsertContributorsByLogin([
pr.user,
pr.merged_by,
review.user,
]);
const prCanonical = pullRequestFromWebhook(pr, repository.full_name);
const [prRecord] = await batchUpsertPullRequests([
{
...prCanonical,
authorId: idByLogin.get(pr.user.login) ?? null,
mergerId: pr.merged_by ? (idByLogin.get(pr.merged_by.login) ?? null) : null,
},
]);
const reviewCanonical = reviewEventFromWebhook(review);
const reviewerId = idByLogin.get(review.user.login) ?? null;
const pullRequestId = prRecord?.id ?? '';
const [eventRecord] = await batchUpsertReviewEvents([
{
...reviewCanonical,
reviewerId,
pullRequestId,
},
]);
let consolidatedId: string | undefined;
if (pullRequestId) {
const events = await findReviewEventsForPair(pullRequestId, reviewerId);
if (events.length > 0) {
const consolidatedRow = buildConsolidatedRow({
pullRequestId,
reviewerId,
prNumber: pr.number ?? null,
reviewerLogin: review.user.login,
events: events.map((e) => ({
state: e.state as ReviewEventState,
submittedAt: e.submittedAt,
})),
});
const [consolidatedRecord] = await batchUpsertConsolidatedReviews([
consolidatedRow,
]);
consolidatedId = consolidatedRecord?.id;
if (consolidatedId && eventRecord?.id) {
await batchUpsertReviewEvents([
{
...reviewCanonical,
reviewerId,
pullRequestId,
reviewId: consolidatedId,
},
]);
}
}
}
return {
processed: true,
reviewEventId: eventRecord?.id,
reviewId: consolidatedId,
pullRequestId: prRecord?.id,
action: payload.action,
};
}
async function handleIssueEvent(payload: GitHubWebhookPayload) {
const issue = payload.issue;
const repository = payload.repository;
if (!issue || !repository) {
return { skipped: true, reason: 'missing issue or repository' };
}
const idByLogin = await upsertContributorsByLogin([issue.user]);
const canonical = issueFromWebhook(issue, repository.full_name);
const [record] = await batchUpsertIssues([
{ ...canonical, authorId: idByLogin.get(issue.user.login) ?? null },
]);
return {
processed: true,
issueId: record?.id,
action: payload.action,
};
}
async function handleProjectV2ItemEvent(
payload: GitHubWebhookPayload,
testProjectItem?: ProjectV2Item,
) {
const item = payload.projects_v2_item;
if (!item?.node_id) {
return { skipped: true, reason: 'missing projects_v2_item.node_id' };
}
if (payload.action === 'deleted') {
console.log(
`[handle-webhook] projects_v2_item delete skipped (nodeId=${item.node_id})`,
);
return { skipped: true, reason: 'delete not implemented' };
}
const node = testProjectItem ?? (await fetchProjectItemByNodeId(item.node_id));
if (!node) {
return { skipped: true, reason: 'project item not found on GitHub' };
}
const canonical = await projectItemFromGraphql(node);
const [record] = await batchUpsertProjectItems([canonical]);
return {
processed: true,
projectItemId: record?.id,
action: payload.action,
};
}
const handler = async (
event: RoutePayload<
GitHubWebhookPayload & { __test_projectItem?: ProjectV2Item }
>,
) => {
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (secret) {
const signatureHeader =
event.headers?.['x-hub-signature-256'] ??
event.headers?.['X-Hub-Signature-256'];
const rawBody = getRawBodyForSignature(event);
const verification = verifyGitHubSignature({
rawBody,
signatureHeader,
secret,
});
if (!verification.ok) {
const delivery =
event.headers?.['x-github-delivery'] ??
event.headers?.['X-GitHub-Delivery'];
console.warn(
`[handle-webhook] signature verification failed (${verification.reason}) delivery=${delivery ?? 'unknown'}`,
);
return { error: 'invalid signature', reason: verification.reason };
}
} else {
console.warn(
'[handle-webhook] GITHUB_WEBHOOK_SECRET is not set; skipping signature verification (insecure)',
);
}
const payload = event.body;
if (!payload) return { error: 'empty body' };
if (payload.pull_request && payload.review) {
return handlePullRequestReviewEvent(payload);
}
if (payload.pull_request) {
return handlePullRequestEvent(payload);
}
if (payload.issue) {
return handleIssueEvent(payload);
}
if (payload.projects_v2_item) {
return handleProjectV2ItemEvent(payload, payload.__test_projectItem);
}
return { skipped: true, reason: 'unhandled event' };
};
export default defineLogicFunction({
universalIdentifier: '22b199b3-2851-4a4f-99fd-4e79c188fe7d',
name: 'handle-github-webhook',
description:
'Receives GitHub webhook events for PRs, reviews, issues, and project items; idempotent upserts via shared batch helpers',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/github/webhook',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: [
'x-hub-signature-256',
'x-github-event',
'x-github-delivery',
],
},
});
@@ -1,14 +0,0 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: 'd1c2b3a4-9e8f-4d3c-b2a1-0f9e8d7c6b5a',
position: 3,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER,
});
@@ -1,17 +0,0 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import { GITHUB_DASHBOARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/page-layouts/github-dashboard.page-layout';
import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: 'e8f9a1b2-c3d4-4567-89ab-cdef01234567',
name: 'GitHub Dashboard',
icon: 'IconBrandGithub',
position: 6,
type: NavigationMenuItemType.PAGE_LAYOUT,
pageLayoutUniversalIdentifier:
GITHUB_DASHBOARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER,
});
@@ -1,13 +0,0 @@
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
export const GITHUB_FOLDER_UNIVERSAL_IDENTIFIER =
'a4f7d8b1-2c93-4e6f-8b1a-9d3e5c7f2a48';
export default defineNavigationMenuItem({
universalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER,
name: 'GitHub',
icon: 'IconBrandGithub',
color: 'gray',
position: 0,
type: NavigationMenuItemType.FOLDER,
});
@@ -1,281 +0,0 @@
import {
AggregateOperations,
definePageLayout,
ObjectRecordGroupByDateGranularity,
PageLayoutTabLayoutMode,
} from 'twenty-sdk/define';
import { TOP_PR_AUTHORS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/front-components/top-pr-authors.front-component';
import { TOP_REVIEWERS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/front-components/top-reviewers.front-component';
import {
ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/issue/objects/issue.object';
import {
MERGED_AT_FIELD_UNIVERSAL_IDENTIFIER,
PR_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
PR_STATE_FIELD_UNIVERSAL_IDENTIFIER,
PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
PULL_REQUEST_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/pull-request/objects/pull-request.object';
import {
PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/pull-request-review/objects/pull-request-review.object';
export const GITHUB_DASHBOARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
'a8d2f1c4-7b69-4e3a-8c5d-9f6b1a3e7c2d';
const THIS_WEEK_RELATIVE = 'THIS_1_WEEK;;UTC;;SUNDAY;;';
const COMMON = {
timezone: 'UTC',
firstDayOfTheWeek: 0,
};
const COL_1 = { column: 0, columnSpan: 3 } as const;
const COL_2 = { column: 3, columnSpan: 5 } as const;
const COL_3 = { column: 8, columnSpan: 4 } as const;
export default definePageLayout({
universalIdentifier: GITHUB_DASHBOARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'GitHub Dashboard',
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier: 'b3e9c2a1-4d68-4f7b-9c25-1e3a5b7c9d11',
title: 'GitHub Dashboard',
position: 0,
icon: 'IconBrandGithub',
layoutMode: PageLayoutTabLayoutMode.GRID,
widgets: [
{
universalIdentifier: 'f1a2b3c4-d5e6-4789-a0b1-c2d3e4f5a6b1',
title: 'PRs Merged This Week',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 0, ...COL_1, rowSpan: 3 },
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
label: 'PRs Merged',
color: 'green',
displayDataLabel: false,
...COMMON,
filter: {
recordFilters: [
{
fieldMetadataUniversalIdentifier:
MERGED_AT_FIELD_UNIVERSAL_IDENTIFIER,
operand: 'IS_RELATIVE',
value: THIS_WEEK_RELATIVE,
type: 'DATE_TIME',
},
],
},
},
},
{
universalIdentifier: 'f1a2b3c4-d5e6-4789-a0b1-c2d3e4f5a6b2',
title: 'PR Reviews This Week',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 3, ...COL_1, rowSpan: 3 },
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
label: 'Reviews',
color: 'blue',
displayDataLabel: false,
...COMMON,
filter: {
recordFilters: [
{
fieldMetadataUniversalIdentifier:
REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
operand: 'IS_RELATIVE',
value: THIS_WEEK_RELATIVE,
type: 'DATE_TIME',
},
],
},
},
},
{
universalIdentifier: '8d4e9d09-29b0-4925-8fcc-aedf5bcecb37',
title: 'PRs Opened This Week',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 6, ...COL_1, rowSpan: 3 },
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
label: 'PRs Opened',
color: 'purple',
displayDataLabel: false,
...COMMON,
filter: {
recordFilters: [
{
fieldMetadataUniversalIdentifier:
PR_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
operand: 'IS_RELATIVE',
value: THIS_WEEK_RELATIVE,
type: 'DATE_TIME',
},
],
},
},
},
{
universalIdentifier: '3c321867-0227-4535-81db-c64a6f673d86',
title: 'Issues Opened This Week',
type: 'GRAPH',
objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 9, ...COL_1, rowSpan: 3 },
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
label: 'Issues Opened',
color: 'orange',
displayDataLabel: false,
...COMMON,
filter: {
recordFilters: [
{
fieldMetadataUniversalIdentifier:
ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
operand: 'IS_RELATIVE',
value: THIS_WEEK_RELATIVE,
type: 'DATE_TIME',
},
],
},
},
},
{
universalIdentifier: '704209be-9174-43a8-ae51-6b11fff20e22',
title: 'PRs by State',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 12, ...COL_1, rowSpan: 6 },
configuration: {
configurationType: 'PIE_CHART',
aggregateFieldMetadataUniversalIdentifier:
PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataUniversalIdentifier:
PR_STATE_FIELD_UNIVERSAL_IDENTIFIER,
displayLegend: true,
showCenterMetric: true,
hideEmptyCategory: true,
...COMMON,
},
},
{
universalIdentifier: 'c1f2a3b4-d5e6-4789-a0b1-c2d3e4f5a6b7',
title: 'PRs Merged per Week',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 0, ...COL_2, rowSpan: 6 },
configuration: {
configurationType: 'BAR_CHART',
aggregateFieldMetadataUniversalIdentifier:
PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
MERGED_AT_FIELD_UNIVERSAL_IDENTIFIER,
primaryAxisDateGranularity:
ObjectRecordGroupByDateGranularity.WEEK,
displayDataLabel: false,
displayLegend: false,
color: 'green',
layout: 'VERTICAL',
omitNullValues: true,
...COMMON,
},
},
{
universalIdentifier: 'd2e3f4a5-b6c7-4890-a1b2-c3d4e5f6a7b8',
title: 'PR Reviews per Week',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 6, ...COL_2, rowSpan: 6 },
configuration: {
configurationType: 'BAR_CHART',
aggregateFieldMetadataUniversalIdentifier:
REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
primaryAxisDateGranularity:
ObjectRecordGroupByDateGranularity.WEEK,
displayDataLabel: false,
displayLegend: false,
color: 'blue',
layout: 'VERTICAL',
omitNullValues: true,
...COMMON,
},
},
{
universalIdentifier: '6d03cbd1-ee03-4bf4-a3ac-143d03fa7fd9',
title: 'Issues per Week',
type: 'GRAPH',
objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 12, ...COL_2, rowSpan: 6 },
configuration: {
configurationType: 'BAR_CHART',
aggregateFieldMetadataUniversalIdentifier:
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
primaryAxisDateGranularity:
ObjectRecordGroupByDateGranularity.WEEK,
secondaryAxisGroupByFieldMetadataUniversalIdentifier:
ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER,
displayDataLabel: false,
displayLegend: true,
color: 'orange',
layout: 'VERTICAL',
omitNullValues: true,
...COMMON,
},
},
{
universalIdentifier: '7b3e9c4a-1d52-4f8b-ac76-3e5b8d2f1a9c',
title: 'Top PR Authors',
type: 'FRONT_COMPONENT',
gridPosition: { row: 0, ...COL_3, rowSpan: 9 },
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
TOP_PR_AUTHORS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
{
universalIdentifier: '5e4a8c1d-7f93-4b2e-9d6c-3a8f1b5e7d4c',
title: 'Top Reviewers',
type: 'FRONT_COMPONENT',
gridPosition: { row: 9, ...COL_3, rowSpan: 9 },
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
TOP_REVIEWERS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
@@ -1,24 +0,0 @@
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object';
import { PROJECT_ITEMS_ON_ISSUE_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/fields/project-items-on-issue.field';
export const LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER =
'7a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d';
export default defineField({
universalIdentifier: LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'linkedIssue',
label: 'Linked Issue',
icon: 'IconBug',
relationTargetObjectMetadataUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
PROJECT_ITEMS_ON_ISSUE_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'linkedIssueId',
},
});
@@ -1,25 +0,0 @@
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object';
import { PROJECT_ITEMS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/project-items-on-pull-request.field';
export const LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER =
'9c4d5e6f-7a8b-4c9d-ae0f-1a2b3c4d5e6f';
export default defineField({
universalIdentifier: LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'linkedPullRequest',
label: 'Linked PR',
icon: 'IconGitPullRequest',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
PROJECT_ITEMS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'linkedPullRequestId',
},
});
@@ -1,25 +0,0 @@
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/fields/assigned-project-items-on-contributor.field';
export const MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER =
'5bc9d7b3-ca5a-4006-bd74-0420d1f3df85';
export default defineField({
universalIdentifier: MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'mainAssignee',
label: 'Main Assignee',
icon: 'IconUser',
relationTargetObjectMetadataUniversalIdentifier:
CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'mainAssigneeId',
},
});
@@ -1,108 +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;
projects: Array<{
owner: string;
number: number;
totalCount: number;
pages: number;
}>;
};
type FetchPageResponse = {
itemCount: number;
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
type SyncStatus = 'syncing' | 'done' | 'error';
const FetchProjectItems = () => {
const [status, setStatus] = useState<SyncStatus>('syncing');
useEffect(() => {
const run = async () => {
try {
const counts = (await callAppRoute(
'/github/count-project-items',
{},
)) as CountResponse;
if (counts.projects.length === 0) {
throw new Error(
'No projects resolved. Set GITHUB_PROJECTS in the application variables (e.g. `twentyhq/24`).',
);
}
const totalPages = Math.max(counts.totalPages, 1);
let pagesProcessed = 0;
let totalItems = 0;
for (const { owner, number } of counts.projects) {
let cursor: string | null = null;
let hasMore = true;
while (hasMore) {
const data = (await callAppRoute('/github/fetch-project-items', {
owner,
number,
cursor,
})) as FetchPageResponse;
totalItems += data.itemCount;
hasMore = data.hasMore && data.itemCount > 0;
cursor = data.endCursor;
pagesProcessed++;
updateProgress(Math.min(Math.round((pagesProcessed / totalPages) * 100), 99));
}
}
updateProgress(100);
enqueueSnackbar({
message: `Fetched ${totalItems} project items`,
variant: 'success',
});
setStatus('done');
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to fetch project items';
enqueueSnackbar({ message, variant: 'error' });
setStatus('error');
} finally {
unmountFrontComponent();
}
};
run();
}, []);
if (status === 'syncing') return <div>Fetching project items...</div>;
if (status === 'error') return <div>Failed to fetch project items.</div>;
return <div>Done</div>;
};
export default defineFrontComponent({
universalIdentifier: '7c397b0c-8b19-4fac-924a-8f6aa1dece78',
name: 'Fetch Project Items',
description: 'Fetches project items from GitHub Projects V2',
isHeadless: true,
component: FetchProjectItems,
command: {
universalIdentifier: '719cfe1c-d570-4c8c-89e6-88671c6ba1ea',
label: 'Fetch Project Items',
icon: 'IconLayoutKanban',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'projectItem',
},
});
@@ -1,41 +0,0 @@
import { githubGraphqlOptional } from 'src/modules/github/connector/github-client';
const ORG_QUERY = `
query($owner: String!, $number: Int!) {
organization(login: $owner) {
projectV2(number: $number) {
items { totalCount }
}
}
}`;
const USER_QUERY = ORG_QUERY.replace(
'organization(login: $owner)',
'user(login: $owner)',
);
type OrgResponse = {
organization: { projectV2: { items: { totalCount: number } } | null } | null;
};
type UserResponse = {
user: { projectV2: { items: { totalCount: number } } | null } | null;
};
export async function countProjectItems(
owner: string,
projectNumber: number,
): Promise<number> {
const orgData = await githubGraphqlOptional<OrgResponse>(ORG_QUERY, {
owner,
number: projectNumber,
});
const orgCount = orgData?.organization?.projectV2?.items.totalCount;
if (typeof orgCount === 'number') return orgCount;
const userData = await githubGraphqlOptional<UserResponse>(USER_QUERY, {
owner,
number: projectNumber,
});
return userData?.user?.projectV2?.items.totalCount ?? 0;
}

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