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:
@@ -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 };
|
||||
}
|
||||
+30
@@ -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';
|
||||
Reference in New Issue
Block a user