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,24 @@
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',
},
});
@@ -0,0 +1,25 @@
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',
},
});
@@ -0,0 +1,25 @@
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',
},
});
@@ -0,0 +1,108 @@
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',
},
});
@@ -0,0 +1,41 @@
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;
}
@@ -0,0 +1,31 @@
import { githubGraphql } from 'src/modules/github/connector/github-client';
import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item';
import { PROJECT_V2_ITEM_FRAGMENT } from 'src/modules/github/project-item/graphql/github/fragments';
const QUERY = `
query($id: ID!) {
node(id: $id) {
... on ProjectV2Item {
${PROJECT_V2_ITEM_FRAGMENT}
}
}
}`;
type Response = {
node: ProjectV2Item | null;
};
export async function fetchProjectItemByNodeId(
nodeId: string,
): Promise<ProjectV2Item | null> {
try {
const data = await githubGraphql<Response>(QUERY, { id: nodeId });
return data.node;
} catch (err) {
const msg = err instanceof Error ? err.message : '';
if (msg.includes('Could not resolve') || msg.includes('global id')) {
return null;
}
throw err;
}
}
@@ -0,0 +1,78 @@
import {
EMPTY_PAGE,
type GithubPage,
githubGraphqlOptional,
} from 'src/modules/github/connector/github-client';
import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item';
import { PROJECT_V2_ITEM_FRAGMENT } from 'src/modules/github/project-item/graphql/github/fragments';
const ORG_QUERY = `
query($owner: String!, $number: Int!, $cursor: String) {
organization(login: $owner) {
projectV2(number: $number) {
items(first: 100, after: $cursor) {
totalCount
pageInfo { hasNextPage endCursor }
nodes { ${PROJECT_V2_ITEM_FRAGMENT} }
}
}
}
}`;
const USER_QUERY = ORG_QUERY.replace(
'organization(login: $owner)',
'user(login: $owner)',
);
type ItemsConnection = GithubPage<ProjectV2Item>;
type OrgResponse = {
organization: { projectV2: { items: ItemsConnection } | null } | null;
};
type UserResponse = {
user: { projectV2: { items: ItemsConnection } | null } | null;
};
export async function fetchProjectItems(
owner: string,
projectNumber: number,
cursor: string | null = null,
): Promise<{
items: ProjectV2Item[];
totalCount: number;
hasMore: boolean;
endCursor: string | null;
}> {
const orgData = await githubGraphqlOptional<OrgResponse>(ORG_QUERY, {
owner,
number: projectNumber,
cursor,
});
let conn: ItemsConnection | undefined =
orgData?.organization?.projectV2?.items;
if (!conn) {
const userData = await githubGraphqlOptional<UserResponse>(USER_QUERY, {
owner,
number: projectNumber,
cursor,
});
conn = userData?.user?.projectV2?.items;
}
if (!conn) {
console.warn(
`[github-gql] project ${owner}/${projectNumber} returned no items (project does not exist or the fine-grained PAT lacks Organization → Projects: Read for "${owner}", and may also need to be approved by an org admin).`,
);
return { items: [], ...EMPTY_PAGE };
}
return {
items: conn.nodes,
totalCount: conn.totalCount,
hasMore: conn.pageInfo.hasNextPage,
endCursor: conn.pageInfo.endCursor,
};
}
@@ -0,0 +1,46 @@
export const PROJECT_V2_ITEM_FRAGMENT = `
id
content {
__typename
... on Issue {
title
number
url
repository { nameWithOwner }
}
... on PullRequest {
title
number
url
repository { nameWithOwner }
}
... on DraftIssue {
title
}
}
fieldValues(first: 20) {
nodes {
__typename
... on ProjectV2ItemFieldSingleSelectValue {
name
field { ... on ProjectV2SingleSelectField { name } }
}
... on ProjectV2ItemFieldIterationValue {
title
field { ... on ProjectV2IterationField { name } }
}
... on ProjectV2ItemFieldTextValue {
text
field { ... on ProjectV2Field { name } }
}
... on ProjectV2ItemFieldNumberValue {
number
field { ... on ProjectV2Field { name } }
}
... on ProjectV2ItemFieldUserValue {
users(first: 10) { nodes { login } }
field { ... on ProjectV2Field { name } }
}
}
}
`;
@@ -0,0 +1,29 @@
import { chunkedBatchCreate } from 'src/modules/shared/twenty-client';
import type { ProjectItemRow } from 'src/modules/github/project-item/types/project-item-row';
export async function batchUpsertProjectItems(
items: Array<{
name: string;
githubProjectItemId: string;
status: string;
sprint: string;
assignees: string;
priority: string | null;
mainAssigneeId: string | null;
linkedIssueId: string | null;
linkedPullRequestId: string | null;
githubUrl: { primaryLinkLabel: string; primaryLinkUrl: string; secondaryLinks: null } | null;
repo: string;
}>,
): Promise<ProjectItemRow[]> {
return chunkedBatchCreate('createProjectItems', items, {
id: true,
githubProjectItemId: true,
name: true,
status: true,
mainAssigneeId: true,
linkedIssueId: true,
linkedPullRequestId: true,
githubUrl: { primaryLinkLabel: true, primaryLinkUrl: true },
}) as Promise<ProjectItemRow[]>;
}
@@ -0,0 +1,47 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import {
getGithubProjects,
type GithubProject,
} from 'src/modules/github/connector/config';
import { countProjectItems } from 'src/modules/github/project-item/graphql/github/count-project-items';
const PAGE_SIZE = 100;
type CountProjectItemsPayload = {
projects?: GithubProject[];
};
const handler = async (event: RoutePayload<CountProjectItemsPayload>) => {
const bodyProjects = event.body?.projects;
const projects =
bodyProjects && bodyProjects.length > 0
? bodyProjects
: getGithubProjects();
const results: Array<GithubProject & { totalCount: number; pages: number }> =
[];
let totalPages = 0;
for (const { owner, number } of projects) {
const totalCount = await countProjectItems(owner, number);
const pages = Math.max(Math.ceil(totalCount / PAGE_SIZE), 1);
results.push({ owner, number, totalCount, pages });
totalPages += pages;
}
return { totalPages, projects: results };
};
export default defineLogicFunction({
universalIdentifier: 'f7a3e1b2-5c4d-4e6f-8a9b-0d1c2e3f4a5b',
name: 'count-project-items',
description:
'Counts total project item pages across configured projects using GraphQL totalCount',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/github/count-project-items',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,57 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { fetchProjectItems } from 'src/modules/github/project-item/graphql/github/fetch-project-items';
import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item';
import { batchUpsertProjectItems } from 'src/modules/github/project-item/graphql/mutations/batch-upsert';
import { projectItemFromGraphql } from 'src/modules/github/project-item/normalizers';
import { isFixtureAllowed } from 'src/modules/shared/fixtures';
export type FetchProjectItemsFixturePage = {
items: ProjectV2Item[];
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
type FetchProjectItemsPayload = {
owner: string;
number: number;
cursor?: string | null;
fixturePage?: FetchProjectItemsFixturePage;
};
const handler = async (event: RoutePayload<FetchProjectItemsPayload>) => {
const { owner, number, cursor = null, fixturePage } = event.body ?? {};
if (!owner || !number) {
return { error: 'owner and number are required' };
}
const result =
fixturePage && isFixtureAllowed()
? fixturePage
: await fetchProjectItems(owner, number, cursor);
const { items, totalCount, hasMore, endCursor } = result;
if (items.length === 0) {
return { itemCount: 0, totalCount, hasMore: false, endCursor: null };
}
const itemData = await Promise.all(items.map(projectItemFromGraphql));
await batchUpsertProjectItems(itemData);
return { itemCount: items.length, totalCount, hasMore, endCursor };
};
export default defineLogicFunction({
universalIdentifier: 'acb300d4-d4ec-491c-b314-3d4db90a49c5',
name: 'fetch-project-items',
description:
'Fetches one page of project items from GitHub Projects V2 GraphQL API and batch upserts them',
timeoutSeconds: 300,
handler,
httpRouteTriggerSettings: {
path: '/github/fetch-project-items',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,11 @@
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: '25d3a916-9a70-478f-8d83-ef336e582fbc',
position: 2,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,106 @@
import type { LinksFieldValue } from 'src/modules/shared/types';
import { toLinksField } from 'src/modules/shared/types';
import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item';
import {
extractFieldValue,
extractAssigneeLogins,
} from 'src/modules/github/project-item/utils/extract-field-value';
import { findIssueByNumberAndRepo } from 'src/modules/github/issue/graphql/queries/find-by-number-and-repo';
import {
findPullRequestByGithubNumber,
findPullRequestByRepoAndNumber,
} from 'src/modules/github/pull-request/graphql/queries/find-by-github-number';
import { findContributorByGhLogin } from 'src/modules/github/contributor/graphql/queries/find-by-gh-login';
const STATUS_MAP: Record<string, string> = {
'No Status': 'NO_STATUS',
Backlog: 'BACKLOG',
Todo: 'TODO',
'In Progress': 'IN_PROGRESS',
'In Review': 'IN_REVIEW',
Done: 'DONE',
};
const PRIORITY_MAP: Record<string, string> = {
Low: 'LOW',
Medium: 'MEDIUM',
High: 'HIGH',
Critical: 'CRITICAL',
};
export type ProjectItemUpsertInput = {
name: string;
githubProjectItemId: string;
status: string;
sprint: string;
assignees: string;
priority: string | null;
mainAssigneeId: string | null;
linkedIssueId: string | null;
linkedPullRequestId: string | null;
githubUrl: LinksFieldValue | null;
repo: string;
};
export async function projectItemFromGraphql(
item: ProjectV2Item,
): Promise<ProjectItemUpsertInput> {
const title =
item.content?.title ??
(extractFieldValue(item, 'Title') || 'Untitled');
const rawStatus = extractFieldValue(item, 'Status');
const status = STATUS_MAP[rawStatus] ?? 'NO_STATUS';
const sprint =
extractFieldValue(item, 'Sprint') ||
extractFieldValue(item, 'Iteration');
const assigneeLogins = extractAssigneeLogins(item);
const assignees = assigneeLogins.join(', ');
const rawPriority = extractFieldValue(item, 'Priority');
const priority = PRIORITY_MAP[rawPriority] ?? null;
let mainAssigneeId: string | null = null;
if (assigneeLogins.length > 0) {
const contributor = await findContributorByGhLogin(assigneeLogins[0]);
mainAssigneeId = contributor?.id ?? null;
}
let linkedIssueId: string | null = null;
let linkedPullRequestId: string | null = null;
let repo = '';
let githubUrl: LinksFieldValue | null = null;
if (item.content) {
const contentType = item.content.__typename;
repo = item.content.repository?.nameWithOwner ?? '';
if (contentType === 'Issue' && item.content.number) {
const issue = await findIssueByNumberAndRepo(item.content.number, repo);
linkedIssueId = issue?.id ?? null;
if (item.content.url) {
githubUrl = toLinksField(item.content.url, `#${item.content.number}`);
}
} else if (contentType === 'PullRequest' && item.content.number) {
const pr = repo
? await findPullRequestByRepoAndNumber(repo, item.content.number)
: await findPullRequestByGithubNumber(item.content.number);
linkedPullRequestId = pr?.id ?? null;
if (item.content.url) {
githubUrl = toLinksField(item.content.url, `#${item.content.number}`);
}
}
}
return {
name: title,
githubProjectItemId: item.id,
status,
sprint,
assignees,
priority,
mainAssigneeId,
linkedIssueId,
linkedPullRequestId,
githubUrl,
repo,
};
}
@@ -0,0 +1,181 @@
import { defineObject, FieldType } from 'twenty-sdk/define';
export const PROJECT_ITEM_UNIVERSAL_IDENTIFIER =
'1c4c36a5-586c-4ead-9f3e-5e9718ba6231';
export const PROJECT_ITEM_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'215cdbbd-8606-4c13-9025-3ebe912eaa32';
export const PROJECT_ITEM_GITHUB_ID_FIELD_UNIVERSAL_IDENTIFIER =
'2d3e4f5a-6b7c-4d8e-af9b-0c1d2e3f4a5b';
export const PROJECT_ITEM_SPRINT_FIELD_UNIVERSAL_IDENTIFIER =
'e3708df4-6b6b-45f6-83fb-9d47c5bc5a25';
export const PROJECT_ITEM_ASSIGNEES_FIELD_UNIVERSAL_IDENTIFIER =
'3e4f5a6b-7c8d-4e9f-b0ac-1d2e3f4a5b6c';
export const PROJECT_ITEM_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER =
'2db040a6-f2af-4e7d-9b70-d453f3cd99b7';
export const PROJECT_ITEM_REPO_FIELD_UNIVERSAL_IDENTIFIER =
'589d6aeb-8384-4d52-9d71-d63b7fd94ec7';
enum ProjectItemStatus {
NO_STATUS = 'NO_STATUS',
BACKLOG = 'BACKLOG',
TODO = 'TODO',
IN_PROGRESS = 'IN_PROGRESS',
IN_REVIEW = 'IN_REVIEW',
DONE = 'DONE',
}
export const PROJECT_ITEM_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'54034cfc-83d9-478d-849c-98c91a819342';
enum ProjectItemPriority {
LOW = 'LOW',
MEDIUM = 'MEDIUM',
HIGH = 'HIGH',
CRITICAL = 'CRITICAL',
}
export const PROJECT_ITEM_PRIORITY_FIELD_UNIVERSAL_IDENTIFIER =
'952a601c-b2da-4b84-9c7b-a9b42fcb7d95';
export const PROJECT_ITEM_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'597b4141-487f-5ad3-87c2-49f0a1679856';
export default defineObject({
universalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
nameSingular: 'projectItem',
namePlural: 'projectItems',
labelSingular: 'Project Item',
labelPlural: 'Project Items',
icon: 'IconLayoutKanban',
labelIdentifierFieldMetadataUniversalIdentifier:
PROJECT_ITEM_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: PROJECT_ITEM_NAME_FIELD_UNIVERSAL_IDENTIFIER,
name: 'name',
type: FieldType.TEXT,
label: 'Name',
icon: 'IconTextCaption',
},
{
universalIdentifier: PROJECT_ITEM_GITHUB_ID_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubProjectItemId',
type: FieldType.TEXT,
label: 'GitHub Item ID',
icon: 'IconHash',
isUnique: true,
},
{
universalIdentifier: PROJECT_ITEM_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
name: 'status',
type: FieldType.SELECT,
label: 'Status',
icon: 'IconCircleDot',
options: [
{
value: ProjectItemStatus.NO_STATUS,
label: 'No Status',
position: 0,
color: 'gray',
},
{
value: ProjectItemStatus.BACKLOG,
label: 'Backlog',
position: 1,
color: 'gray',
},
{
value: ProjectItemStatus.TODO,
label: 'Todo',
position: 2,
color: 'blue',
},
{
value: ProjectItemStatus.IN_PROGRESS,
label: 'In Progress',
position: 3,
color: 'yellow',
},
{
value: ProjectItemStatus.IN_REVIEW,
label: 'In Review',
position: 4,
color: 'turquoise',
},
{
value: ProjectItemStatus.DONE,
label: 'Done',
position: 5,
color: 'green',
},
],
},
{
universalIdentifier: PROJECT_ITEM_SPRINT_FIELD_UNIVERSAL_IDENTIFIER,
name: 'sprint',
type: FieldType.TEXT,
label: 'Sprint',
icon: 'IconRun',
},
{
universalIdentifier: PROJECT_ITEM_ASSIGNEES_FIELD_UNIVERSAL_IDENTIFIER,
name: 'assignees',
type: FieldType.TEXT,
label: 'Assignees',
icon: 'IconUsers',
},
{
universalIdentifier: PROJECT_ITEM_PRIORITY_FIELD_UNIVERSAL_IDENTIFIER,
name: 'priority',
type: FieldType.SELECT,
label: 'Priority',
icon: 'IconFlag',
options: [
{
value: ProjectItemPriority.LOW,
label: 'Low',
position: 0,
color: 'green',
},
{
value: ProjectItemPriority.MEDIUM,
label: 'Medium',
position: 1,
color: 'turquoise',
},
{
value: ProjectItemPriority.HIGH,
label: 'High',
position: 2,
color: 'red',
},
{
value: ProjectItemPriority.CRITICAL,
label: 'Critical',
position: 3,
color: 'red',
},
],
},
{
universalIdentifier: PROJECT_ITEM_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubUrl',
type: FieldType.LINKS,
label: 'GitHub URL',
icon: 'IconLink',
},
{
universalIdentifier: PROJECT_ITEM_REPO_FIELD_UNIVERSAL_IDENTIFIER,
name: 'repo',
type: FieldType.TEXT,
label: 'Repository',
icon: 'IconFolder',
},
],
});
@@ -0,0 +1,7 @@
export type GitHubProjectV2Item = {
id: number;
node_id: string;
project_node_id?: string;
content_node_id?: string;
content_type?: 'Issue' | 'PullRequest' | 'DraftIssue';
};
@@ -0,0 +1,16 @@
import type { LinksFieldValue } from 'src/modules/shared/types';
export type ProjectItemRow = {
id: string;
name?: string | null;
githubProjectItemId?: string | null;
status?: string | null;
sprint?: string | null;
assignees?: string | null;
priority?: string | null;
mainAssigneeId?: string | null;
linkedIssueId?: string | null;
linkedPullRequestId?: string | null;
githubUrl?: LinksFieldValue | null;
repo?: string | null;
};
@@ -0,0 +1,42 @@
type FieldRef = { name: string };
export type ProjectV2FieldValue =
| {
__typename: 'ProjectV2ItemFieldSingleSelectValue';
name: string;
field: FieldRef;
}
| {
__typename: 'ProjectV2ItemFieldIterationValue';
title: string;
field: FieldRef;
}
| {
__typename: 'ProjectV2ItemFieldTextValue';
text: string;
field: FieldRef;
}
| {
__typename: 'ProjectV2ItemFieldNumberValue';
number: number;
field: FieldRef;
}
| {
__typename: 'ProjectV2ItemFieldUserValue';
users: { nodes: Array<{ login: string }> };
field: FieldRef;
};
export type ProjectV2Item = {
id: string;
content: {
__typename: string;
title?: string;
number?: number;
url?: string;
repository?: { nameWithOwner: string };
} | null;
fieldValues: {
nodes: Array<ProjectV2FieldValue>;
};
};
@@ -0,0 +1,34 @@
import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item';
export function extractAssigneeLogins(item: ProjectV2Item): string[] {
for (const fv of item.fieldValues.nodes) {
if (fv.field?.name !== 'Assignees') continue;
if (fv.__typename === 'ProjectV2ItemFieldUserValue') {
return fv.users.nodes.map((u) => u.login);
}
}
return [];
}
export function extractFieldValue(
item: ProjectV2Item,
fieldName: string,
): string {
for (const fv of item.fieldValues.nodes) {
if (fv.field?.name !== fieldName) continue;
switch (fv.__typename) {
case 'ProjectV2ItemFieldSingleSelectValue':
return fv.name;
case 'ProjectV2ItemFieldIterationValue':
return fv.title;
case 'ProjectV2ItemFieldTextValue':
return fv.text;
case 'ProjectV2ItemFieldNumberValue':
return String(fv.number);
case 'ProjectV2ItemFieldUserValue':
return fv.users.nodes.map((u) => u.login).join(', ');
}
}
return '';
}
@@ -0,0 +1,123 @@
import {
defineView,
ViewKey,
ViewSortDirection,
ViewType,
} from 'twenty-sdk/define';
import {
PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
PROJECT_ITEM_NAME_FIELD_UNIVERSAL_IDENTIFIER,
PROJECT_ITEM_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
PROJECT_ITEM_SPRINT_FIELD_UNIVERSAL_IDENTIFIER,
PROJECT_ITEM_ASSIGNEES_FIELD_UNIVERSAL_IDENTIFIER,
PROJECT_ITEM_PRIORITY_FIELD_UNIVERSAL_IDENTIFIER,
PROJECT_ITEM_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER,
PROJECT_ITEM_REPO_FIELD_UNIVERSAL_IDENTIFIER,
PROJECT_ITEM_CREATED_AT_FIELD_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';
import { LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/linked-issue-on-project-item.field';
import { LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/linked-pr-on-project-item.field';
export const ALL_PROJECT_ITEMS_VIEW_UNIVERSAL_IDENTIFIER =
'9bb6d69e-9411-4195-9042-8df2e4b72a11';
export default defineView({
universalIdentifier: ALL_PROJECT_ITEMS_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All Project Items',
objectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconLayoutKanban',
key: ViewKey.INDEX,
position: 0,
fields: [
{
universalIdentifier: '19064693-70e0-4653-b6f6-572c3adfcc78',
fieldMetadataUniversalIdentifier:
PROJECT_ITEM_NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 300,
},
{
universalIdentifier: 'f7a1de3d-3989-4cb9-8e8e-0edd1b4d15e7',
fieldMetadataUniversalIdentifier:
PROJECT_ITEM_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
position: 1,
isVisible: true,
size: 130,
},
{
universalIdentifier: 'f4fae1f9-8480-4cfd-b493-9dbac84c6643',
fieldMetadataUniversalIdentifier:
PROJECT_ITEM_PRIORITY_FIELD_UNIVERSAL_IDENTIFIER,
position: 2,
isVisible: true,
size: 130,
},
{
universalIdentifier: '5969adcf-1e89-4561-a052-0296058623f3',
fieldMetadataUniversalIdentifier:
MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
position: 3,
isVisible: true,
size: 180,
},
{
universalIdentifier: '21868cca-dae2-45e7-aa92-e667703e8cfe',
fieldMetadataUniversalIdentifier:
PROJECT_ITEM_SPRINT_FIELD_UNIVERSAL_IDENTIFIER,
position: 4,
isVisible: true,
size: 150,
},
{
universalIdentifier: 'bf877bd3-a788-4db5-89cd-db6fb74fe49d',
fieldMetadataUniversalIdentifier:
PROJECT_ITEM_ASSIGNEES_FIELD_UNIVERSAL_IDENTIFIER,
position: 5,
isVisible: true,
size: 200,
},
{
universalIdentifier: '4c063477-ffe8-4799-bc50-bb9b2483cbab',
fieldMetadataUniversalIdentifier:
LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
position: 6,
isVisible: true,
size: 180,
},
{
universalIdentifier: 'acba5c9a-fd97-42b6-b0f5-e8e3fb7d49c5',
fieldMetadataUniversalIdentifier:
LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
position: 7,
isVisible: true,
size: 180,
},
{
universalIdentifier: '931fac89-e99d-46dd-b316-85ff04356811',
fieldMetadataUniversalIdentifier:
PROJECT_ITEM_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER,
position: 8,
isVisible: true,
size: 200,
},
{
universalIdentifier: '11c446af-470a-40d4-96e8-9e91879accf4',
fieldMetadataUniversalIdentifier:
PROJECT_ITEM_REPO_FIELD_UNIVERSAL_IDENTIFIER,
position: 9,
isVisible: true,
size: 180,
},
],
sorts: [
{
universalIdentifier: 'a7c181f1-2961-45b0-a700-dd9489b3420c',
fieldMetadataUniversalIdentifier:
PROJECT_ITEM_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
direction: ViewSortDirection.DESC,
},
],
});