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,54 @@
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'}`,
);
}
}
@@ -0,0 +1,128 @@
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,
);
}
@@ -0,0 +1,73 @@
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();
});
});
@@ -0,0 +1,124 @@
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');
});
});
@@ -0,0 +1,121 @@
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',
});
});
});