feat(community): add github-connector example app (#19961)

## Summary

Adds a new community app at
`packages/twenty-apps/community/github-connector` that demonstrates a
complete, production-style GitHub integration built on the Twenty SDK.

It is extracted (and decoupled) from the internal `twenty-eng` workspace
so external developers can use it as a reference for their own
connectors.

What it ships:

- **Six synced objects**: `pullRequest`, `pullRequestReview`,
`pullRequestReviewEvent`, `issue`, `projectItem`, `engineer`
- **Logic functions** for periodic backfills (PRs, reviews, issues,
project items, contributors) and a single signed-webhook route trigger
(`POST /github/webhook`) that performs idempotent upserts for
`pull_request`, `pull_request_review`, `issues`, and `projects_v2_item`
events
- **Views, navigation menu items and a GitHub folder** so the data is
discoverable in the UI out of the box
- **Configurable repos / project numbers** via `GITHUB_REPOS` and
`GITHUB_PROJECT_NUMBERS` application variables — no hardcoded org

## Authentication

Two interchangeable modes (PAT preferred for quick setup, GitHub App
recommended for production):

1. **Personal Access Token** — set `GITHUB_TOKEN`. Used as-is for both
REST and GraphQL.
2. **GitHub App** — set `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`,
`GITHUB_APP_INSTALLATION_ID`. Issues a signed JWT, exchanges it for a
short-lived installation token, and caches the token until expiry.

Webhook signature verification (`X-Hub-Signature-256`) is enforced when
`GITHUB_WEBHOOK_SECRET` is set.

## Notes

- Built on `twenty-sdk@2.0.0` / `twenty-client-sdk@2.0.0`
- Decoupled from internal modules (`quality/bug`, `discord`, `release`,
`code-build`, `project-management`) — `mustBeQa` is inlined and a local
`github` nav folder replaces shared ones
- `npx twenty typecheck`, `yarn lint`, and `npx twenty build` all run
cleanly
- Includes a comprehensive README with setup, env vars, webhook
configuration, and the auth resolution flow
This commit is contained in:
Charles Bochet
2026-04-22 18:17:08 +02:00
committed by GitHub
parent f30ef2432f
commit 3ebeb3a3e8
143 changed files with 13059 additions and 0 deletions
@@ -0,0 +1,50 @@
const getEnv = (key: string): string | undefined => {
if (typeof process === 'undefined') return undefined;
return process.env?.[key];
};
type ErrorResponse = {
messages?: string[];
message?: string;
error?: string;
statusCode?: number;
};
function extractErrorMessage(text: string, status: number): string {
try {
const json = JSON.parse(text) as ErrorResponse;
if (json.messages?.length) return json.messages[0];
return json.message ?? json.error ?? `Server error (${status})`;
} catch {
return text || `Server error (${status})`;
}
}
export async function callAppRoute(
path: string,
body: Record<string, unknown>,
): Promise<unknown> {
const apiUrl = getEnv('TWENTY_API_URL') ?? '';
const token = getEnv('TWENTY_APP_ACCESS_TOKEN');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(`${apiUrl}/s${path}`, {
method: 'POST',
headers,
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
throw new Error(extractErrorMessage(text, res.status));
}
return res.json();
}
@@ -0,0 +1,3 @@
export function isFixtureAllowed(): boolean {
return process.env.NODE_ENV !== 'production';
}
@@ -0,0 +1,49 @@
export type RetryOptions = {
retries?: number;
baseDelayMs?: number;
maxDelayMs?: number;
shouldRetry?: (err: unknown) => boolean;
};
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export async function retry<T>(
label: string,
fn: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const requestedRetries = options.retries ?? 3;
const retries = Math.max(
Math.floor(Number.isFinite(requestedRetries) ? requestedRetries : 1),
1,
);
const baseDelayMs = options.baseDelayMs ?? 500;
const maxDelayMs = options.maxDelayMs ?? 5_000;
const shouldRetry = options.shouldRetry ?? (() => true);
let lastErr: unknown;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
lastErr = err;
const msg = err instanceof Error ? err.message : String(err);
if (attempt === retries || !shouldRetry(err)) {
console.log(
`[retry] ${label} attempt ${attempt}/${retries} failed (giving up): ${msg}`,
);
throw err;
}
const delay = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
const jittered = delay + Math.floor(Math.random() * baseDelayMs);
console.log(
`[retry] ${label} attempt ${attempt}/${retries} failed, retrying in ${jittered}ms: ${msg}`,
);
await sleep(jittered);
}
}
throw lastErr;
}
@@ -0,0 +1,17 @@
export async function timed<T>(
label: string,
fn: () => Promise<T>,
): Promise<T> {
const start = Date.now();
try {
const result = await fn();
const ms = Date.now() - start;
console.log(`[timing] ${label} ok in ${ms}ms`);
return result;
} catch (err) {
const ms = Date.now() - start;
const msg = err instanceof Error ? err.message : String(err);
console.log(`[timing] ${label} FAILED in ${ms}ms: ${msg}`);
throw err;
}
}
@@ -0,0 +1,48 @@
import { CoreApiClient } from 'twenty-client-sdk/core';
export const getClient = () => new CoreApiClient();
const BATCH_CHUNK_SIZE = 50;
export async function chunkedBatchCreate<T extends Record<string, unknown>>(
mutationName: string,
items: T[],
returnFields: Record<string, true | Record<string, true>>,
): Promise<Record<string, unknown>[]> {
if (items.length === 0) return [];
const client = getClient();
const results: Record<string, unknown>[] = [];
const totalChunks = Math.ceil(items.length / BATCH_CHUNK_SIZE);
const overallStart = Date.now();
for (let i = 0; i < items.length; i += BATCH_CHUNK_SIZE) {
const chunk = items.slice(i, i + BATCH_CHUNK_SIZE);
const chunkIndex = Math.floor(i / BATCH_CHUNK_SIZE) + 1;
const chunkStart = Date.now();
const res = await client.mutation({
[mutationName]: {
__args: { data: chunk, upsert: true },
...returnFields,
},
});
const chunkMs = Date.now() - chunkStart;
console.log(
`[timing] ${mutationName} chunk ${chunkIndex}/${totalChunks} (${chunk.length} rows) in ${chunkMs}ms`,
);
const rows = res[mutationName];
if (Array.isArray(rows)) {
results.push(...(rows as Record<string, unknown>[]));
}
}
const totalMs = Date.now() - overallStart;
console.log(
`[timing] ${mutationName} total ${items.length} rows in ${totalMs}ms (${totalChunks} chunk(s))`,
);
return results;
}
@@ -0,0 +1,9 @@
export type LinksFieldValue = {
primaryLinkLabel: string;
primaryLinkUrl: string;
secondaryLinks: null;
};
export function toLinksField(url: string, label = ''): LinksFieldValue {
return { primaryLinkLabel: label, primaryLinkUrl: url, secondaryLinks: null };
}
@@ -0,0 +1,30 @@
export const APP_DISPLAY_NAME = 'GitHub Connector';
export const APP_DESCRIPTION =
'Sync pull requests, reviews, issues, contributors and Projects (v2) items from GitHub into Twenty.';
export const APP_ABOUT_DESCRIPTION = [
'Bring your GitHub activity into Twenty as native records you can view, filter, group and report on alongside the rest of your CRM data.',
'',
'**What gets synced**',
'- Pull requests, pull-request reviews and review events',
'- Issues',
'- Contributors (with avatars, profile links and bot detection)',
'- Projects (v2) items, including custom field values',
'',
'**How it stays in sync**',
'- Real-time updates via GitHub webhooks',
'- On-demand backfills via one-click commands on every record and view',
'- Works with any number of repositories and organizations',
'',
'**Authentication**',
'- GitHub App with private-key JWT (recommended for orgs)',
'- Fine-grained Personal Access Token (great for quick setups)',
'',
'**Out of the box**',
'- A GitHub Dashboard with PR / review counters, weekly histograms and top-contributor leaderboards',
'- A Contributor Stats panel on every contributor record (PRs authored, merged and reviewed over time)',
'- Pre-built views for pull requests, reviews, issues and project items',
].join('\n');
export const APPLICATION_UNIVERSAL_IDENTIFIER =
'0c5b6bf2-2b41-4d5e-9f8a-3c4e6d7a8b9c';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'4e7d8a5f-1b32-4f6a-bc91-2d9c8b7a6e54';