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:
+24
@@ -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',
|
||||
},
|
||||
});
|
||||
+25
@@ -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',
|
||||
},
|
||||
});
|
||||
+25
@@ -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',
|
||||
},
|
||||
});
|
||||
+108
@@ -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',
|
||||
},
|
||||
});
|
||||
+41
@@ -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;
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
+78
@@ -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,
|
||||
};
|
||||
}
|
||||
+46
@@ -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 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
+29
@@ -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[]>;
|
||||
}
|
||||
+47
@@ -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,
|
||||
},
|
||||
});
|
||||
+57
@@ -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,
|
||||
},
|
||||
});
|
||||
+11
@@ -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,
|
||||
});
|
||||
+106
@@ -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,
|
||||
};
|
||||
}
|
||||
+181
@@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
+7
@@ -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';
|
||||
};
|
||||
+16
@@ -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;
|
||||
};
|
||||
+42
@@ -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>;
|
||||
};
|
||||
};
|
||||
+34
@@ -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 '';
|
||||
}
|
||||
+123
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user