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,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,
|
||||
);
|
||||
}
|
||||
+73
@@ -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();
|
||||
});
|
||||
});
|
||||
+124
@@ -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');
|
||||
});
|
||||
});
|
||||
+121
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user