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,122 @@
import { createSign } from 'crypto';
export function normalizePem(raw: string): string {
let pem = raw.replace(/\\n/g, '\n').trim();
if (!pem.includes('\n')) {
const beginMatch = pem.match(/^(-----BEGIN [A-Z ]+-----)/);
const endMatch = pem.match(/(-----END [A-Z ]+-----)$/);
if (beginMatch && endMatch) {
const header = beginMatch[1];
const footer = endMatch[1];
const body = pem.slice(header.length, pem.length - footer.length);
const lines = body.match(/.{1,64}/g) ?? [];
pem = [header, ...lines, footer].join('\n');
}
}
return pem;
}
function base64url(input: Buffer | string): string {
const buf = typeof input === 'string' ? Buffer.from(input) : input;
return buf.toString('base64url');
}
function signJwt(appId: string, privateKey: string): string {
const now = Math.floor(Date.now() / 1000);
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const payload = base64url(
JSON.stringify({ iat: now - 60, exp: now + 600, iss: appId }),
);
const signature = createSign('RSA-SHA256')
.update(`${header}.${payload}`)
.sign(privateKey, 'base64url');
return `${header}.${payload}.${signature}`;
}
type CachedToken = { token: string; expiresAt: number };
let cached: CachedToken | null = null;
const TOKEN_MARGIN_MS = 5 * 60 * 1000;
async function requestInstallationToken(
appId: string,
privateKey: string,
installationId: string,
): Promise<CachedToken> {
const jwt = signJwt(appId, privateKey);
const res = await fetch(
`https://api.github.com/app/installations/${installationId}/access_tokens`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${jwt}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
},
);
if (!res.ok) {
const body = await res.text();
throw new Error(
`GitHub App token exchange failed (${res.status}): ${body}`,
);
}
const data = (await res.json()) as {
token: string;
expires_at: string;
};
return {
token: data.token,
expiresAt: new Date(data.expires_at).getTime(),
};
}
function assertFineGrainedPat(token: string): void {
if (token.startsWith('github_pat_')) return;
throw new Error(
'GITHUB_TOKEN must be a fine-grained Personal Access Token (starts with `github_pat_`). Classic PATs are not supported — create one at https://github.com/settings/personal-access-tokens.',
);
}
export async function getGitHubToken(): Promise<string> {
const pat = process.env.GITHUB_TOKEN?.trim();
if (pat) {
assertFineGrainedPat(pat);
return pat;
}
if (cached && Date.now() < cached.expiresAt - TOKEN_MARGIN_MS) {
return cached.token;
}
const appId = process.env.GITHUB_APP_ID;
const rawKey = process.env.GITHUB_APP_PRIVATE_KEY;
const installationId = process.env.GITHUB_APP_INSTALLATION_ID;
if (!appId || !rawKey || !installationId) {
const missing = [
!appId && 'GITHUB_APP_ID',
!rawKey && 'GITHUB_APP_PRIVATE_KEY',
!installationId && 'GITHUB_APP_INSTALLATION_ID',
]
.filter(Boolean)
.join(', ');
throw new Error(
`Missing GitHub credentials. Set GITHUB_TOKEN (fine-grained PAT) or all of: ${missing}.`,
);
}
const privateKey = normalizePem(rawKey);
cached = await requestInstallationToken(appId, privateKey, installationId);
return cached.token;
}
@@ -0,0 +1,55 @@
export type GithubRepo = { owner: string; repo: string };
export function parseGithubRepo(entry: string): GithubRepo | null {
const trimmed = entry.trim();
if (!trimmed) return null;
const match = trimmed.match(/^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/);
if (!match) return null;
return { owner: match[1], repo: match[2] };
}
export function getGithubRepos(): string[] {
const raw = process.env.GITHUB_REPOS ?? '';
return raw
.split(',')
.map(parseGithubRepo)
.filter((r): r is GithubRepo => r !== null)
.map((r) => `${r.owner}/${r.repo}`);
}
export type GithubProject = { owner: string; number: number };
export function parseGithubProject(entry: string): GithubProject | null {
const trimmed = entry.trim();
if (!trimmed) return null;
const urlMatch = trimmed.match(
/github\.com\/(?:orgs|users)\/([^/]+)\/projects\/(\d+)/i,
);
if (urlMatch) {
const owner = urlMatch[1];
const number = Number.parseInt(urlMatch[2], 10);
if (owner && Number.isFinite(number) && number > 0) {
return { owner, number };
}
}
const shortMatch = trimmed.match(/^([^/\s]+)\/(\d+)$/);
if (shortMatch) {
const owner = shortMatch[1];
const number = Number.parseInt(shortMatch[2], 10);
if (owner && Number.isFinite(number) && number > 0) {
return { owner, number };
}
}
return null;
}
export function getGithubProjects(): GithubProject[] {
const raw = process.env.GITHUB_PROJECTS ?? '';
return raw
.split(',')
.map(parseGithubProject)
.filter((p): p is GithubProject => p !== null);
}
@@ -0,0 +1,45 @@
import {
getGithubRepos,
parseGithubRepo,
} from 'src/modules/github/connector/config';
export type RepoCount = {
owner: string;
repo: string;
totalCount: number;
pages: number;
};
export type CountAcrossReposResult = {
totalPages: number;
repos: RepoCount[];
};
const PAGE_SIZE = 100;
export async function countAcrossRepos(
bodyRepos: string[] | undefined,
count: (owner: string, repo: string) => Promise<number>,
logTag: string,
): Promise<CountAcrossReposResult> {
const repos =
bodyRepos && bodyRepos.length > 0 ? bodyRepos : getGithubRepos();
const results: RepoCount[] = [];
let totalPages = 0;
for (const fullRepo of repos) {
const parsed = parseGithubRepo(fullRepo);
if (!parsed) {
console.warn(`[${logTag}] Skipping malformed repo entry: ${fullRepo}`);
continue;
}
const { owner, repo } = parsed;
const totalCount = await count(owner, repo);
const pages = Math.max(Math.ceil(totalCount / PAGE_SIZE), 1);
results.push({ owner, repo, totalCount, pages });
totalPages += pages;
}
return { totalPages, repos: results };
}
@@ -0,0 +1,157 @@
import { getGitHubToken } from 'src/modules/github/connector/auth';
const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql';
const LOW_REMAINING_THRESHOLD = 200;
export class GitHubRateLimitError extends Error {
constructor(
message: string,
public readonly kind: 'primary' | 'secondary',
public readonly remaining: number | null,
public readonly limit: number | null,
public readonly resetAt: Date | null,
public readonly retryAfterSeconds: number | null,
) {
super(message);
this.name = 'GitHubRateLimitError';
}
}
function readRateLimitHeaders(res: Response) {
const num = (h: string) => {
const v = res.headers.get(h);
return v === null ? null : Number(v);
};
const reset = num('x-ratelimit-reset');
return {
limit: num('x-ratelimit-limit'),
remaining: num('x-ratelimit-remaining'),
used: num('x-ratelimit-used'),
resource: res.headers.get('x-ratelimit-resource'),
resetAt:
reset === null || Number.isNaN(reset) ? null : new Date(reset * 1000),
retryAfter: num('retry-after'),
};
}
export async function githubGraphql<T>(
query: string,
variables: Record<string, unknown>,
): Promise<T> {
const queryName = query.match(/query\s*\(/)
? query.slice(0, 60).replace(/\s+/g, ' ').trim()
: 'mutation';
console.log(
`[github-gql] Executing: ${queryName} vars=${JSON.stringify(Object.keys(variables))}`,
);
const token = await getGitHubToken();
const res = await fetch(GITHUB_GRAPHQL_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables }),
});
const rl = readRateLimitHeaders(res);
if (rl.remaining !== null && rl.limit !== null) {
const resetIn = rl.resetAt
? Math.max(0, Math.round((rl.resetAt.getTime() - Date.now()) / 1000))
: null;
const isLow =
rl.remaining <= LOW_REMAINING_THRESHOLD ||
rl.remaining / rl.limit < 0.1;
const tag = isLow
? '[github-gql][rate-limit-low]'
: '[github-gql][rate-limit]';
console.log(
`${tag} resource=${rl.resource ?? 'graphql'} remaining=${rl.remaining}/${rl.limit} used=${rl.used ?? '?'} resetIn=${resetIn ?? '?'}s`,
);
}
if (!res.ok) {
const body = await res.text();
const lower = body.toLowerCase();
const isSecondary =
res.status === 403 &&
(lower.includes('secondary rate limit') ||
lower.includes('abuse detection'));
const isPrimary =
(res.status === 403 || res.status === 429) &&
lower.includes('rate limit') &&
!isSecondary;
if (isPrimary || isSecondary) {
throw new GitHubRateLimitError(
`GitHub ${isSecondary ? 'secondary' : 'primary'} rate limit hit (${res.status}): ${body.slice(0, 200)}`,
isSecondary ? 'secondary' : 'primary',
rl.remaining,
rl.limit,
rl.resetAt,
rl.retryAfter,
);
}
throw new Error(
`GitHub GraphQL ${res.status} ${res.statusText}: ${body.slice(0, 500)}`,
);
}
const json = (await res.json()) as {
data: T;
errors?: Array<{ type?: string; message: string }>;
};
if (json.errors?.length) {
const isRateLimited = json.errors.some(
(e) => e.type === 'RATE_LIMITED' || /rate limit/i.test(e.message),
);
if (isRateLimited) {
throw new GitHubRateLimitError(
`GitHub GraphQL RATE_LIMITED: ${json.errors.map((e) => e.message).join(', ')}`,
'primary',
rl.remaining,
rl.limit,
rl.resetAt,
rl.retryAfter,
);
}
throw new Error(
`GraphQL errors: ${json.errors.map((e) => e.message).join(', ')}`,
);
}
return json.data;
}
export async function githubGraphqlOptional<T>(
query: string,
variables: Record<string, unknown>,
): Promise<T | null> {
try {
return await githubGraphql<T>(query, variables);
} catch (err) {
const msg = err instanceof Error ? err.message : '';
if (/Could not resolve to a (User|Organization)/i.test(msg)) return null;
throw err;
}
}
export type GithubPage<T> = {
totalCount: number;
pageInfo: { hasNextPage: boolean; endCursor: string | null };
nodes: T[];
};
export type Paged<TItems extends Record<string, unknown[]>> = TItems & {
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
export const EMPTY_PAGE = {
totalCount: 0,
hasMore: false,
endCursor: null,
} as const;
@@ -0,0 +1,5 @@
export type GitHubUser = {
login: string;
id: number;
avatar_url?: string | null;
};
@@ -0,0 +1,15 @@
import type { GitHubUser } from 'src/modules/github/connector/github-user';
import type { GitHubPullRequest } from 'src/modules/github/pull-request/types/github-pull-request';
import type { GitHubReview } from 'src/modules/github/pull-request-review-event/types/github-review';
import type { GitHubIssue } from 'src/modules/github/issue/types/github-issue';
import type { GitHubProjectV2Item } from 'src/modules/github/project-item/types/github-project-v2-item';
export type GitHubWebhookPayload = {
action: string;
pull_request?: GitHubPullRequest;
review?: GitHubReview;
issue?: GitHubIssue;
projects_v2_item?: GitHubProjectV2Item;
sender: GitHubUser;
repository?: { full_name: string };
};
@@ -0,0 +1,54 @@
import { createHmac, timingSafeEqual } from 'crypto';
export type SignatureVerificationResult =
| { ok: true }
| { ok: false; reason: string };
export function getRawBodyForSignature(event: {
body: unknown;
isBase64Encoded?: boolean;
}): string | null {
const raw = event.body;
if (raw == null) return '';
if (typeof raw === 'string') {
return event.isBase64Encoded
? Buffer.from(raw, 'base64').toString('utf8')
: raw;
}
return null;
}
export function verifyGitHubSignature({
rawBody,
signatureHeader,
secret,
}: {
rawBody: string | null;
signatureHeader: string | undefined;
secret: string;
}): SignatureVerificationResult {
if (rawBody === null) {
return {
ok: false,
reason:
'raw request body is unavailable (the runtime parsed it as JSON); HMAC cannot be verified',
};
}
if (!signatureHeader) {
return { ok: false, reason: 'missing X-Hub-Signature-256 header' };
}
const prefix = 'sha256=';
if (!signatureHeader.startsWith(prefix)) {
return { ok: false, reason: 'malformed signature header' };
}
const provided = signatureHeader.slice(prefix.length);
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
if (provided.length !== expected.length) {
return { ok: false, reason: 'signature length mismatch' };
}
const ok = timingSafeEqual(
Buffer.from(provided, 'utf8'),
Buffer.from(expected, 'utf8'),
);
return ok ? { ok: true } : { ok: false, reason: 'signature mismatch' };
}
@@ -0,0 +1,282 @@
import { type ReactNode } from 'react';
import { THEME } from 'src/modules/github/contributor/components/theme';
export type LeaderboardEntry = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: string | null;
count: number;
};
const ROW_HEIGHT = 32;
const CELL_PADDING_X = 8;
const RANK_COL_WIDTH = 36;
const COUNT_COL_WIDTH = 72;
const styles = {
root: {
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
minHeight: 0,
fontFamily: THEME.fontFamily,
color: THEME.fontPrimary,
background: THEME.bgPrimary,
boxSizing: 'border-box',
overflow: 'hidden',
} as const,
table: {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden',
width: '100%',
} as const,
headerRow: {
display: 'flex',
alignItems: 'stretch',
height: ROW_HEIGHT,
flexShrink: 0,
borderBottom: `1px solid ${THEME.borderLight}`,
background: THEME.bgPrimary,
} as const,
headerCell: {
display: 'flex',
alignItems: 'center',
height: '100%',
padding: `0 ${CELL_PADDING_X}px`,
borderRight: `1px solid ${THEME.borderLight}`,
color: THEME.fontTertiary,
fontSize: 12,
fontWeight: 500,
boxSizing: 'border-box',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
userSelect: 'none',
} as const,
headerCellLast: {
borderRight: 'none',
} as const,
row: {
display: 'flex',
alignItems: 'stretch',
height: ROW_HEIGHT,
flexShrink: 0,
width: '100%',
background: THEME.bgPrimary,
color: THEME.fontPrimary,
textDecoration: 'none',
boxSizing: 'border-box',
} as const,
cell: {
display: 'flex',
alignItems: 'center',
height: '100%',
padding: `0 ${CELL_PADDING_X}px`,
borderBottom: `1px solid ${THEME.borderLight}`,
borderRight: `1px solid ${THEME.borderLight}`,
fontSize: 13,
boxSizing: 'border-box',
minWidth: 0,
overflow: 'hidden',
transition: 'background-color 80ms ease',
} as const,
cellLast: {
borderRight: 'none',
} as const,
rankCell: {
width: RANK_COL_WIDTH,
flexShrink: 0,
justifyContent: 'flex-end',
color: THEME.fontTertiary,
fontSize: 12,
fontVariantNumeric: 'tabular-nums',
} as const,
contributorCell: {
flex: 1,
minWidth: 0,
gap: 6,
} as const,
countCell: {
width: COUNT_COL_WIDTH,
flexShrink: 0,
justifyContent: 'flex-end',
color: THEME.fontSecondary,
fontVariantNumeric: 'tabular-nums',
fontWeight: 500,
} as const,
avatar: {
width: 16,
height: 16,
borderRadius: '50%',
objectFit: 'cover',
background: THEME.bgTertiary,
flexShrink: 0,
} as const,
nameWrapper: {
display: 'flex',
alignItems: 'baseline',
gap: 6,
minWidth: 0,
flex: 1,
lineHeight: 1.2,
} as const,
name: {
fontSize: 13,
fontWeight: 500,
color: THEME.fontPrimary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const,
login: {
fontSize: 12,
fontWeight: 400,
color: THEME.fontTertiary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const,
empty: {
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: THEME.fontTertiary,
fontSize: 13,
padding: 16,
textAlign: 'center',
} as const,
};
type LeaderboardProps = {
contributorColumnLabel: string;
countColumnLabel: string;
entries: LeaderboardEntry[];
loading: boolean;
error: string | null;
emptyMessage?: string;
};
const renderState = (content: ReactNode) => (
<div style={styles.empty}>{content}</div>
);
const Row = ({
rank,
entry,
countLabel,
}: {
rank: number;
entry: LeaderboardEntry;
countLabel: string;
}) => {
const label = entry.name ?? entry.ghLogin ?? 'Unknown';
const profileHref = entry.ghLogin
? `https://github.com/${entry.ghLogin}`
: undefined;
const onEnter = (e: React.MouseEvent<HTMLElement>) => {
e.currentTarget.style.backgroundColor = THEME.bgSecondary;
};
const onLeave = (e: React.MouseEvent<HTMLElement>) => {
e.currentTarget.style.backgroundColor = THEME.bgPrimary;
};
const cells = (
<>
<div style={{ ...styles.cell, ...styles.rankCell }}>{rank}</div>
<div style={{ ...styles.cell, ...styles.contributorCell }}>
{entry.avatarUrl ? (
<img src={entry.avatarUrl} alt="" style={styles.avatar} />
) : (
<div style={styles.avatar} />
)}
<div style={styles.nameWrapper}>
<span style={styles.name}>{label}</span>
{entry.ghLogin && entry.ghLogin !== label && (
<span style={styles.login}>@{entry.ghLogin}</span>
)}
</div>
</div>
<div
style={{ ...styles.cell, ...styles.countCell, ...styles.cellLast }}
title={countLabel}
>
{entry.count}
</div>
</>
);
if (profileHref) {
return (
<a
href={profileHref}
target="_blank"
rel="noopener noreferrer"
style={{ ...styles.row, cursor: 'pointer' }}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
>
{cells}
</a>
);
}
return (
<div style={styles.row} onMouseEnter={onEnter} onMouseLeave={onLeave}>
{cells}
</div>
);
};
export const Leaderboard = ({
contributorColumnLabel,
countColumnLabel,
entries,
loading,
error,
emptyMessage = 'No activity in the last 90 days',
}: LeaderboardProps) => (
<div style={styles.root}>
<div style={styles.headerRow}>
<div style={{ ...styles.headerCell, ...styles.rankCell }}>#</div>
<div style={{ ...styles.headerCell, ...styles.contributorCell }}>
{contributorColumnLabel}
</div>
<div
style={{
...styles.headerCell,
...styles.countCell,
...styles.headerCellLast,
}}
>
{countColumnLabel}
</div>
</div>
{error ? (
renderState(error)
) : loading && entries.length === 0 ? (
renderState('Loading...')
) : entries.length === 0 ? (
renderState(emptyMessage)
) : (
<div style={styles.table}>
{entries.map((entry, i) => (
<Row
key={entry.id}
rank={i + 1}
entry={entry}
countLabel={countColumnLabel}
/>
))}
</div>
)}
</div>
);
@@ -0,0 +1,23 @@
export const THEME = {
fontFamily:
'var(--t-font-family, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)',
borderRadius: 'var(--t-border-radius-sm, 4px)',
fontPrimary: 'var(--t-font-color-primary)',
fontSecondary: 'var(--t-font-color-secondary)',
fontTertiary: 'var(--t-font-color-tertiary)',
fontLight: 'var(--t-font-color-light)',
borderLight: 'var(--t-border-color-light)',
borderMedium: 'var(--t-border-color-medium)',
bgPrimary: 'var(--t-background-primary)',
bgSecondary: 'var(--t-background-secondary)',
bgTertiary: 'var(--t-background-tertiary)',
bgTransparentLight: 'var(--t-background-transparent-light)',
bgTransparentLighter: 'var(--t-background-transparent-lighter)',
chartAuthored: 'var(--t-color-purple)',
chartMerged: 'var(--t-color-green)',
chartReviewed: 'var(--t-color-blue)',
} as const;
@@ -0,0 +1,24 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { PROJECT_ITEM_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';
export const ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'b35aed82-2c01-4f37-b3f9-cf67dc269d2c';
export default defineField({
universalIdentifier:
ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'assignedProjectItems',
label: 'Assigned Items',
icon: 'IconLayoutKanban',
relationTargetObjectMetadataUniversalIdentifier:
PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,22 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object';
import { ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/fields/author-on-issue.field';
export const AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'31f185c0-d2a2-47e2-9132-8cbac05f174a';
export default defineField({
universalIdentifier: AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'authoredIssues',
label: 'Authored Issues',
icon: 'IconBug',
relationTargetObjectMetadataUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,23 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object';
import { AUTHOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/author-on-pull-request.field';
export const AUTHORED_PRS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'c3d4e5f6-2b3c-4d4e-9f6a-7b8c9d0e1f2a';
export default defineField({
universalIdentifier: AUTHORED_PRS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'authoredPrs',
label: 'Authored PRs',
icon: 'IconGitPullRequest',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
AUTHOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,24 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object';
import { MERGER_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/merger-on-pull-request.field';
export const MERGED_PULL_REQUESTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'02ec0165-fb5a-46f9-b7c7-a0fd605befa2';
export default defineField({
universalIdentifier:
MERGED_PULL_REQUESTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'mergedPullRequests',
label: 'Merged Pull Requests',
icon: 'IconGitMerge',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
MERGER_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,23 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object';
import { REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/fields/reviewer-on-review-event.field';
export const REVIEW_EVENTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'e4f5a6b7-8c9d-4e0f-a1b2-c3d4e5f6a7b8';
export default defineField({
universalIdentifier: REVIEW_EVENTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'reviewEvents',
label: 'Review Events',
icon: 'IconEye',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,23 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/objects/pull-request-review.object';
import { REVIEWER_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/fields/reviewer-on-review.field';
export const REVIEWS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'564e7f97-74dc-4e9b-a4bc-ed4215ab9ac7';
export default defineField({
universalIdentifier: REVIEWS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'reviews',
label: 'Reviews',
icon: 'IconEye',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
REVIEWER_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,693 @@
import { useEffect, useMemo, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
objectMetadataItem,
useRecordId,
} from 'twenty-sdk/front-component';
import { THEME } from 'src/modules/github/contributor/components/theme';
import { callAppRoute } from 'src/modules/shared/call-app-route';
type Period = 'week' | 'month' | '3months' | 'year';
type Bucket = {
key: string;
label: string;
start: string;
end: string;
prAuthored: number;
prMerged: number;
prReviewed: number;
};
type ContributorInfo = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: string | null;
};
type StatsResponse = {
contributor: ContributorInfo;
period: Period;
granularity: 'day' | 'week' | 'month';
buckets: Bucket[];
totals: { prAuthored: number; prMerged: number; prReviewed: number };
truncated: {
prAuthored: boolean;
prMerged: boolean;
prReviewed: boolean;
};
error?: string;
};
type SearchResponse = {
contributors: ContributorInfo[];
};
const readSerializedValue = (
e: React.SyntheticEvent<HTMLElement>,
): string | undefined => {
const obj = e as { detail?: { value?: string }; value?: string };
if (typeof obj.detail?.value === 'string') return obj.detail.value;
if (typeof obj.value === 'string') return obj.value;
return undefined;
};
const onValueChange =
(fn: (value: string) => void) =>
(e: React.SyntheticEvent<HTMLElement>) => {
const v = readSerializedValue(e);
if (typeof v === 'string') fn(v);
};
const PERIOD_OPTIONS: Array<{ value: Period; label: string }> = [
{ value: 'week', label: 'Last week' },
{ value: 'month', label: 'Last month' },
{ value: '3months', label: 'Last 3 months' },
{ value: 'year', label: 'Last year' },
];
const WIDGET_HEADER_HEIGHT = 24;
const styles = {
root: {
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
minHeight: 0,
boxSizing: 'border-box',
padding: 12,
gap: 12,
fontFamily: THEME.fontFamily,
color: THEME.fontPrimary,
background: THEME.bgSecondary,
overflow: 'hidden',
} as const,
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
flexWrap: 'wrap',
padding: '8px 12px',
background: THEME.bgPrimary,
border: `1px solid ${THEME.borderLight}`,
borderRadius: THEME.borderRadius,
boxSizing: 'border-box',
flexShrink: 0,
} as const,
contributor: {
display: 'flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
} as const,
avatar: {
width: 24,
height: 24,
borderRadius: '50%',
objectFit: 'cover',
background: THEME.bgTertiary,
flexShrink: 0,
} as const,
smallAvatar: {
width: 16,
height: 16,
borderRadius: '50%',
objectFit: 'cover',
background: THEME.bgTertiary,
flexShrink: 0,
} as const,
nameCol: {
display: 'flex',
alignItems: 'baseline',
minWidth: 0,
gap: 6,
lineHeight: 1.2,
} as const,
name: {
fontSize: 14,
fontWeight: 500,
color: THEME.fontPrimary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const,
login: {
fontSize: 12,
fontWeight: 400,
color: THEME.fontTertiary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const,
controls: {
display: 'flex',
alignItems: 'center',
gap: 6,
flexWrap: 'wrap',
} as const,
select: {
fontFamily: THEME.fontFamily,
fontSize: 13,
height: 28,
lineHeight: '26px',
padding: '0 24px 0 10px',
border: `1px solid ${THEME.borderMedium}`,
borderRadius: THEME.borderRadius,
background: `${THEME.bgPrimary} url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 20 20' fill='gray'><path d='M10 13l-5-5h10z'/></svg>") no-repeat right 8px center`,
color: THEME.fontPrimary,
cursor: 'pointer',
outline: 'none',
appearance: 'none',
WebkitAppearance: 'none',
MozAppearance: 'none',
} as const,
buttonNeutral: {
fontFamily: THEME.fontFamily,
fontSize: 13,
fontWeight: 500,
height: 28,
lineHeight: '26px',
padding: '0 10px',
border: `1px solid ${THEME.borderMedium}`,
borderRadius: THEME.borderRadius,
background: THEME.bgPrimary,
color: THEME.fontPrimary,
cursor: 'pointer',
outline: 'none',
} as const,
body: {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
gap: 12,
} as const,
chartCard: {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
border: `1px solid ${THEME.borderLight}`,
borderRadius: THEME.borderRadius,
padding: 8,
background: THEME.bgPrimary,
boxSizing: 'border-box',
} as const,
chartHeader: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: WIDGET_HEADER_HEIGHT,
padding: '0 4px',
flexShrink: 0,
} as const,
chartTitle: {
fontSize: 13,
fontWeight: 500,
color: THEME.fontPrimary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const,
chartTotal: {
fontSize: 13,
fontWeight: 500,
color: THEME.fontTertiary,
fontVariantNumeric: 'tabular-nums',
flexShrink: 0,
} as const,
chartContainer: {
flex: '1 1 0%',
minHeight: 90,
width: '100%',
position: 'relative',
marginTop: 4,
} as const,
centered: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
color: THEME.fontTertiary,
fontSize: 13,
textAlign: 'center',
padding: 16,
} as const,
searchWrapper: {
display: 'flex',
flexDirection: 'column',
gap: 8,
flex: 1,
minHeight: 0,
} as const,
searchInput: {
fontFamily: THEME.fontFamily,
fontSize: 13,
height: 28,
padding: '0 10px',
border: `1px solid ${THEME.borderMedium}`,
borderRadius: THEME.borderRadius,
outline: 'none',
background: THEME.bgPrimary,
color: THEME.fontPrimary,
} as const,
searchList: {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
overflowY: 'auto',
border: `1px solid ${THEME.borderLight}`,
borderRadius: THEME.borderRadius,
background: THEME.bgPrimary,
} as const,
searchItem: {
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 10px',
cursor: 'pointer',
borderBottom: `1px solid ${THEME.borderLight}`,
fontSize: 13,
color: THEME.fontPrimary,
} as const,
truncatedHint: {
fontSize: 11,
color: THEME.fontTertiary,
marginTop: 4,
paddingLeft: 4,
} as const,
};
type BarChartProps = {
buckets: Bucket[];
valueKey: 'prAuthored' | 'prMerged' | 'prReviewed';
color: string;
};
const CHART_PADDING = { top: 6, right: 4, bottom: 16, left: 22 };
const BarChart = ({ buckets, valueKey, color }: BarChartProps) => {
const yMax = useMemo(() => {
const m = buckets.reduce((acc, b) => Math.max(acc, b[valueKey]), 0);
if (m === 0) return 1;
return m <= 5 ? m : Math.ceil(m / 5) * 5;
}, [buckets, valueKey]);
const yTicks = useMemo(() => [0, Math.round(yMax / 2), yMax], [yMax]);
const n = buckets.length;
const labelEvery = (() => {
if (n <= 6) return 1;
if (n <= 14) return 2;
return Math.ceil(n / 6);
})();
return (
<div style={styles.chartContainer}>
<div
style={{
position: 'absolute',
left: 0,
top: CHART_PADDING.top,
bottom: CHART_PADDING.bottom,
width: CHART_PADDING.left,
}}
>
{yTicks.map((t) => {
const bottomPct = (t / yMax) * 100;
return (
<div
key={t}
style={{
position: 'absolute',
right: 4,
bottom: `${bottomPct}%`,
transform: 'translateY(50%)',
fontSize: 10,
color: THEME.fontTertiary,
lineHeight: 1,
fontVariantNumeric: 'tabular-nums',
}}
>
{t}
</div>
);
})}
</div>
<div
style={{
position: 'absolute',
left: CHART_PADDING.left,
right: CHART_PADDING.right,
top: CHART_PADDING.top,
bottom: CHART_PADDING.bottom,
}}
>
{yTicks.map((t, i) => {
const bottomPct = (t / yMax) * 100;
return (
<div
key={t}
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: `${bottomPct}%`,
borderTop: `1px ${i === 0 ? 'solid' : 'dotted'} ${THEME.borderLight}`,
pointerEvents: 'none',
}}
/>
);
})}
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'flex-end',
gap: 2,
}}
>
{buckets.map((b, i) => {
const v = b[valueKey];
const heightPct = (v / yMax) * 100;
const showLabel = i % labelEvery === 0 || i === n - 1;
return (
<div
key={b.key}
title={`${b.label}: ${v}`}
style={{
flex: '1 1 0',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-end',
position: 'relative',
minWidth: 0,
}}
>
<div
style={{
width: '70%',
maxWidth: 22,
height: `${heightPct}%`,
minHeight: v > 0 ? 2 : 0,
background: color,
borderRadius: '2px 2px 0 0',
}}
/>
{showLabel && (
<div
style={{
position: 'absolute',
top: '100%',
marginTop: 4,
fontSize: 10,
color: THEME.fontTertiary,
whiteSpace: 'nowrap',
lineHeight: 1,
pointerEvents: 'none',
fontVariantNumeric: 'tabular-nums',
}}
>
{b.label}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
};
const ContributorStats = () => {
const recordId = useRecordId();
const [period, setPeriod] = useState<Period>('month');
const [selectedId, setSelectedId] = useState<string | null>(recordId);
const [stats, setStats] = useState<StatsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResponse['contributors']>([]);
const [searchLoading, setSearchLoading] = useState(false);
useEffect(() => {
setSelectedId(recordId);
}, [recordId]);
useEffect(() => {
if (selectedId) return;
let cancelled = false;
setSearchLoading(true);
const handle = setTimeout(async () => {
try {
const res = (await callAppRoute('/contributors/search', {
query: searchQuery,
limit: 20,
})) as SearchResponse;
if (!cancelled) setSearchResults(res.contributors ?? []);
} catch (err) {
if (!cancelled) {
enqueueSnackbar({
message:
err instanceof Error ? err.message : 'Failed to search contributors',
variant: 'error',
});
}
} finally {
if (!cancelled) setSearchLoading(false);
}
}, 200);
return () => {
cancelled = true;
clearTimeout(handle);
};
}, [selectedId, searchQuery]);
useEffect(() => {
if (!selectedId) {
setStats(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
(async () => {
try {
const res = (await callAppRoute('/contributors/stats', {
contributorId: selectedId,
period,
})) as StatsResponse;
if (cancelled) return;
if (res.error) {
setError(res.error);
setStats(null);
} else {
setStats(res);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load stats');
setStats(null);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedId, period]);
const renderHeader = (contributor: ContributorInfo | null) => (
<div style={styles.header}>
<div style={styles.contributor}>
{contributor?.avatarUrl ? (
<img src={contributor.avatarUrl} alt="" style={styles.avatar} />
) : (
<div style={styles.avatar} />
)}
<div style={styles.nameCol}>
<span style={styles.name}>
{contributor?.name ?? contributor?.ghLogin ?? 'Contributor stats'}
</span>
{contributor?.ghLogin &&
contributor.ghLogin !== contributor.name && (
<span style={styles.login}>@{contributor.ghLogin}</span>
)}
</div>
</div>
<div style={styles.controls}>
{selectedId && !recordId && (
<button
type="button"
onClick={() => setSelectedId(null)}
style={styles.buttonNeutral}
>
Change
</button>
)}
<select
value={period}
onChange={onValueChange((v) => setPeriod(v as Period))}
style={styles.select}
>
{PERIOD_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
);
if (!selectedId) {
return (
<div style={styles.root}>
{renderHeader(null)}
<div style={styles.searchWrapper}>
<input
value={searchQuery}
onInput={onValueChange(setSearchQuery)}
onChange={onValueChange(setSearchQuery)}
placeholder="Search a contributor by name or GitHub login..."
style={styles.searchInput}
/>
<div style={styles.searchList}>
{searchLoading && searchResults.length === 0 && (
<div style={{ ...styles.searchItem, color: THEME.fontTertiary }}>
Searching...
</div>
)}
{!searchLoading && searchResults.length === 0 && (
<div style={{ ...styles.searchItem, color: THEME.fontTertiary }}>
No contributors found.
</div>
)}
{searchResults.map((eng) => (
<div
key={eng.id}
role="button"
tabIndex={0}
onClick={() => setSelectedId(eng.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') setSelectedId(eng.id);
}}
style={styles.searchItem}
>
{eng.avatarUrl ? (
<img src={eng.avatarUrl} alt="" style={styles.smallAvatar} />
) : (
<div style={styles.smallAvatar} />
)}
<div style={styles.nameCol}>
<span style={styles.name}>
{eng.name ?? eng.ghLogin ?? 'Unknown'}
</span>
{eng.ghLogin && eng.ghLogin !== eng.name && (
<span style={styles.login}>@{eng.ghLogin}</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
return (
<div style={styles.root}>
{renderHeader(stats?.contributor ?? null)}
<div style={styles.body}>
{loading && !stats && (
<div style={styles.centered}>Loading contributor stats...</div>
)}
{error && <div style={styles.centered}>{error}</div>}
{stats && (
<>
<div style={styles.chartCard}>
<div style={styles.chartHeader}>
<span style={styles.chartTitle}>PRs authored (merged)</span>
<span style={styles.chartTotal}>{stats.totals.prAuthored}</span>
</div>
<BarChart
buckets={stats.buckets}
valueKey="prAuthored"
color={THEME.chartAuthored}
/>
{stats.truncated.prAuthored && (
<span style={styles.truncatedHint}>
Showing partial data (results truncated).
</span>
)}
</div>
<div style={styles.chartCard}>
<div style={styles.chartHeader}>
<span style={styles.chartTitle}>PRs merged</span>
<span style={styles.chartTotal}>{stats.totals.prMerged}</span>
</div>
<BarChart
buckets={stats.buckets}
valueKey="prMerged"
color={THEME.chartMerged}
/>
{stats.truncated.prMerged && (
<span style={styles.truncatedHint}>
Showing partial data (results truncated).
</span>
)}
</div>
<div style={styles.chartCard}>
<div style={styles.chartHeader}>
<span style={styles.chartTitle}>PRs reviewed</span>
<span style={styles.chartTotal}>{stats.totals.prReviewed}</span>
</div>
<BarChart
buckets={stats.buckets}
valueKey="prReviewed"
color={THEME.chartReviewed}
/>
{stats.truncated.prReviewed && (
<span style={styles.truncatedHint}>
Showing partial data (results truncated).
</span>
)}
</div>
</>
)}
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: '6c2f1a8d-4b9e-4f7a-9c5d-1e8b2a3d4c5f',
name: 'Contributor Stats',
description:
'Displays time-bucketed charts of PRs authored (merged only), merged and reviewed by a contributor over the last week, month, 3 months or year.',
component: ContributorStats,
command: {
universalIdentifier: '7d3f2b9e-5c0a-4e8b-ad6e-2f9c3b4d5e6a',
label: 'Contributor Stats',
icon: 'IconChartBar',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'contributor',
},
});
@@ -0,0 +1,106 @@
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;
repos: Array<{ owner: string; repo: string; totalCount: number; pages: number }>;
};
type FetchPageResponse = {
contributorCount: number;
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
type SyncStatus = 'syncing' | 'done' | 'error';
const FetchContributors = () => {
const [status, setStatus] = useState<SyncStatus>('syncing');
useEffect(() => {
const run = async () => {
try {
const counts = (await callAppRoute(
'/github/count-contributors',
{},
)) as CountResponse;
if (counts.repos.length === 0) {
throw new Error(
'No repos resolved. Set GITHUB_REPOS in the application variables.',
);
}
const totalPages = Math.max(counts.totalPages, 1);
let pagesProcessed = 0;
let totalSynced = 0;
for (const { owner, repo } of counts.repos) {
let cursor: string | null = null;
let hasMore = true;
while (hasMore) {
const data = (await callAppRoute('/github/fetch-contributors', {
owner,
repo,
cursor,
})) as FetchPageResponse;
totalSynced += data.contributorCount;
hasMore = data.hasMore && data.contributorCount > 0;
cursor = data.endCursor;
pagesProcessed++;
updateProgress(
Math.min(Math.round((pagesProcessed / totalPages) * 100), 99),
);
}
}
updateProgress(100);
enqueueSnackbar({
message: `Synced ${totalSynced} contributors`,
variant: 'success',
});
setStatus('done');
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to fetch contributors';
enqueueSnackbar({ message, variant: 'error' });
setStatus('error');
} finally {
unmountFrontComponent();
}
};
run();
}, []);
if (status === 'syncing') return <div>Fetching contributors...</div>;
if (status === 'error') return <div>Failed to fetch contributors.</div>;
return <div>Done</div>;
};
export default defineFrontComponent({
universalIdentifier: '08f40f82-24ed-4f3e-8c99-695151e90e38',
name: 'Fetch Contributors',
description:
'Fetches contributors from every configured GitHub repo (GITHUB_REPOS) and upserts them as Contributor records.',
isHeadless: true,
component: FetchContributors,
command: {
universalIdentifier: '4640992f-c2c9-4bba-b5df-9c8f05dc9e80',
label: 'Fetch Contributors',
icon: 'IconUsers',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'contributor',
},
});
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import {
Leaderboard,
type LeaderboardEntry,
} from 'src/modules/github/contributor/components/leaderboard';
import { callAppRoute } from 'src/modules/shared/call-app-route';
type Response = {
topAuthors: LeaderboardEntry[];
};
const TopPRAuthors = () => {
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
(async () => {
try {
const res = (await callAppRoute('/contributors/top', {
days: 90,
limit: 20,
kind: 'authors',
})) as Response;
if (!cancelled) setEntries(res.topAuthors ?? []);
} catch (err) {
if (!cancelled) {
const message =
err instanceof Error ? err.message : 'Failed to load top authors';
setError(message);
enqueueSnackbar({ message, variant: 'error' });
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<Leaderboard
contributorColumnLabel="Contributor"
countColumnLabel="PRs"
entries={entries}
loading={loading}
error={error}
/>
);
};
export const TOP_PR_AUTHORS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'a1d4f7e2-9b3c-4e8a-bf21-5d6c8a9b2e3f';
export default defineFrontComponent({
universalIdentifier: TOP_PR_AUTHORS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Top PR Authors',
description: 'Leaderboard of the top 20 pull-request authors over the last 90 days.',
component: TopPRAuthors,
});
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import {
Leaderboard,
type LeaderboardEntry,
} from 'src/modules/github/contributor/components/leaderboard';
import { callAppRoute } from 'src/modules/shared/call-app-route';
type Response = {
topReviewers: LeaderboardEntry[];
};
const TopReviewers = () => {
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
(async () => {
try {
const res = (await callAppRoute('/contributors/top', {
days: 90,
limit: 20,
kind: 'reviewers',
})) as Response;
if (!cancelled) setEntries(res.topReviewers ?? []);
} catch (err) {
if (!cancelled) {
const message =
err instanceof Error ? err.message : 'Failed to load top reviewers';
setError(message);
enqueueSnackbar({ message, variant: 'error' });
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<Leaderboard
contributorColumnLabel="Contributor"
countColumnLabel="Reviews"
entries={entries}
loading={loading}
error={error}
/>
);
};
export const TOP_REVIEWERS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'b2e5f8a3-ac4d-4f9b-8032-6e7d9bac3f4a';
export default defineFrontComponent({
universalIdentifier: TOP_REVIEWERS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Top Reviewers',
description: 'Leaderboard of the top 20 pull-request reviewers over the last 90 days.',
component: TopReviewers,
});
@@ -0,0 +1,20 @@
import { githubGraphql } from 'src/modules/github/connector/github-client';
const QUERY = `
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
mentionableUsers { totalCount }
}
}`;
type Response = {
repository: { mentionableUsers: { totalCount: number } } | null;
};
export async function countContributors(
owner: string,
name: string,
): Promise<number> {
const data = await githubGraphql<Response>(QUERY, { owner, name });
return data.repository?.mentionableUsers.totalCount ?? 0;
}
@@ -0,0 +1,52 @@
import {
EMPTY_PAGE,
type GithubPage,
githubGraphql,
} from 'src/modules/github/connector/github-client';
const QUERY = `
query($owner: String!, $name: String!, $cursor: String) {
repository(owner: $owner, name: $name) {
mentionableUsers(first: 100, after: $cursor) {
totalCount
pageInfo { hasNextPage endCursor }
nodes {
login
databaseId
avatarUrl
}
}
}
}`;
export type GqlContributor = {
login: string;
databaseId: number;
avatarUrl: string;
};
type Response = {
repository: { mentionableUsers: GithubPage<GqlContributor> } | null;
};
export async function fetchContributors(
owner: string,
name: string,
cursor: string | null = null,
): Promise<{
contributors: GqlContributor[];
totalCount: number;
hasMore: boolean;
endCursor: string | null;
}> {
const data = await githubGraphql<Response>(QUERY, { owner, name, cursor });
const conn = data.repository?.mentionableUsers;
if (!conn) return { contributors: [], ...EMPTY_PAGE };
return {
contributors: conn.nodes,
totalCount: conn.totalCount,
hasMore: conn.pageInfo.hasNextPage,
endCursor: conn.pageInfo.endCursor,
};
}
@@ -0,0 +1,21 @@
import type { ContributorRow } from 'src/modules/github/contributor/types/contributor-row';
import { chunkedBatchCreate } from 'src/modules/shared/twenty-client';
export async function batchUpsertContributors(
items: Array<{
ghLogin: string;
name: string;
githubId: number;
avatarUrl?: { primaryLinkLabel: string; primaryLinkUrl: string; secondaryLinks: null } | null;
contributions?: number;
}>,
): Promise<ContributorRow[]> {
return chunkedBatchCreate('createContributors', items, {
id: true,
ghLogin: true,
name: true,
githubId: true,
avatarUrl: true,
contributions: true,
}) as Promise<ContributorRow[]>;
}
@@ -0,0 +1,27 @@
import type { ContributorRow } from 'src/modules/github/contributor/types/contributor-row';
import { getClient } from 'src/modules/shared/twenty-client';
export async function findContributorByGhLogin(
ghLogin: string,
): Promise<ContributorRow | null> {
const client = getClient();
const res = await client.query({
contributors: {
__args: {
filter: { ghLogin: { eq: ghLogin } },
first: 1,
},
edges: {
node: {
id: true,
ghLogin: true,
name: true,
githubId: true,
},
},
},
});
const edges = res.contributors?.edges;
return (edges?.[0]?.node as ContributorRow | undefined) ?? null;
}
@@ -0,0 +1,52 @@
import { getClient } from 'src/modules/shared/twenty-client';
export type ContributorSearchResult = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: string | null;
};
export async function searchContributors(
query: string,
limit: number,
): Promise<ContributorSearchResult[]> {
const client = getClient();
const filter =
query.length === 0
? undefined
: {
or: [
{ name: { ilike: `%${query}%` } },
{ ghLogin: { ilike: `%${query}%` } },
],
};
const res = await client.query({
contributors: {
__args: {
...(filter ? { filter } : {}),
orderBy: [{ name: 'AscNullsLast' }],
first: limit,
},
edges: {
node: {
id: true,
name: true,
ghLogin: true,
avatarUrl: { primaryLinkUrl: true },
},
},
},
});
const edges = res.contributors?.edges ?? [];
return edges.map((e) => ({
id: e.node.id,
name: e.node.name ?? null,
ghLogin: e.node.ghLogin ?? null,
avatarUrl: e.node.avatarUrl?.primaryLinkUrl ?? null,
}));
}
@@ -0,0 +1,376 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { getClient } from 'src/modules/shared/twenty-client';
export type StatsPeriod = 'week' | 'month' | '3months' | 'year';
type Granularity = 'day' | 'week' | 'month';
type Bucket = {
key: string;
label: string;
start: string;
end: string;
prAuthored: number;
prMerged: number;
prReviewed: number;
};
type Totals = {
prAuthored: number;
prMerged: number;
prReviewed: number;
};
type ContributorInfo = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: string | null;
};
type ContributorStatsPayload = {
contributorId?: string;
period?: StatsPeriod;
};
type ContributorStatsResponse =
| {
contributor: ContributorInfo;
period: StatsPeriod;
granularity: Granularity;
buckets: Bucket[];
totals: Totals;
truncated: {
prAuthored: boolean;
prMerged: boolean;
prReviewed: boolean;
};
}
| { error: string };
const PAGE_SIZE = 100;
const MAX_PAGES = 30;
const PERIOD_CONFIG: Record<
StatsPeriod,
{ granularity: Granularity; rangeMs: number; bucketCount: number }
> = {
week: { granularity: 'day', rangeMs: 7 * 24 * 3600 * 1000, bucketCount: 7 },
month: { granularity: 'day', rangeMs: 30 * 24 * 3600 * 1000, bucketCount: 30 },
'3months': {
granularity: 'week',
rangeMs: 13 * 7 * 24 * 3600 * 1000,
bucketCount: 13,
},
year: {
granularity: 'month',
rangeMs: 365 * 24 * 3600 * 1000,
bucketCount: 12,
},
};
const startOfUtcDay = (d: Date): Date =>
new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
const startOfUtcWeek = (d: Date): Date => {
const day = startOfUtcDay(d);
const dow = day.getUTCDay();
const diff = (dow + 6) % 7;
day.setUTCDate(day.getUTCDate() - diff);
return day;
};
const startOfUtcMonth = (d: Date): Date =>
new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
const addBucket = (start: Date, granularity: Granularity, n: number): Date => {
const out = new Date(start);
if (granularity === 'day') out.setUTCDate(out.getUTCDate() + n);
else if (granularity === 'week') out.setUTCDate(out.getUTCDate() + 7 * n);
else out.setUTCMonth(out.getUTCMonth() + n);
return out;
};
const bucketStartFor = (d: Date, granularity: Granularity): Date => {
if (granularity === 'day') return startOfUtcDay(d);
if (granularity === 'week') return startOfUtcWeek(d);
return startOfUtcMonth(d);
};
const formatBucketLabel = (start: Date, granularity: Granularity): string => {
const month = start.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
if (granularity === 'month') {
return `${month} ${String(start.getUTCFullYear()).slice(2)}`;
}
return `${month} ${start.getUTCDate()}`;
};
const buildBuckets = (
now: Date,
period: StatsPeriod,
): { buckets: Bucket[]; rangeStart: Date; granularity: Granularity } => {
const { granularity, bucketCount } = PERIOD_CONFIG[period];
const lastBucketStart = bucketStartFor(now, granularity);
const firstBucketStart = addBucket(
lastBucketStart,
granularity,
-(bucketCount - 1),
);
const buckets: Bucket[] = [];
for (let i = 0; i < bucketCount; i++) {
const start = addBucket(firstBucketStart, granularity, i);
const end = addBucket(start, granularity, 1);
buckets.push({
key: start.toISOString(),
label: formatBucketLabel(start, granularity),
start: start.toISOString(),
end: end.toISOString(),
prAuthored: 0,
prMerged: 0,
prReviewed: 0,
});
}
return { buckets, rangeStart: firstBucketStart, granularity };
};
type Edge<T> = { node: T };
type Connection<T> = {
edges: Edge<T>[];
pageInfo: { hasNextPage: boolean; endCursor: string | null };
};
async function paginateUntil<T>(
fetchPage: (cursor: string | null) => Promise<Connection<T>>,
isOlderThanRange: (item: T) => boolean,
): Promise<{ items: T[]; truncated: boolean }> {
const items: T[] = [];
let cursor: string | null = null;
for (let page = 0; page < MAX_PAGES; page++) {
const conn = await fetchPage(cursor);
let stop = false;
for (const edge of conn.edges) {
if (isOlderThanRange(edge.node)) {
stop = true;
break;
}
items.push(edge.node);
}
if (stop) return { items, truncated: false };
if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor) {
return { items, truncated: false };
}
cursor = conn.pageInfo.endCursor;
}
return { items, truncated: true };
}
type MergedPrNode = { mergedAt: string | null };
type ReviewNode = { firstSubmittedAt: string | null };
const fetchContributorInfo = async (
contributorId: string,
): Promise<ContributorInfo | null> => {
const client = getClient();
const res = await client.query({
contributors: {
__args: { filter: { id: { eq: contributorId } }, first: 1 },
edges: {
node: {
id: true,
name: true,
ghLogin: true,
avatarUrl: { primaryLinkUrl: true },
},
},
},
});
const node = res.contributors?.edges?.[0]?.node;
if (!node) return null;
return {
id: node.id,
name: node.name ?? null,
ghLogin: node.ghLogin ?? null,
avatarUrl: node.avatarUrl?.primaryLinkUrl ?? null,
};
};
const handler = async (
event: RoutePayload<ContributorStatsPayload>,
): Promise<ContributorStatsResponse> => {
const contributorId = event.body?.contributorId;
const period: StatsPeriod = event.body?.period ?? 'month';
if (!contributorId) {
return { error: 'contributorId is required' };
}
if (!(period in PERIOD_CONFIG)) {
return { error: `Unsupported period: ${period}` };
}
const contributor = await fetchContributorInfo(contributorId);
if (!contributor) {
return { error: 'Contributor not found' };
}
const now = new Date();
const { buckets, rangeStart, granularity } = buildBuckets(now, period);
const rangeStartMs = rangeStart.getTime();
const bucketIndex = new Map<string, number>();
buckets.forEach((b, i) => bucketIndex.set(b.key, i));
const client = getClient();
const mergedResult = await paginateUntil<MergedPrNode>(
async (cursor) => {
const res = await client.query({
pullRequests: {
__args: {
filter: {
and: [
{ mergerId: { eq: contributorId } },
{ state: { eq: 'MERGED' } },
],
},
orderBy: [{ mergedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: { node: { mergedAt: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequests as Connection<MergedPrNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.mergedAt) return false;
return new Date(n.mergedAt).getTime() < rangeStartMs;
},
);
const authoredResult = await paginateUntil<MergedPrNode>(
async (cursor) => {
const res = await client.query({
pullRequests: {
__args: {
filter: {
and: [
{ authorId: { eq: contributorId } },
{ state: { eq: 'MERGED' } },
],
},
orderBy: [{ mergedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: { node: { mergedAt: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequests as Connection<MergedPrNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.mergedAt) return false;
return new Date(n.mergedAt).getTime() < rangeStartMs;
},
);
const reviewedResult = await paginateUntil<ReviewNode>(
async (cursor) => {
const res = await client.query({
pullRequestReviews: {
__args: {
filter: { reviewerId: { eq: contributorId } },
orderBy: [{ firstSubmittedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: { node: { firstSubmittedAt: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequestReviews as Connection<ReviewNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.firstSubmittedAt) return false;
return new Date(n.firstSubmittedAt).getTime() < rangeStartMs;
},
);
const totals: Totals = { prAuthored: 0, prMerged: 0, prReviewed: 0 };
for (const pr of authoredResult.items) {
if (!pr.mergedAt) continue;
const d = new Date(pr.mergedAt);
if (d.getTime() < rangeStartMs) continue;
const key = bucketStartFor(d, granularity).toISOString();
const idx = bucketIndex.get(key);
if (idx === undefined) continue;
buckets[idx].prAuthored++;
totals.prAuthored++;
}
for (const pr of mergedResult.items) {
if (!pr.mergedAt) continue;
const d = new Date(pr.mergedAt);
if (d.getTime() < rangeStartMs) continue;
const key = bucketStartFor(d, granularity).toISOString();
const idx = bucketIndex.get(key);
if (idx === undefined) continue;
buckets[idx].prMerged++;
totals.prMerged++;
}
for (const r of reviewedResult.items) {
if (!r.firstSubmittedAt) continue;
const d = new Date(r.firstSubmittedAt);
if (d.getTime() < rangeStartMs) continue;
const key = bucketStartFor(d, granularity).toISOString();
const idx = bucketIndex.get(key);
if (idx === undefined) continue;
buckets[idx].prReviewed++;
totals.prReviewed++;
}
return {
contributor,
period,
granularity,
buckets,
totals,
truncated: {
prAuthored: authoredResult.truncated,
prMerged: mergedResult.truncated,
prReviewed: reviewedResult.truncated,
},
};
};
export default defineLogicFunction({
universalIdentifier: 'a3c9e1b6-2f47-4d8a-9b0f-7e6d1a2c3b4f',
name: 'contributor-stats',
description:
'Returns time-bucketed counts of PRs authored (merged only), merged and reviewed by a contributor over the selected period.',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/contributors/stats',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,28 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { countAcrossRepos } from 'src/modules/github/connector/count-across-repos';
import { countContributors } from 'src/modules/github/contributor/graphql/github/count-contributors';
type CountContributorsPayload = {
repos?: string[];
};
const handler = async (event: RoutePayload<CountContributorsPayload>) =>
countAcrossRepos(
event.body?.repos,
countContributors,
'count-contributors',
);
export default defineLogicFunction({
universalIdentifier: 'fe0a6f00-0d63-4cb9-9b3c-1d8186181830',
name: 'count-contributors',
description:
'Counts contributors across configured repos and returns the per-repo page split.',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/github/count-contributors',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,70 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import {
fetchContributors,
type GqlContributor,
} from 'src/modules/github/contributor/graphql/github/fetch-contributors';
import { batchUpsertContributors } from 'src/modules/github/contributor/graphql/mutations/batch-upsert';
import { isFixtureAllowed } from 'src/modules/shared/fixtures';
export type FetchContributorsFixturePage = {
contributors: GqlContributor[];
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
type FetchContributorsPayload = {
owner: string;
repo: string;
cursor?: string | null;
fixturePage?: FetchContributorsFixturePage;
};
const handler = async (event: RoutePayload<FetchContributorsPayload>) => {
const { owner, repo, cursor = null, fixturePage } = event.body ?? {};
if (!owner || !repo) {
return { error: 'owner and repo are required' };
}
const result =
fixturePage && isFixtureAllowed()
? fixturePage
: await fetchContributors(owner, repo, cursor);
const { contributors, totalCount, hasMore, endCursor } = result;
if (contributors.length === 0) {
return { contributorCount: 0, totalCount, hasMore: false, endCursor: null };
}
const contributorData = contributors.map((c: GqlContributor) => ({
ghLogin: c.login,
name: c.login,
githubId: c.databaseId ?? 0,
avatarUrl: c.avatarUrl
? { primaryLinkLabel: c.login, primaryLinkUrl: c.avatarUrl, secondaryLinks: null }
: null,
}));
await batchUpsertContributors(contributorData);
return {
contributorCount: contributors.length,
totalCount,
hasMore,
endCursor,
};
};
export default defineLogicFunction({
universalIdentifier: 'ed9ba981-4172-4924-a18f-51fc6dcbcb23',
name: 'fetch-contributors',
description:
'Fetches one page of contributors via GitHub GraphQL API and batch upserts them into the workspace.',
timeoutSeconds: 300,
handler,
httpRouteTriggerSettings: {
path: '/github/fetch-contributors',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,43 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import {
searchContributors,
type ContributorSearchResult,
} from 'src/modules/github/contributor/graphql/queries/search-contributors';
type SearchContributorsPayload = {
query?: string;
limit?: number;
};
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 50;
const handler = async (
event: RoutePayload<SearchContributorsPayload>,
): Promise<{ contributors: ContributorSearchResult[] }> => {
const queryInput = event.body?.query;
const rawQuery = typeof queryInput === 'string' ? queryInput.trim() : '';
const limitInput = event.body?.limit;
const limitNumber =
typeof limitInput === 'number' && Number.isFinite(limitInput)
? limitInput
: DEFAULT_LIMIT;
const limit = Math.min(Math.max(Math.floor(limitNumber), 1), MAX_LIMIT);
const contributors = await searchContributors(rawQuery, limit);
return { contributors };
};
export default defineLogicFunction({
universalIdentifier: 'b4d8f2a7-3e58-4f9b-ac1d-8f7e2b3c4d5e',
name: 'search-contributors',
description:
'Searches contributors by name or GitHub login for use in front-end pickers.',
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/contributors/search',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,257 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { isBotLogin } from 'src/modules/github/contributor/utils/is-bot-login';
import { getClient } from 'src/modules/shared/twenty-client';
type ContributorRef = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: string | null;
};
type LeaderboardEntry = ContributorRef & {
count: number;
};
type TopContributorsKind = 'authors' | 'reviewers' | 'both';
type TopContributorsPayload = {
days?: number;
limit?: number;
kind?: TopContributorsKind;
};
type TopContributorsResponse = {
days: number;
limit: number;
topAuthors: LeaderboardEntry[];
topReviewers: LeaderboardEntry[];
truncated: { authors: boolean; reviewers: boolean };
};
const PAGE_SIZE = 100;
const MAX_PAGES = 50;
const DEFAULT_DAYS = 90;
const DEFAULT_LIMIT = 10;
const MAX_LIMIT = 50;
const MAX_DAYS = 365;
type Edge<T> = { node: T };
type Connection<T> = {
edges: Edge<T>[];
pageInfo: { hasNextPage: boolean; endCursor: string | null };
};
async function paginateUntil<T>(
fetchPage: (cursor: string | null) => Promise<Connection<T>>,
isOlderThanRange: (item: T) => boolean,
): Promise<{ items: T[]; truncated: boolean }> {
const items: T[] = [];
let cursor: string | null = null;
for (let page = 0; page < MAX_PAGES; page++) {
const conn = await fetchPage(cursor);
let stop = false;
for (const edge of conn.edges) {
if (isOlderThanRange(edge.node)) {
stop = true;
break;
}
items.push(edge.node);
}
if (stop) return { items, truncated: false };
if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor) {
return { items, truncated: false };
}
cursor = conn.pageInfo.endCursor;
}
return { items, truncated: true };
}
type AuthorInfo = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: { primaryLinkUrl: string | null } | null;
};
type PrNode = {
githubCreatedAt: string | null;
author: AuthorInfo | null;
};
type ReviewNode = {
firstSubmittedAt: string | null;
reviewer: AuthorInfo | null;
};
const tally = (
items: { contributor: AuthorInfo | null }[],
limit: number,
): LeaderboardEntry[] => {
const counts = new Map<string, LeaderboardEntry>();
for (const item of items) {
const c = item.contributor;
if (!c) continue;
if (isBotLogin(c.ghLogin)) continue;
const existing = counts.get(c.id);
if (existing) {
existing.count += 1;
} else {
counts.set(c.id, {
id: c.id,
name: c.name ?? null,
ghLogin: c.ghLogin ?? null,
avatarUrl: c.avatarUrl?.primaryLinkUrl ?? null,
count: 1,
});
}
}
return Array.from(counts.values())
.sort((a, b) => b.count - a.count || (a.ghLogin ?? '').localeCompare(b.ghLogin ?? ''))
.slice(0, limit);
};
const handler = async (
event: RoutePayload<TopContributorsPayload>,
): Promise<TopContributorsResponse> => {
const daysInput = event.body?.days;
const limitInput = event.body?.limit;
const days = Math.min(
Math.max(
Math.floor(
typeof daysInput === 'number' && Number.isFinite(daysInput)
? daysInput
: DEFAULT_DAYS,
),
1,
),
MAX_DAYS,
);
const limit = Math.min(
Math.max(
Math.floor(
typeof limitInput === 'number' && Number.isFinite(limitInput)
? limitInput
: DEFAULT_LIMIT,
),
1,
),
MAX_LIMIT,
);
const kindInput = event.body?.kind;
const kind: TopContributorsKind =
kindInput === 'authors' || kindInput === 'reviewers' ? kindInput : 'both';
const sinceMs = Date.now() - days * 24 * 3600 * 1000;
const client = getClient();
const authoredResult: { items: PrNode[]; truncated: boolean } =
kind === 'reviewers' ? { items: [], truncated: false } : await paginateUntil<PrNode>(
async (cursor) => {
const res = await client.query({
pullRequests: {
__args: {
orderBy: [{ githubCreatedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: {
node: {
githubCreatedAt: true,
author: {
id: true,
name: true,
ghLogin: true,
avatarUrl: { primaryLinkUrl: true },
},
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequests as Connection<PrNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.githubCreatedAt) return false;
return new Date(n.githubCreatedAt).getTime() < sinceMs;
},
);
const reviewedResult: { items: ReviewNode[]; truncated: boolean } =
kind === 'authors' ? { items: [], truncated: false } : await paginateUntil<ReviewNode>(
async (cursor) => {
const res = await client.query({
pullRequestReviews: {
__args: {
orderBy: [{ firstSubmittedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: {
node: {
firstSubmittedAt: true,
reviewer: {
id: true,
name: true,
ghLogin: true,
avatarUrl: { primaryLinkUrl: true },
},
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequestReviews as Connection<ReviewNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.firstSubmittedAt) return false;
return new Date(n.firstSubmittedAt).getTime() < sinceMs;
},
);
const topAuthors = tally(
authoredResult.items.map((pr) => ({ contributor: pr.author })),
limit,
);
const topReviewers = tally(
reviewedResult.items.map((r) => ({ contributor: r.reviewer })),
limit,
);
return {
days,
limit,
topAuthors,
topReviewers,
truncated: {
authors: authoredResult.truncated,
reviewers: reviewedResult.truncated,
},
};
};
export default defineLogicFunction({
universalIdentifier: 'd5b9c4a2-7e3f-4a1b-9c8d-2f4e6a7b8c9d',
name: 'top-contributors',
description:
'Returns the top contributors by pull-request authorship and review counts over a configurable time window.',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/contributors/top',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,47 @@
import { type LinksFieldValue, toLinksField } from 'src/modules/shared/types';
export type GhUser = {
login: string;
id?: number;
databaseId?: number;
avatarUrl?: string | null;
avatar_url?: string | null;
};
export type ContributorCanonical = {
ghLogin: string;
name: string;
githubId: number;
avatarUrl: LinksFieldValue | null;
};
export function contributorFromGhUser(user: GhUser): ContributorCanonical {
const avatarUrl = user.avatarUrl ?? user.avatar_url ?? null;
return {
ghLogin: user.login,
name: user.login,
githubId: user.id ?? user.databaseId ?? 0,
avatarUrl: avatarUrl ? toLinksField(avatarUrl, user.login) : null,
};
}
export function dedupeContributors(
users: Array<GhUser | null | undefined>,
): ContributorCanonical[] {
const seen = new Map<string, ContributorCanonical>();
for (const u of users) {
if (!u || !u.login) continue;
const existing = seen.get(u.login);
if (!existing) {
seen.set(u.login, contributorFromGhUser(u));
continue;
}
if (!existing.avatarUrl) {
const avatarUrl = u.avatarUrl ?? u.avatar_url ?? null;
if (avatarUrl) {
existing.avatarUrl = toLinksField(avatarUrl, u.login);
}
}
}
return [...seen.values()];
}
@@ -0,0 +1,72 @@
import { defineObject, FieldType } from 'twenty-sdk/define';
export const CONTRIBUTOR_UNIVERSAL_IDENTIFIER =
'8e9464f0-fdc9-487a-9963-c7accac0d4bb';
export const CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'e2e77e0e-5300-44ce-ab1c-9ad0593b5d1a';
export const CONTRIBUTOR_GH_LOGIN_FIELD_UNIVERSAL_IDENTIFIER =
'a1d3c7b2-4e5f-4a8b-9c6d-2e1f0b3a4c5d';
export const CONTRIBUTOR_GITHUB_ID_FIELD_UNIVERSAL_IDENTIFIER =
'f7a8b9c0-1d2e-4f3a-8b5c-6d7e8f9a0b1c';
export const CONTRIBUTOR_AVATAR_URL_FIELD_UNIVERSAL_IDENTIFIER =
'450a612c-f7a3-4bba-aa36-e22339eb0720';
export const CONTRIBUTOR_CONTRIBUTIONS_FIELD_UNIVERSAL_IDENTIFIER =
'66f73e49-6edb-48fa-812b-c3e684ef6340';
export const CONTRIBUTOR_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'bb8d2a87-b41c-578b-98de-2e37dba12a14';
export default defineObject({
universalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
nameSingular: 'contributor',
namePlural: 'contributors',
labelSingular: 'Contributor',
labelPlural: 'Contributors',
icon: 'IconUsers',
labelIdentifierFieldMetadataUniversalIdentifier:
CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER,
name: 'name',
type: FieldType.TEXT,
label: 'Name',
icon: 'IconUser',
},
{
universalIdentifier: CONTRIBUTOR_GH_LOGIN_FIELD_UNIVERSAL_IDENTIFIER,
name: 'ghLogin',
type: FieldType.TEXT,
label: 'GitHub Login',
icon: 'IconBrandGithub',
isUnique: true,
},
{
universalIdentifier: CONTRIBUTOR_GITHUB_ID_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubId',
type: FieldType.NUMBER,
label: 'GitHub ID',
icon: 'IconHash',
},
{
universalIdentifier: CONTRIBUTOR_AVATAR_URL_FIELD_UNIVERSAL_IDENTIFIER,
name: 'avatarUrl',
type: FieldType.LINKS,
label: 'Avatar',
icon: 'IconPhoto',
},
{
universalIdentifier: CONTRIBUTOR_CONTRIBUTIONS_FIELD_UNIVERSAL_IDENTIFIER,
name: 'contributions',
type: FieldType.NUMBER,
label: 'Contributions',
icon: 'IconTrendingUp',
defaultValue: 0,
},
],
});
@@ -0,0 +1,10 @@
import type { LinksFieldValue } from 'src/modules/shared/types';
export type ContributorRow = {
id: string;
ghLogin?: string | null;
name?: string | null;
githubId?: number | null;
avatarUrl?: LinksFieldValue | null;
contributions?: number | null;
};
@@ -0,0 +1,30 @@
const BOT_LOGIN_SUFFIX = '[bot]';
const KNOWN_BOT_LOGINS = new Set<string>([
'github-actions',
'dependabot',
'dependabot-preview',
'renovate',
'renovate-bot',
'cubic-dev-ai',
'greptile-apps',
'sentry',
'sentry-io',
'codecov',
'codecov-commenter',
'snyk-bot',
'mergify',
'allcontributors',
'imgbot',
'pre-commit-ci',
'semantic-release-bot',
'stale',
'web-flow',
]);
export function isBotLogin(login: string | null | undefined): boolean {
if (!login) return false;
const lower = login.toLowerCase();
if (lower.endsWith(BOT_LOGIN_SUFFIX)) return true;
return KNOWN_BOT_LOGINS.has(lower);
}
@@ -0,0 +1,51 @@
import {
defineView,
ViewKey,
ViewSortDirection,
ViewType,
} from 'twenty-sdk/define';
import {
CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER,
CONTRIBUTOR_GH_LOGIN_FIELD_UNIVERSAL_IDENTIFIER,
CONTRIBUTOR_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/contributor/objects/contributor.object';
export const ALL_CONTRIBUTORS_VIEW_UNIVERSAL_IDENTIFIER =
'e2b51f8e-97ea-49e0-b38d-a69d2191236f';
export default defineView({
universalIdentifier: ALL_CONTRIBUTORS_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All Contributors',
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconUsers',
key: ViewKey.INDEX,
position: 0,
fields: [
{
universalIdentifier: 'b53cb9dd-0e2e-415e-81af-80bedbed89bc',
fieldMetadataUniversalIdentifier:
CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 200,
},
{
universalIdentifier: '604a2f1d-ef20-4821-8742-fe3b5b83ffcf',
fieldMetadataUniversalIdentifier:
CONTRIBUTOR_GH_LOGIN_FIELD_UNIVERSAL_IDENTIFIER,
position: 1,
isVisible: true,
size: 150,
},
],
sorts: [
{
universalIdentifier: 'c0c54264-8de9-418b-b91a-cc4a2b94c539',
fieldMetadataUniversalIdentifier:
CONTRIBUTOR_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
direction: ViewSortDirection.DESC,
},
],
});
@@ -0,0 +1,25 @@
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';
import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/fields/authored-issues-on-contributor.field';
export const ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER =
'5ff7ab7f-bcfa-4e7a-9a33-0860ec692736';
export default defineField({
universalIdentifier: ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'author',
label: 'Author',
icon: 'IconUser',
relationTargetObjectMetadataUniversalIdentifier:
CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'authorId',
},
});
@@ -0,0 +1,23 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object';
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
import { LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/linked-issue-on-project-item.field';
export const PROJECT_ITEMS_ON_ISSUE_FIELD_UNIVERSAL_IDENTIFIER =
'8b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e';
export default defineField({
universalIdentifier: PROJECT_ITEMS_ON_ISSUE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'projectItems',
label: 'Project Items',
icon: 'IconLayoutKanban',
relationTargetObjectMetadataUniversalIdentifier:
PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,109 @@
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';
import { retry } from 'src/modules/shared/retry';
type CountResponse = {
totalPages: number;
repos: Array<{ owner: string; repo: string; totalCount: number; pages: number }>;
};
type FetchPageResponse = {
issueCount: number;
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
type SyncStatus = 'syncing' | 'done' | 'error';
const FetchIssues = () => {
const [status, setStatus] = useState<SyncStatus>('syncing');
useEffect(() => {
const run = async () => {
try {
const counts = (await callAppRoute(
'/github/count-issues',
{},
)) as CountResponse;
if (counts.repos.length === 0) {
throw new Error(
'No repos resolved. Set GITHUB_REPOS in the application variables.',
);
}
const totalPages = Math.max(counts.totalPages, 1);
let pagesProcessed = 0;
let totalIssues = 0;
for (const { owner, repo } of counts.repos) {
let cursor: string | null = null;
let hasMore = true;
while (hasMore) {
const cursorTag = cursor ? `@${cursor.slice(0, 8)}` : '';
const data = (await retry(
`fetch-issues ${owner}/${repo}${cursorTag}`,
() =>
callAppRoute('/github/fetch-issues', {
owner,
repo,
cursor,
}),
)) as FetchPageResponse;
totalIssues += data.issueCount;
hasMore = data.hasMore && data.issueCount > 0;
cursor = data.endCursor;
pagesProcessed++;
updateProgress(Math.min(Math.round((pagesProcessed / totalPages) * 100), 99));
}
}
updateProgress(100);
enqueueSnackbar({
message: `Fetched ${totalIssues} issues`,
variant: 'success',
});
setStatus('done');
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to fetch issues';
enqueueSnackbar({ message, variant: 'error' });
setStatus('error');
} finally {
unmountFrontComponent();
}
};
run();
}, []);
if (status === 'syncing') return <div>Fetching issues...</div>;
if (status === 'error') return <div>Failed to fetch issues.</div>;
return <div>Done</div>;
};
export default defineFrontComponent({
universalIdentifier: '9430e4fc-9ecb-428d-9bde-2babeb1f452f',
name: 'Fetch Issues',
description: 'Fetches issues from GitHub repos',
isHeadless: true,
component: FetchIssues,
command: {
universalIdentifier: 'c34f56aa-ff65-43a4-9db2-774945dbcc53',
label: 'Fetch Issues',
icon: 'IconBug',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'issue',
},
});
@@ -0,0 +1,26 @@
import { githubGraphql } from 'src/modules/github/connector/github-client';
const QUERY = `
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
issues(states: [OPEN, CLOSED]) {
totalCount
}
}
}`;
type Response = {
repository: { issues: { totalCount: number } } | null;
};
export async function countIssues(
owner: string,
name: string,
): Promise<number> {
try {
const data = await githubGraphql<Response>(QUERY, { owner, name });
return data.repository?.issues.totalCount ?? 0;
} catch {
return 0;
}
}
@@ -0,0 +1,66 @@
import {
EMPTY_PAGE,
type GithubPage,
githubGraphql,
} from 'src/modules/github/connector/github-client';
const QUERY = `
query($owner: String!, $name: String!, $cursor: String) {
repository(owner: $owner, name: $name) {
issues(first: 100, states: [OPEN, CLOSED], orderBy: {field: CREATED_AT, direction: DESC}, after: $cursor) {
totalCount
pageInfo { hasNextPage endCursor }
nodes {
number
title
url
state
createdAt
closedAt
author { login avatarUrl ... on User { databaseId } }
labels(first: 50) { nodes { name } }
}
}
}
}`;
export type GqlIssue = {
number: number;
title: string;
url: string;
state: 'OPEN' | 'CLOSED';
createdAt: string;
closedAt: string | null;
author: { login: string; avatarUrl?: string | null; databaseId?: number } | null;
labels: { nodes: Array<{ name: string }> };
};
type Response = {
repository: { issues: GithubPage<GqlIssue> } | null;
};
export async function fetchIssues(
owner: string,
name: string,
cursor: string | null = null,
): Promise<{
issues: GqlIssue[];
totalCount: number;
hasMore: boolean;
endCursor: string | null;
}> {
try {
const data = await githubGraphql<Response>(QUERY, { owner, name, cursor });
const conn = data.repository?.issues;
if (!conn) return { issues: [], ...EMPTY_PAGE };
return {
issues: conn.nodes,
totalCount: conn.totalCount,
hasMore: conn.pageInfo.hasNextPage,
endCursor: conn.pageInfo.endCursor,
};
} catch {
return { issues: [], ...EMPTY_PAGE };
}
}
@@ -0,0 +1,27 @@
import { chunkedBatchCreate } from 'src/modules/shared/twenty-client';
import type { LinksFieldValue } from 'src/modules/shared/types';
import type { IssueRow } from 'src/modules/github/issue/types/issue-row';
export async function batchUpsertIssues(
items: Array<{
title: string;
githubNumber: number;
uniqueIdentifier: string;
githubUrl: LinksFieldValue;
state: string;
labels: string[];
githubCreatedAt: string | null;
closedAt: string | null;
repo: string;
authorId: string | null;
}>,
): Promise<IssueRow[]> {
return chunkedBatchCreate('createIssues', items, {
id: true,
githubNumber: true,
uniqueIdentifier: true,
title: true,
state: true,
repo: true,
}) as Promise<IssueRow[]>;
}
@@ -0,0 +1,40 @@
import { getClient } from 'src/modules/shared/twenty-client';
import type { IssueRow } from 'src/modules/github/issue/types/issue-row';
export async function findIssueByNumberAndRepo(
githubNumber: number,
repo: string,
): Promise<IssueRow | null> {
const client = getClient();
const res = await client.query({
issues: {
__args: {
filter: {
and: [
{ githubNumber: { eq: githubNumber } },
{ repo: { eq: repo } },
],
},
first: 1,
},
edges: {
node: {
id: true,
title: true,
githubNumber: true,
uniqueIdentifier: true,
githubUrl: { primaryLinkLabel: true, primaryLinkUrl: true },
state: true,
labels: true,
githubCreatedAt: true,
closedAt: true,
repo: true,
authorId: true,
},
},
},
});
const node = res.issues?.edges?.[0]?.node;
return (node as IssueRow | undefined) ?? null;
}
@@ -0,0 +1,24 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { countAcrossRepos } from 'src/modules/github/connector/count-across-repos';
import { countIssues } from 'src/modules/github/issue/graphql/github/count-issues';
type CountIssuesPayload = {
repos?: string[];
};
const handler = async (event: RoutePayload<CountIssuesPayload>) =>
countAcrossRepos(event.body?.repos, countIssues, 'count-issues');
export default defineLogicFunction({
universalIdentifier: 'd8cc32bf-6be9-44fc-920a-8bba510f045f',
name: 'count-issues',
description:
'Counts total issue pages across configured repos using GraphQL totalCount',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/github/count-issues',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,93 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import {
fetchIssues,
type GqlIssue,
} from 'src/modules/github/issue/graphql/github/fetch-issues';
import { batchUpsertContributors } from 'src/modules/github/contributor/graphql/mutations/batch-upsert';
import { batchUpsertIssues } from 'src/modules/github/issue/graphql/mutations/batch-upsert';
import { dedupeContributors } from 'src/modules/github/contributor/normalizers';
import { issueFromGraphql } from 'src/modules/github/issue/normalizers';
import { timed } from 'src/modules/shared/timing';
import { isFixtureAllowed } from 'src/modules/shared/fixtures';
export type FetchIssuesFixturePage = {
issues: GqlIssue[];
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
type FetchIssuesPayload = {
owner: string;
repo: string;
cursor?: string | null;
fixturePage?: FetchIssuesFixturePage;
};
const handler = async (event: RoutePayload<FetchIssuesPayload>) => {
const handlerStart = Date.now();
const { owner, repo, cursor = null, fixturePage } = event.body ?? {};
if (!owner || !repo) {
return { error: 'owner and repo are required' };
}
const tag = `${owner}/${repo}${cursor ? `@${cursor.slice(0, 8)}` : ''}`;
console.log(`[fetch-issues] start ${tag}${fixturePage ? ' (fixture)' : ''}`);
const result =
fixturePage && isFixtureAllowed()
? fixturePage
: await timed(`fetch-issues:github ${tag}`, () =>
fetchIssues(owner, repo, cursor),
);
const { issues, totalCount, hasMore, endCursor } = result;
if (issues.length === 0) {
console.log(`[fetch-issues] empty page for ${tag}`);
return { issueCount: 0, totalCount, hasMore: false, endCursor: null };
}
const fullRepo = `${owner}/${repo}`;
const contributorInputs = dedupeContributors(issues.map((i) => i.author));
const contributors = await timed(
`fetch-issues:upsertContributors ${tag} (${contributorInputs.length})`,
() => batchUpsertContributors(contributorInputs),
);
const idByLogin = new Map<string, string>();
for (const c of contributors) {
if (c.ghLogin) idByLogin.set(c.ghLogin, c.id);
}
const issueData = issues.map((issue) => ({
...issueFromGraphql(issue, fullRepo),
authorId: issue.author ? (idByLogin.get(issue.author.login) ?? null) : null,
}));
await timed(
`fetch-issues:upsertIssues ${tag} (${issueData.length})`,
() => batchUpsertIssues(issueData),
);
const totalMs = Date.now() - handlerStart;
console.log(
`[fetch-issues] done ${tag} in ${totalMs}ms (issues=${issues.length}, hasMore=${hasMore})`,
);
return { issueCount: issues.length, totalCount, hasMore, endCursor };
};
export default defineLogicFunction({
universalIdentifier: 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e',
name: 'fetch-issues',
description:
'Fetches one page of issues via GitHub GraphQL API and batch upserts them',
timeoutSeconds: 300,
handler,
httpRouteTriggerSettings: {
path: '/github/fetch-issues',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,11 @@
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object';
import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: '7c4f8e1a-3b9d-4f2e-8a6c-1d5b7e9f3a2c',
position: 1,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,63 @@
import type { LinksFieldValue } from 'src/modules/shared/types';
import { toLinksField } from 'src/modules/shared/types';
import type { GitHubIssue } from 'src/modules/github/issue/types/github-issue';
import type { GqlIssue } from 'src/modules/github/issue/graphql/github/fetch-issues';
export type IssueState = 'OPEN' | 'CLOSED';
export type IssueCanonical = {
title: string;
githubNumber: number;
uniqueIdentifier: string;
githubUrl: LinksFieldValue;
state: IssueState;
labels: string[];
githubCreatedAt: string | null;
closedAt: string | null;
repo: string;
};
export function deriveIssueState(state: string): IssueState {
return state.toUpperCase() === 'CLOSED' ? 'CLOSED' : 'OPEN';
}
export function buildIssueUniqueIdentifier(
repoFullName: string,
number: number,
): string {
return `${repoFullName}#${number}`;
}
export function issueFromWebhook(
issue: GitHubIssue,
repoFullName: string,
): IssueCanonical {
return {
title: issue.title,
githubNumber: issue.number,
uniqueIdentifier: buildIssueUniqueIdentifier(repoFullName, issue.number),
githubUrl: toLinksField(issue.html_url, issue.title),
state: deriveIssueState(issue.state),
labels: issue.labels.map((l) => l.name),
githubCreatedAt: issue.created_at,
closedAt: issue.closed_at,
repo: repoFullName,
};
}
export function issueFromGraphql(
issue: GqlIssue,
repoFullName: string,
): IssueCanonical {
return {
title: issue.title,
githubNumber: issue.number,
uniqueIdentifier: buildIssueUniqueIdentifier(repoFullName, issue.number),
githubUrl: toLinksField(issue.url, issue.title),
state: deriveIssueState(issue.state),
labels: issue.labels.nodes.map((l) => l.name),
githubCreatedAt: issue.createdAt,
closedAt: issue.closedAt,
repo: repoFullName,
};
}
@@ -0,0 +1,134 @@
import { defineObject, FieldType } from 'twenty-sdk/define';
export const ISSUE_UNIVERSAL_IDENTIFIER =
'3a4b5c6d-7e8f-4a9b-8c0d-1e2f3a4b5c6d';
export const ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER =
'4b5c6d7e-8f9a-4b0c-9d1e-2f3a4b5c6d7e';
export const ISSUE_GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER =
'5c6d7e8f-9a0b-4c1d-ae2f-3a4b5c6d7e8f';
export const ISSUE_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER =
'6d7e8f9a-0b1c-4d2e-bf3a-4b5c6d7e8f9a';
export const ISSUE_LABELS_FIELD_UNIVERSAL_IDENTIFIER =
'dfd35d92-f4a3-44c5-b758-494c0882fcc8';
export const ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'76364df9-6f5c-477d-bd99-97b6738df3bf';
export const ISSUE_CLOSED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'48ac555b-73b7-4ce9-a264-251c8e06b4e9';
export const ISSUE_REPO_FIELD_UNIVERSAL_IDENTIFIER =
'79c41cdc-912a-4e4d-b9de-3f2421bcb2fb';
export const ISSUE_UNIQUE_IDENTIFIER_FIELD_UNIVERSAL_IDENTIFIER =
'0ca04207-4d74-4298-8238-59cdf685862b';
enum IssueState {
OPEN = 'OPEN',
CLOSED = 'CLOSED',
}
export const ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER =
'1c2d3e4f-5a6b-4c7d-a48e-9f0a1b2c3d4e';
export const ISSUE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'440e9ba8-20d1-5843-91ef-3b7b699a59f4';
export default defineObject({
universalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
nameSingular: 'issue',
namePlural: 'issues',
labelSingular: 'Issue',
labelPlural: 'Issues',
icon: 'IconBug',
labelIdentifierFieldMetadataUniversalIdentifier:
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
name: 'title',
type: FieldType.TEXT,
label: 'Title',
icon: 'IconTextCaption',
},
{
universalIdentifier: ISSUE_GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubNumber',
type: FieldType.NUMBER,
label: 'Issue Number',
icon: 'IconHash',
},
{
universalIdentifier: ISSUE_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubUrl',
type: FieldType.LINKS,
label: 'GitHub URL',
icon: 'IconLink',
},
{
universalIdentifier: ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER,
name: 'state',
type: FieldType.SELECT,
label: 'State',
icon: 'IconCircleDot',
options: [
{
value: IssueState.OPEN,
label: 'Open',
position: 0,
color: 'green',
},
{
value: IssueState.CLOSED,
label: 'Closed',
position: 1,
color: 'red',
},
],
},
{
universalIdentifier: ISSUE_LABELS_FIELD_UNIVERSAL_IDENTIFIER,
name: 'labels',
type: FieldType.ARRAY,
label: 'Labels',
icon: 'IconTag',
},
{
universalIdentifier: ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubCreatedAt',
type: FieldType.DATE_TIME,
label: 'GitHub Created At',
icon: 'IconCalendarPlus',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: ISSUE_CLOSED_AT_FIELD_UNIVERSAL_IDENTIFIER,
name: 'closedAt',
type: FieldType.DATE_TIME,
label: 'Closed At',
icon: 'IconCalendarOff',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: ISSUE_REPO_FIELD_UNIVERSAL_IDENTIFIER,
name: 'repo',
type: FieldType.TEXT,
label: 'Repository',
icon: 'IconFolder',
},
{
universalIdentifier: ISSUE_UNIQUE_IDENTIFIER_FIELD_UNIVERSAL_IDENTIFIER,
name: 'uniqueIdentifier',
type: FieldType.TEXT,
label: 'Unique Identifier',
icon: 'IconFingerprint',
isUnique: true,
},
],
});
@@ -0,0 +1,13 @@
import type { GitHubUser } from 'src/modules/github/connector/github-user';
export type GitHubIssue = {
number: number;
title: string;
html_url: string;
state: 'open' | 'closed';
labels: Array<{ name: string }>;
created_at: string;
closed_at: string | null;
user: GitHubUser;
pull_request?: unknown;
};
@@ -0,0 +1,15 @@
import type { LinksFieldValue } from 'src/modules/shared/types';
export type IssueRow = {
id: string;
title?: string | null;
githubNumber?: number | null;
uniqueIdentifier?: string | null;
githubUrl?: LinksFieldValue | null;
state?: string | null;
labels?: string[] | null;
githubCreatedAt?: string | null;
closedAt?: string | null;
repo?: string | null;
authorId?: string | null;
};
@@ -0,0 +1,87 @@
import {
defineView,
ViewKey,
ViewSortDirection,
ViewType,
} from 'twenty-sdk/define';
import {
ISSUE_UNIVERSAL_IDENTIFIER,
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_LABELS_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_REPO_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/issue/objects/issue.object';
import { ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/fields/author-on-issue.field';
export const ALL_ISSUES_VIEW_UNIVERSAL_IDENTIFIER =
'b2c3d4e5-1111-4f6a-b7c8-d9e0f1a2b3c4';
export default defineView({
universalIdentifier: ALL_ISSUES_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All Issues',
objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconBug',
key: ViewKey.INDEX,
position: 0,
fields: [
{
universalIdentifier: '5b115bd1-489c-44e5-ac1b-7d508e06f165',
fieldMetadataUniversalIdentifier:
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 300,
},
{
universalIdentifier: '77ac1882-3a13-43e3-822e-c9f5f7d83a67',
fieldMetadataUniversalIdentifier:
ISSUE_GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER,
position: 1,
isVisible: true,
size: 100,
},
{
universalIdentifier: '4f0d17f2-c3a6-4431-a928-3812273592e2',
fieldMetadataUniversalIdentifier:
ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER,
position: 2,
isVisible: true,
size: 100,
},
{
universalIdentifier: '01673163-ccfa-4370-a1c3-48c33b6c4aa3',
fieldMetadataUniversalIdentifier:
ISSUE_LABELS_FIELD_UNIVERSAL_IDENTIFIER,
position: 3,
isVisible: true,
size: 200,
},
{
universalIdentifier: '6d932c97-8f92-4d93-968f-29ca34b27ac0',
fieldMetadataUniversalIdentifier:
ISSUE_REPO_FIELD_UNIVERSAL_IDENTIFIER,
position: 4,
isVisible: true,
size: 180,
},
{
universalIdentifier: 'dc293fd6-747c-4291-9426-5d7b53234936',
fieldMetadataUniversalIdentifier:
ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER,
position: 5,
isVisible: true,
size: 150,
},
],
sorts: [
{
universalIdentifier: '8e972333-b919-4c1b-ad7b-4fab8f018253',
fieldMetadataUniversalIdentifier:
ISSUE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
direction: ViewSortDirection.DESC,
},
],
});
@@ -0,0 +1,260 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import type { GitHubWebhookPayload } from 'src/modules/github/connector/webhook-payload';
import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item';
import { fetchProjectItemByNodeId } from 'src/modules/github/project-item/graphql/github/fetch-project-item-by-node-id';
import {
getRawBodyForSignature,
verifyGitHubSignature,
} from 'src/modules/github/connector/webhook-signature';
import { batchUpsertContributors } from 'src/modules/github/contributor/graphql/mutations/batch-upsert';
import { batchUpsertPullRequests } from 'src/modules/github/pull-request/graphql/mutations/batch-upsert';
import { batchUpsertReviewEvents } from 'src/modules/github/pull-request-review-event/graphql/mutations/batch-upsert';
import { batchUpsertConsolidatedReviews } from 'src/modules/github/pull-request-review/graphql/mutations/batch-upsert';
import { findReviewEventsForPair } from 'src/modules/github/pull-request-review-event/graphql/queries/find-events-for-pair';
import { buildConsolidatedRow } from 'src/modules/github/pull-request-review/utils/build-consolidated-row';
import type { ReviewEventState } from 'src/modules/github/pull-request-review/utils/consolidate-reviews';
import { batchUpsertIssues } from 'src/modules/github/issue/graphql/mutations/batch-upsert';
import { batchUpsertProjectItems } from 'src/modules/github/project-item/graphql/mutations/batch-upsert';
import { dedupeContributors } from 'src/modules/github/contributor/normalizers';
import { pullRequestFromWebhook } from 'src/modules/github/pull-request/normalizers';
import { reviewEventFromWebhook } from 'src/modules/github/pull-request-review-event/normalizers';
import { issueFromWebhook } from 'src/modules/github/issue/normalizers';
import { projectItemFromGraphql } from 'src/modules/github/project-item/normalizers';
async function upsertContributorsByLogin(
users: Array<{ login: string; id?: number } | null | undefined>,
): Promise<Map<string, string>> {
const inputs = dedupeContributors(users);
if (inputs.length === 0) return new Map();
const rows = await batchUpsertContributors(inputs);
const map = new Map<string, string>();
for (const r of rows) {
if (r.ghLogin) map.set(r.ghLogin, r.id);
}
return map;
}
async function handlePullRequestEvent(payload: GitHubWebhookPayload) {
const pr = payload.pull_request;
const repository = payload.repository;
if (!pr || !repository) {
return { skipped: true, reason: 'missing pull_request or repository' };
}
const idByLogin = await upsertContributorsByLogin([pr.user, pr.merged_by]);
const authorId = idByLogin.get(pr.user.login) ?? null;
const mergerId = pr.merged_by ? (idByLogin.get(pr.merged_by.login) ?? null) : null;
const canonical = pullRequestFromWebhook(pr, repository.full_name);
const [record] = await batchUpsertPullRequests([
{ ...canonical, authorId, mergerId },
]);
return {
processed: true,
pullRequestId: record?.id,
action: payload.action,
state: canonical.state,
};
}
async function handlePullRequestReviewEvent(payload: GitHubWebhookPayload) {
const review = payload.review;
const pr = payload.pull_request;
const repository = payload.repository;
if (!review || !pr || !repository) {
return {
skipped: true,
reason: 'missing review, pull_request, or repository',
};
}
const idByLogin = await upsertContributorsByLogin([
pr.user,
pr.merged_by,
review.user,
]);
const prCanonical = pullRequestFromWebhook(pr, repository.full_name);
const [prRecord] = await batchUpsertPullRequests([
{
...prCanonical,
authorId: idByLogin.get(pr.user.login) ?? null,
mergerId: pr.merged_by ? (idByLogin.get(pr.merged_by.login) ?? null) : null,
},
]);
const reviewCanonical = reviewEventFromWebhook(review);
const reviewerId = idByLogin.get(review.user.login) ?? null;
const pullRequestId = prRecord?.id ?? '';
const [eventRecord] = await batchUpsertReviewEvents([
{
...reviewCanonical,
reviewerId,
pullRequestId,
},
]);
let consolidatedId: string | undefined;
if (pullRequestId) {
const events = await findReviewEventsForPair(pullRequestId, reviewerId);
if (events.length > 0) {
const consolidatedRow = buildConsolidatedRow({
pullRequestId,
reviewerId,
prNumber: pr.number ?? null,
reviewerLogin: review.user.login,
events: events.map((e) => ({
state: e.state as ReviewEventState,
submittedAt: e.submittedAt,
})),
});
const [consolidatedRecord] = await batchUpsertConsolidatedReviews([
consolidatedRow,
]);
consolidatedId = consolidatedRecord?.id;
if (consolidatedId && eventRecord?.id) {
await batchUpsertReviewEvents([
{
...reviewCanonical,
reviewerId,
pullRequestId,
reviewId: consolidatedId,
},
]);
}
}
}
return {
processed: true,
reviewEventId: eventRecord?.id,
reviewId: consolidatedId,
pullRequestId: prRecord?.id,
action: payload.action,
};
}
async function handleIssueEvent(payload: GitHubWebhookPayload) {
const issue = payload.issue;
const repository = payload.repository;
if (!issue || !repository) {
return { skipped: true, reason: 'missing issue or repository' };
}
const idByLogin = await upsertContributorsByLogin([issue.user]);
const canonical = issueFromWebhook(issue, repository.full_name);
const [record] = await batchUpsertIssues([
{ ...canonical, authorId: idByLogin.get(issue.user.login) ?? null },
]);
return {
processed: true,
issueId: record?.id,
action: payload.action,
};
}
async function handleProjectV2ItemEvent(
payload: GitHubWebhookPayload,
testProjectItem?: ProjectV2Item,
) {
const item = payload.projects_v2_item;
if (!item?.node_id) {
return { skipped: true, reason: 'missing projects_v2_item.node_id' };
}
if (payload.action === 'deleted') {
console.log(
`[handle-webhook] projects_v2_item delete skipped (nodeId=${item.node_id})`,
);
return { skipped: true, reason: 'delete not implemented' };
}
const node = testProjectItem ?? (await fetchProjectItemByNodeId(item.node_id));
if (!node) {
return { skipped: true, reason: 'project item not found on GitHub' };
}
const canonical = await projectItemFromGraphql(node);
const [record] = await batchUpsertProjectItems([canonical]);
return {
processed: true,
projectItemId: record?.id,
action: payload.action,
};
}
const handler = async (
event: RoutePayload<
GitHubWebhookPayload & { __test_projectItem?: ProjectV2Item }
>,
) => {
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (secret) {
const signatureHeader =
event.headers?.['x-hub-signature-256'] ??
event.headers?.['X-Hub-Signature-256'];
const rawBody = getRawBodyForSignature(event);
const verification = verifyGitHubSignature({
rawBody,
signatureHeader,
secret,
});
if (!verification.ok) {
const delivery =
event.headers?.['x-github-delivery'] ??
event.headers?.['X-GitHub-Delivery'];
console.warn(
`[handle-webhook] signature verification failed (${verification.reason}) delivery=${delivery ?? 'unknown'}`,
);
return { error: 'invalid signature', reason: verification.reason };
}
} else {
console.warn(
'[handle-webhook] GITHUB_WEBHOOK_SECRET is not set; skipping signature verification (insecure)',
);
}
const payload = event.body;
if (!payload) return { error: 'empty body' };
if (payload.pull_request && payload.review) {
return handlePullRequestReviewEvent(payload);
}
if (payload.pull_request) {
return handlePullRequestEvent(payload);
}
if (payload.issue) {
return handleIssueEvent(payload);
}
if (payload.projects_v2_item) {
return handleProjectV2ItemEvent(payload, payload.__test_projectItem);
}
return { skipped: true, reason: 'unhandled event' };
};
export default defineLogicFunction({
universalIdentifier: '22b199b3-2851-4a4f-99fd-4e79c188fe7d',
name: 'handle-github-webhook',
description:
'Receives GitHub webhook events for PRs, reviews, issues, and project items; idempotent upserts via shared batch helpers',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/github/webhook',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: [
'x-hub-signature-256',
'x-github-event',
'x-github-delivery',
],
},
});
@@ -0,0 +1,14 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: 'd1c2b3a4-9e8f-4d3c-b2a1-0f9e8d7c6b5a',
position: 3,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,17 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import { GITHUB_DASHBOARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/page-layouts/github-dashboard.page-layout';
import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: 'e8f9a1b2-c3d4-4567-89ab-cdef01234567',
name: 'GitHub Dashboard',
icon: 'IconBrandGithub',
position: 6,
type: NavigationMenuItemType.PAGE_LAYOUT,
pageLayoutUniversalIdentifier:
GITHUB_DASHBOARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,13 @@
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
export const GITHUB_FOLDER_UNIVERSAL_IDENTIFIER =
'a4f7d8b1-2c93-4e6f-8b1a-9d3e5c7f2a48';
export default defineNavigationMenuItem({
universalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER,
name: 'GitHub',
icon: 'IconBrandGithub',
color: 'gray',
position: 0,
type: NavigationMenuItemType.FOLDER,
});
@@ -0,0 +1,281 @@
import {
AggregateOperations,
definePageLayout,
ObjectRecordGroupByDateGranularity,
PageLayoutTabLayoutMode,
} from 'twenty-sdk/define';
import { TOP_PR_AUTHORS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/front-components/top-pr-authors.front-component';
import { TOP_REVIEWERS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/front-components/top-reviewers.front-component';
import {
ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/issue/objects/issue.object';
import {
MERGED_AT_FIELD_UNIVERSAL_IDENTIFIER,
PR_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
PR_STATE_FIELD_UNIVERSAL_IDENTIFIER,
PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
PULL_REQUEST_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/pull-request/objects/pull-request.object';
import {
PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/pull-request-review/objects/pull-request-review.object';
export const GITHUB_DASHBOARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
'a8d2f1c4-7b69-4e3a-8c5d-9f6b1a3e7c2d';
const THIS_WEEK_RELATIVE = 'THIS_1_WEEK;;UTC;;SUNDAY;;';
const COMMON = {
timezone: 'UTC',
firstDayOfTheWeek: 0,
};
const COL_1 = { column: 0, columnSpan: 3 } as const;
const COL_2 = { column: 3, columnSpan: 5 } as const;
const COL_3 = { column: 8, columnSpan: 4 } as const;
export default definePageLayout({
universalIdentifier: GITHUB_DASHBOARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'GitHub Dashboard',
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier: 'b3e9c2a1-4d68-4f7b-9c25-1e3a5b7c9d11',
title: 'GitHub Dashboard',
position: 0,
icon: 'IconBrandGithub',
layoutMode: PageLayoutTabLayoutMode.GRID,
widgets: [
{
universalIdentifier: 'f1a2b3c4-d5e6-4789-a0b1-c2d3e4f5a6b1',
title: 'PRs Merged This Week',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 0, ...COL_1, rowSpan: 3 },
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
label: 'PRs Merged',
color: 'green',
displayDataLabel: false,
...COMMON,
filter: {
recordFilters: [
{
fieldMetadataUniversalIdentifier:
MERGED_AT_FIELD_UNIVERSAL_IDENTIFIER,
operand: 'IS_RELATIVE',
value: THIS_WEEK_RELATIVE,
type: 'DATE_TIME',
},
],
},
},
},
{
universalIdentifier: 'f1a2b3c4-d5e6-4789-a0b1-c2d3e4f5a6b2',
title: 'PR Reviews This Week',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 3, ...COL_1, rowSpan: 3 },
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
label: 'Reviews',
color: 'blue',
displayDataLabel: false,
...COMMON,
filter: {
recordFilters: [
{
fieldMetadataUniversalIdentifier:
REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
operand: 'IS_RELATIVE',
value: THIS_WEEK_RELATIVE,
type: 'DATE_TIME',
},
],
},
},
},
{
universalIdentifier: '8d4e9d09-29b0-4925-8fcc-aedf5bcecb37',
title: 'PRs Opened This Week',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 6, ...COL_1, rowSpan: 3 },
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
label: 'PRs Opened',
color: 'purple',
displayDataLabel: false,
...COMMON,
filter: {
recordFilters: [
{
fieldMetadataUniversalIdentifier:
PR_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
operand: 'IS_RELATIVE',
value: THIS_WEEK_RELATIVE,
type: 'DATE_TIME',
},
],
},
},
},
{
universalIdentifier: '3c321867-0227-4535-81db-c64a6f673d86',
title: 'Issues Opened This Week',
type: 'GRAPH',
objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 9, ...COL_1, rowSpan: 3 },
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
label: 'Issues Opened',
color: 'orange',
displayDataLabel: false,
...COMMON,
filter: {
recordFilters: [
{
fieldMetadataUniversalIdentifier:
ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
operand: 'IS_RELATIVE',
value: THIS_WEEK_RELATIVE,
type: 'DATE_TIME',
},
],
},
},
},
{
universalIdentifier: '704209be-9174-43a8-ae51-6b11fff20e22',
title: 'PRs by State',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 12, ...COL_1, rowSpan: 6 },
configuration: {
configurationType: 'PIE_CHART',
aggregateFieldMetadataUniversalIdentifier:
PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataUniversalIdentifier:
PR_STATE_FIELD_UNIVERSAL_IDENTIFIER,
displayLegend: true,
showCenterMetric: true,
hideEmptyCategory: true,
...COMMON,
},
},
{
universalIdentifier: 'c1f2a3b4-d5e6-4789-a0b1-c2d3e4f5a6b7',
title: 'PRs Merged per Week',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 0, ...COL_2, rowSpan: 6 },
configuration: {
configurationType: 'BAR_CHART',
aggregateFieldMetadataUniversalIdentifier:
PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
MERGED_AT_FIELD_UNIVERSAL_IDENTIFIER,
primaryAxisDateGranularity:
ObjectRecordGroupByDateGranularity.WEEK,
displayDataLabel: false,
displayLegend: false,
color: 'green',
layout: 'VERTICAL',
omitNullValues: true,
...COMMON,
},
},
{
universalIdentifier: 'd2e3f4a5-b6c7-4890-a1b2-c3d4e5f6a7b8',
title: 'PR Reviews per Week',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 6, ...COL_2, rowSpan: 6 },
configuration: {
configurationType: 'BAR_CHART',
aggregateFieldMetadataUniversalIdentifier:
REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
primaryAxisDateGranularity:
ObjectRecordGroupByDateGranularity.WEEK,
displayDataLabel: false,
displayLegend: false,
color: 'blue',
layout: 'VERTICAL',
omitNullValues: true,
...COMMON,
},
},
{
universalIdentifier: '6d03cbd1-ee03-4bf4-a3ac-143d03fa7fd9',
title: 'Issues per Week',
type: 'GRAPH',
objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 12, ...COL_2, rowSpan: 6 },
configuration: {
configurationType: 'BAR_CHART',
aggregateFieldMetadataUniversalIdentifier:
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
primaryAxisDateGranularity:
ObjectRecordGroupByDateGranularity.WEEK,
secondaryAxisGroupByFieldMetadataUniversalIdentifier:
ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER,
displayDataLabel: false,
displayLegend: true,
color: 'orange',
layout: 'VERTICAL',
omitNullValues: true,
...COMMON,
},
},
{
universalIdentifier: '7b3e9c4a-1d52-4f8b-ac76-3e5b8d2f1a9c',
title: 'Top PR Authors',
type: 'FRONT_COMPONENT',
gridPosition: { row: 0, ...COL_3, rowSpan: 9 },
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
TOP_PR_AUTHORS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
{
universalIdentifier: '5e4a8c1d-7f93-4b2e-9d6c-3a8f1b5e7d4c',
title: 'Top Reviewers',
type: 'FRONT_COMPONENT',
gridPosition: { row: 9, ...COL_3, rowSpan: 9 },
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
TOP_REVIEWERS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
@@ -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,
},
],
});
@@ -0,0 +1,30 @@
import {
defineField,
FieldType,
RelationType,
OnDeleteAction,
} from 'twenty-sdk/define';
import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object';
import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object';
import { REVIEW_EVENTS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/review-events-on-pull-request.field';
export const PULL_REQUEST_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'8bd96f9d-fa6b-431a-9264-000097b580f8';
export default defineField({
universalIdentifier: PULL_REQUEST_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'pullRequest',
label: 'Pull Request',
icon: 'IconGitPullRequest',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
REVIEW_EVENTS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.CASCADE,
joinColumnName: 'pullRequestId',
},
});
@@ -0,0 +1,30 @@
import {
defineField,
FieldType,
RelationType,
OnDeleteAction,
} from 'twenty-sdk/define';
import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object';
import { PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/objects/pull-request-review.object';
import { EVENTS_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/fields/events-on-review.field';
export const REVIEW_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'1bc4170d-e998-4aac-bfea-4a6145d7d707';
export default defineField({
universalIdentifier: REVIEW_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'review',
label: 'Review',
icon: 'IconEye',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
EVENTS_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'reviewId',
},
});
@@ -0,0 +1,30 @@
import {
defineField,
FieldType,
RelationType,
OnDeleteAction,
} from 'twenty-sdk/define';
import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { REVIEW_EVENTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/fields/review-events-on-contributor.field';
export const REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'f5a6b7c8-9d0e-4f1a-b2c3-d4e5f6a7b8c9';
export default defineField({
universalIdentifier: REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'reviewer',
label: 'Reviewer',
icon: 'IconUser',
relationTargetObjectMetadataUniversalIdentifier:
CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
REVIEW_EVENTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'reviewerId',
},
});
@@ -0,0 +1,25 @@
import { chunkedBatchCreate } from 'src/modules/shared/twenty-client';
import type { PullRequestReviewEventRow } from 'src/modules/github/pull-request-review-event/types/pull-request-review-event-row';
export async function batchUpsertReviewEvents(
items: Array<{
githubReviewId: number;
title: string;
state: string;
submittedAt: string | null;
reviewerId: string | null;
pullRequestId: string;
reviewId?: string | null;
}>,
): Promise<PullRequestReviewEventRow[]> {
return chunkedBatchCreate('createPullRequestReviewEvents', items, {
id: true,
githubReviewId: true,
title: true,
state: true,
submittedAt: true,
reviewerId: true,
pullRequestId: true,
reviewId: true,
}) as Promise<PullRequestReviewEventRow[]>;
}
@@ -0,0 +1,61 @@
import { getClient } from 'src/modules/shared/twenty-client';
export type ReviewEventForPair = {
id: string;
state: string;
submittedAt: string | null;
};
type Edge<T> = { node: T };
type Connection<T> = {
edges: Edge<T>[];
pageInfo: { hasNextPage: boolean; endCursor: string | null };
};
const PAGE_SIZE = 100;
export async function findReviewEventsForPair(
pullRequestId: string,
reviewerId: string | null,
): Promise<ReviewEventForPair[]> {
const client = getClient();
const out: ReviewEventForPair[] = [];
let cursor: string | null = null;
const reviewerFilter =
reviewerId === null
? { reviewerId: { is: 'NULL' } }
: { reviewerId: { eq: reviewerId } };
for (;;) {
const res = await client.query({
pullRequestReviewEvents: {
__args: {
filter: {
and: [{ pullRequestId: { eq: pullRequestId } }, reviewerFilter],
},
orderBy: [{ submittedAt: 'AscNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: {
node: { id: true, state: true, submittedAt: true },
},
pageInfo: { hasNextPage: true, endCursor: true },
},
});
const conn =
(res.pullRequestReviewEvents as Connection<ReviewEventForPair>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
};
for (const edge of conn.edges) out.push(edge.node);
if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor) break;
cursor = conn.pageInfo.endCursor;
}
return out;
}
@@ -0,0 +1,15 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item';
import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object';
export default defineNavigationMenuItem({
universalIdentifier: '2a3b4c5d-6e7f-4890-9abc-def012345679',
position: 5,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier:
PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,48 @@
import type { GitHubReview } from 'src/modules/github/pull-request-review-event/types/github-review';
export type ReviewEventState =
| 'APPROVED'
| 'CHANGES_REQUESTED'
| 'COMMENTED'
| 'DISMISSED';
export type ReviewEventCanonical = {
githubReviewId: number;
title: string;
state: ReviewEventState;
submittedAt: string | null;
};
export type GqlReviewLike = {
databaseId: number;
state: ReviewEventState;
submittedAt: string | null;
author: { login: string } | null;
};
export function buildReviewEventTitle(login: string, state: string): string {
return `${login} \u2014 ${state.toLowerCase()}`;
}
export function reviewEventFromWebhook(
review: GitHubReview,
): ReviewEventCanonical {
return {
githubReviewId: review.id,
title: buildReviewEventTitle(review.user.login, review.state),
state: review.state,
submittedAt: review.submitted_at,
};
}
export function reviewEventFromGraphql(
review: GqlReviewLike,
): ReviewEventCanonical {
const login = review.author?.login ?? 'unknown';
return {
githubReviewId: review.databaseId,
title: buildReviewEventTitle(login, review.state),
state: review.state,
submittedAt: review.submittedAt,
};
}
@@ -0,0 +1,97 @@
import { defineObject, FieldType } from 'twenty-sdk/define';
export const PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER =
'7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d';
export const REVIEW_EVENT_TITLE_FIELD_UNIVERSAL_IDENTIFIER =
'8b9c0d1e-2f3a-4b4c-9d5e-6f7a8b9c0d1e';
export const REVIEW_EVENT_GITHUB_REVIEW_ID_FIELD_UNIVERSAL_IDENTIFIER =
'9c0d1e2f-3a4b-4c5d-ae6f-7a8b9c0d1e2f';
export const REVIEW_EVENT_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'0d1e2f3a-4b5c-4d6e-bf7a-8b9c0d1e2f3a';
enum ReviewEventState {
APPROVED = 'APPROVED',
CHANGES_REQUESTED = 'CHANGES_REQUESTED',
COMMENTED = 'COMMENTED',
DISMISSED = 'DISMISSED',
}
export const REVIEW_EVENT_STATE_FIELD_UNIVERSAL_IDENTIFIER =
'e5eeeaab-4ea9-4f9f-873d-9c1c1473ddb3';
export const PULL_REQUEST_REVIEW_EVENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'fc577b22-c927-5b0e-9d6d-532dd41cf6d2';
export default defineObject({
universalIdentifier: PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER,
nameSingular: 'pullRequestReviewEvent',
namePlural: 'pullRequestReviewEvents',
labelSingular: 'Pull Request Review Event',
labelPlural: 'Pull Request Review Events',
icon: 'IconEye',
labelIdentifierFieldMetadataUniversalIdentifier:
REVIEW_EVENT_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: REVIEW_EVENT_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
name: 'title',
type: FieldType.TEXT,
label: 'Title',
icon: 'IconTextCaption',
},
{
universalIdentifier:
REVIEW_EVENT_GITHUB_REVIEW_ID_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubReviewId',
type: FieldType.NUMBER,
label: 'GitHub Review ID',
icon: 'IconHash',
isUnique: true,
},
{
universalIdentifier: REVIEW_EVENT_STATE_FIELD_UNIVERSAL_IDENTIFIER,
name: 'state',
type: FieldType.SELECT,
label: 'State',
icon: 'IconCircleDot',
options: [
{
value: ReviewEventState.APPROVED,
label: 'Approved',
position: 0,
color: 'green',
},
{
value: ReviewEventState.CHANGES_REQUESTED,
label: 'Changes Requested',
position: 1,
color: 'red',
},
{
value: ReviewEventState.COMMENTED,
label: 'Commented',
position: 2,
color: 'blue',
},
{
value: ReviewEventState.DISMISSED,
label: 'Dismissed',
position: 3,
color: 'gray',
},
],
},
{
universalIdentifier: REVIEW_EVENT_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
name: 'submittedAt',
type: FieldType.DATE_TIME,
label: 'Submitted At',
icon: 'IconCalendar',
isNullable: true,
defaultValue: null,
},
],
});
@@ -0,0 +1,9 @@
import type { GitHubUser } from 'src/modules/github/connector/github-user';
export type GitHubReview = {
id: number;
user: GitHubUser;
state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'DISMISSED';
submitted_at: string;
body: string | null;
};
@@ -0,0 +1,10 @@
export type PullRequestReviewEventRow = {
id: string;
title?: string | null;
githubReviewId?: number | null;
state?: string | null;
submittedAt?: string | null;
reviewerId?: string | null;
pullRequestId?: string | null;
reviewId?: string | null;
};
@@ -0,0 +1,78 @@
import {
defineView,
ViewKey,
ViewSortDirection,
ViewType,
} from 'twenty-sdk/define';
import {
PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER,
REVIEW_EVENT_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
REVIEW_EVENT_STATE_FIELD_UNIVERSAL_IDENTIFIER,
REVIEW_EVENT_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
PULL_REQUEST_REVIEW_EVENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object';
import { REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/fields/reviewer-on-review-event.field';
import { PULL_REQUEST_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/fields/pull-request-on-review-event.field';
export const ALL_REVIEW_EVENTS_VIEW_UNIVERSAL_IDENTIFIER =
'a1b2c3d4-0000-4e5f-a6b7-c8d9e0f1a2b3';
export default defineView({
universalIdentifier: ALL_REVIEW_EVENTS_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All Review Events',
objectUniversalIdentifier: PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconEye',
key: ViewKey.INDEX,
position: 0,
fields: [
{
universalIdentifier: '69e69714-05a5-48cd-98b9-13e79ba602c5',
fieldMetadataUniversalIdentifier:
REVIEW_EVENT_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 250,
},
{
universalIdentifier: 'ca794df5-74b0-4fc7-a2a7-05a72380e080',
fieldMetadataUniversalIdentifier:
REVIEW_EVENT_STATE_FIELD_UNIVERSAL_IDENTIFIER,
position: 1,
isVisible: true,
size: 150,
},
{
universalIdentifier: '624b139e-46de-416d-97b2-527c07b3a738',
fieldMetadataUniversalIdentifier:
REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
position: 2,
isVisible: true,
size: 160,
},
{
universalIdentifier: 'bcf9d3ac-d68c-410b-99f4-7e36f992f767',
fieldMetadataUniversalIdentifier:
PULL_REQUEST_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
position: 3,
isVisible: true,
size: 200,
},
{
universalIdentifier: '798ec4a2-9e72-4d48-8010-fe5dcaa7fbed',
fieldMetadataUniversalIdentifier:
REVIEW_EVENT_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
position: 4,
isVisible: true,
size: 180,
},
],
sorts: [
{
universalIdentifier: '0c8f9d6f-1a2d-4b14-a35a-8ec65a0683e3',
fieldMetadataUniversalIdentifier:
PULL_REQUEST_REVIEW_EVENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
direction: ViewSortDirection.DESC,
},
],
});
@@ -0,0 +1,23 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/objects/pull-request-review.object';
import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object';
import { REVIEW_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/fields/review-on-review-event.field';
export const EVENTS_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER =
'846f70d2-5a2b-41e1-b10c-628f874837f2';
export default defineField({
universalIdentifier: EVENTS_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'reviewEvents',
label: 'Review Events',
icon: 'IconHistory',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
REVIEW_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,30 @@
import {
defineField,
FieldType,
RelationType,
OnDeleteAction,
} from 'twenty-sdk/define';
import { PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/objects/pull-request-review.object';
import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object';
import { REVIEWS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/reviews-on-pull-request.field';
export const PULL_REQUEST_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER =
'5cc6fd4f-400f-421b-8c24-ffbf08077dd8';
export default defineField({
universalIdentifier: PULL_REQUEST_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'pullRequest',
label: 'Pull Request',
icon: 'IconGitPullRequest',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
REVIEWS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.CASCADE,
joinColumnName: 'pullRequestId',
},
});
@@ -0,0 +1,30 @@
import {
defineField,
FieldType,
RelationType,
OnDeleteAction,
} from 'twenty-sdk/define';
import { PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/objects/pull-request-review.object';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { REVIEWS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/fields/reviews-on-contributor.field';
export const REVIEWER_FIELD_UNIVERSAL_IDENTIFIER =
'2272ecc4-90e1-4c66-96bd-a7ac20280943';
export default defineField({
universalIdentifier: REVIEWER_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'reviewer',
label: 'Reviewer',
icon: 'IconUser',
relationTargetObjectMetadataUniversalIdentifier:
CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
REVIEWS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'reviewerId',
},
});
@@ -0,0 +1,19 @@
import { chunkedBatchCreate } from 'src/modules/shared/twenty-client';
import type { PullRequestReviewRow } from 'src/modules/github/pull-request-review/types/pull-request-review-row';
import type { ConsolidatedReviewUpsertInput } from 'src/modules/github/pull-request-review/utils/build-consolidated-row';
export async function batchUpsertConsolidatedReviews(
items: ConsolidatedReviewUpsertInput[],
): Promise<PullRequestReviewRow[]> {
return chunkedBatchCreate('createPullRequestReviews', items, {
id: true,
reviewKey: true,
title: true,
state: true,
firstSubmittedAt: true,
lastSubmittedAt: true,
eventCount: true,
reviewerId: true,
pullRequestId: true,
}) as Promise<PullRequestReviewRow[]>;
}
@@ -0,0 +1,164 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { getClient } from 'src/modules/shared/twenty-client';
import { timed } from 'src/modules/shared/timing';
import { batchUpsertConsolidatedReviews } from 'src/modules/github/pull-request-review/graphql/mutations/batch-upsert';
import {
buildConsolidatedRow,
type ConsolidatedReviewUpsertInput,
} from 'src/modules/github/pull-request-review/utils/build-consolidated-row';
import type { ReviewEventState } from 'src/modules/github/pull-request-review/utils/consolidate-reviews';
const PAGE_SIZE = 100;
type Edge<T> = { node: T };
type Connection<T> = {
edges: Edge<T>[];
pageInfo: { hasNextPage: boolean; endCursor: string | null };
};
type ReviewEventNode = {
state: ReviewEventState;
submittedAt: string | null;
reviewerId: string | null;
pullRequestId: string | null;
reviewer: { ghLogin: string | null } | null;
pullRequest: { githubNumber: number | null } | null;
};
type GroupContext = {
pullRequestId: string;
reviewerId: string | null;
prNumber: number | null;
reviewerLogin: string | null;
events: { state: ReviewEventState; submittedAt: string | null }[];
};
async function* paginate<T>(
fetchPage: (cursor: string | null) => Promise<Connection<T>>,
): AsyncGenerator<T[]> {
let cursor: string | null = null;
let hasMore = true;
while (hasMore) {
const conn = await fetchPage(cursor);
yield conn.edges.map((e) => e.node);
hasMore = conn.pageInfo.hasNextPage;
cursor = conn.pageInfo.endCursor;
if (!cursor) break;
}
}
const handler = async (_event: RoutePayload<unknown>) => {
const handlerStart = Date.now();
const client = getClient();
const groups = new Map<string, GroupContext>();
let eventCount = 0;
let skippedNoPr = 0;
await timed('recompute-reviews:scan-events', async () => {
for await (const batch of paginate<ReviewEventNode>(async (cursor) => {
const res = await client.query({
pullRequestReviewEvents: {
__args: { first: PAGE_SIZE, after: cursor },
edges: {
node: {
state: true,
submittedAt: true,
reviewerId: true,
pullRequestId: true,
reviewer: { ghLogin: true },
pullRequest: { githubNumber: true },
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequestReviewEvents as Connection<ReviewEventNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
})) {
for (const node of batch) {
eventCount++;
if (!node.pullRequestId) {
skippedNoPr++;
continue;
}
const key = `${node.pullRequestId}:${node.reviewerId ?? 'unknown'}`;
let group = groups.get(key);
if (!group) {
group = {
pullRequestId: node.pullRequestId,
reviewerId: node.reviewerId,
prNumber: node.pullRequest?.githubNumber ?? null,
reviewerLogin: node.reviewer?.ghLogin ?? null,
events: [],
};
groups.set(key, group);
}
if (!group.reviewerLogin && node.reviewer?.ghLogin) {
group.reviewerLogin = node.reviewer.ghLogin;
}
if (group.prNumber === null && node.pullRequest?.githubNumber != null) {
group.prNumber = node.pullRequest.githubNumber;
}
group.events.push({
state: node.state,
submittedAt: node.submittedAt,
});
}
}
});
const rows: ConsolidatedReviewUpsertInput[] = [];
for (const group of groups.values()) {
if (group.events.length === 0) continue;
rows.push(
buildConsolidatedRow({
pullRequestId: group.pullRequestId,
reviewerId: group.reviewerId,
prNumber: group.prNumber,
reviewerLogin: group.reviewerLogin,
events: group.events,
}),
);
}
let upsertedCount = 0;
if (rows.length > 0) {
const recs = await timed(
`recompute-reviews:upsert (${rows.length})`,
() => batchUpsertConsolidatedReviews(rows),
);
upsertedCount = recs.length;
}
const totalMs = Date.now() - handlerStart;
console.log(
`[recompute-reviews] done in ${totalMs}ms (events=${eventCount}, groups=${groups.size}, upserted=${upsertedCount}, skippedNoPr=${skippedNoPr})`,
);
return {
eventCount,
groupCount: groups.size,
upsertedCount,
skippedNoPr,
durationMs: totalMs,
};
};
export default defineLogicFunction({
universalIdentifier: 'fd2cbfa6-89a2-40f4-9164-0f658525cf07',
name: 'recompute-pull-request-reviews',
description:
'Backfill/repeat-safe: scans every PullRequestReviewEvent, groups by (pullRequestId, reviewerId), and upserts the consolidated PullRequestReview row using the "first substantive wins" rule.',
timeoutSeconds: 300,
handler,
httpRouteTriggerSettings: {
path: '/pull-request-reviews/recompute',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,14 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item';
import { PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/objects/pull-request-review.object';
export default defineNavigationMenuItem({
universalIdentifier: '1f2e3d4c-5b6a-4798-8abc-def012345678',
position: 4,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,120 @@
import { defineObject, FieldType } from 'twenty-sdk/define';
export const PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER =
'3c9b62b5-88a6-4a05-8e62-7637987cc8bb';
export const REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER =
'0d0f01f1-bdb2-4dc9-a915-a2eacecdedce';
export const REVIEW_KEY_FIELD_UNIVERSAL_IDENTIFIER =
'f96c80a8-d42f-414e-8eab-5c5684c0b948';
export const REVIEW_STATE_FIELD_UNIVERSAL_IDENTIFIER =
'b93fabcc-67ea-4532-847c-2b015fe90ee5';
export const REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'd7157b19-b173-41ca-96a5-98b7cc4f379e';
export const REVIEW_LAST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'ea73e23e-6010-4cb4-b9e0-9dfe572e4b69';
export const REVIEW_EVENT_COUNT_FIELD_UNIVERSAL_IDENTIFIER =
'5bca3165-8033-41da-96bb-bf381e6db45f';
export const PULL_REQUEST_REVIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'b4d61187-1b78-5d6e-9752-79f994ff6d55';
enum ReviewState {
APPROVED = 'APPROVED',
CHANGES_REQUESTED = 'CHANGES_REQUESTED',
COMMENTED = 'COMMENTED',
DISMISSED = 'DISMISSED',
}
export default defineObject({
universalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
nameSingular: 'pullRequestReview',
namePlural: 'pullRequestReviews',
labelSingular: 'Pull Request Review',
labelPlural: 'Pull Request Reviews',
icon: 'IconEye',
labelIdentifierFieldMetadataUniversalIdentifier:
REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
name: 'title',
type: FieldType.TEXT,
label: 'Title',
icon: 'IconTextCaption',
},
{
universalIdentifier: REVIEW_KEY_FIELD_UNIVERSAL_IDENTIFIER,
name: 'reviewKey',
type: FieldType.TEXT,
label: 'Review Key',
icon: 'IconKey',
isUnique: true,
},
{
universalIdentifier: REVIEW_STATE_FIELD_UNIVERSAL_IDENTIFIER,
name: 'state',
type: FieldType.SELECT,
label: 'State',
icon: 'IconCircleDot',
options: [
{
value: ReviewState.APPROVED,
label: 'Approved',
position: 0,
color: 'green',
},
{
value: ReviewState.CHANGES_REQUESTED,
label: 'Changes Requested',
position: 1,
color: 'red',
},
{
value: ReviewState.COMMENTED,
label: 'Commented',
position: 2,
color: 'blue',
},
{
value: ReviewState.DISMISSED,
label: 'Dismissed',
position: 3,
color: 'gray',
},
],
},
{
universalIdentifier:
REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
name: 'firstSubmittedAt',
type: FieldType.DATE_TIME,
label: 'First Submitted At',
icon: 'IconCalendar',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: REVIEW_LAST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
name: 'lastSubmittedAt',
type: FieldType.DATE_TIME,
label: 'Last Submitted At',
icon: 'IconCalendarTime',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: REVIEW_EVENT_COUNT_FIELD_UNIVERSAL_IDENTIFIER,
name: 'eventCount',
type: FieldType.NUMBER,
label: 'Event Count',
icon: 'IconHash',
defaultValue: 0,
},
],
});
@@ -0,0 +1,11 @@
export type PullRequestReviewRow = {
id: string;
title?: string | null;
reviewKey?: string | null;
state?: string | null;
firstSubmittedAt?: string | null;
lastSubmittedAt?: string | null;
eventCount?: number | null;
reviewerId?: string | null;
pullRequestId?: string | null;
};
@@ -0,0 +1,53 @@
import {
consolidateReviews,
type ReviewEventForConsolidation,
} from 'src/modules/github/pull-request-review/utils/consolidate-reviews';
export type ConsolidatedReviewUpsertInput = {
reviewKey: string;
title: string;
state: string;
firstSubmittedAt: string | null;
lastSubmittedAt: string | null;
eventCount: number;
reviewerId: string | null;
pullRequestId: string;
};
export type BuildConsolidatedRowParams = {
pullRequestId: string;
reviewerId: string | null;
prNumber: number | null;
reviewerLogin: string | null;
events: ReviewEventForConsolidation[];
};
export const buildReviewKey = (
pullRequestId: string,
reviewerId: string | null,
): string => `${pullRequestId}:${reviewerId ?? 'unknown'}`;
export const buildConsolidatedTitle = (
reviewerLogin: string | null,
prNumber: number | null,
): string => {
const login = reviewerLogin ?? 'unknown';
const number = typeof prNumber === 'number' ? prNumber : '?';
return `${login} on PR #${number}`;
};
export const buildConsolidatedRow = (
params: BuildConsolidatedRowParams,
): ConsolidatedReviewUpsertInput => {
const verdict = consolidateReviews(params.events);
return {
reviewKey: buildReviewKey(params.pullRequestId, params.reviewerId),
title: buildConsolidatedTitle(params.reviewerLogin, params.prNumber),
state: verdict.state,
firstSubmittedAt: verdict.firstSubmittedAt,
lastSubmittedAt: verdict.lastSubmittedAt,
eventCount: verdict.eventCount,
reviewerId: params.reviewerId,
pullRequestId: params.pullRequestId,
};
};
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';
import {
consolidateReviews,
type ReviewEventForConsolidation,
} from 'src/modules/github/pull-request-review/utils/consolidate-reviews';
import {
buildConsolidatedRow,
buildReviewKey,
buildConsolidatedTitle,
} from 'src/modules/github/pull-request-review/utils/build-consolidated-row';
const evt = (
state: ReviewEventForConsolidation['state'],
submittedAt: string | null,
): ReviewEventForConsolidation => ({ state, submittedAt });
describe('consolidateReviews', () => {
it('throws on empty input', () => {
expect(() => consolidateReviews([])).toThrow();
});
it('returns a verdict for a single COMMENTED event', () => {
const verdict = consolidateReviews([evt('COMMENTED', '2025-04-01T10:00:00Z')]);
expect(verdict).toEqual({
state: 'COMMENTED',
firstSubmittedAt: '2025-04-01T10:00:00Z',
lastSubmittedAt: '2025-04-01T10:00:00Z',
eventCount: 1,
});
});
it('picks the earliest substantive event when COMMENTED → APPROVED → COMMENTED', () => {
const verdict = consolidateReviews([
evt('COMMENTED', '2025-04-01T10:00:00Z'),
evt('APPROVED', '2025-04-02T10:00:00Z'),
evt('COMMENTED', '2025-04-03T10:00:00Z'),
]);
expect(verdict).toEqual({
state: 'APPROVED',
firstSubmittedAt: '2025-04-02T10:00:00Z',
lastSubmittedAt: '2025-04-03T10:00:00Z',
eventCount: 3,
});
});
it('keeps CHANGES_REQUESTED when it precedes APPROVED', () => {
const verdict = consolidateReviews([
evt('CHANGES_REQUESTED', '2025-04-01T08:00:00Z'),
evt('APPROVED', '2025-04-05T08:00:00Z'),
]);
expect(verdict.state).toBe('CHANGES_REQUESTED');
expect(verdict.firstSubmittedAt).toBe('2025-04-01T08:00:00Z');
expect(verdict.lastSubmittedAt).toBe('2025-04-05T08:00:00Z');
expect(verdict.eventCount).toBe(2);
});
it('falls back to first event when only DISMISSED entries exist', () => {
const verdict = consolidateReviews([
evt('DISMISSED', '2025-04-02T08:00:00Z'),
evt('DISMISSED', '2025-04-03T08:00:00Z'),
]);
expect(verdict.state).toBe('DISMISSED');
expect(verdict.firstSubmittedAt).toBe('2025-04-02T08:00:00Z');
expect(verdict.lastSubmittedAt).toBe('2025-04-03T08:00:00Z');
expect(verdict.eventCount).toBe(2);
});
it('handles only COMMENTED entries', () => {
const verdict = consolidateReviews([
evt('COMMENTED', '2025-04-01T10:00:00Z'),
evt('COMMENTED', '2025-04-02T10:00:00Z'),
]);
expect(verdict.state).toBe('COMMENTED');
expect(verdict.firstSubmittedAt).toBe('2025-04-01T10:00:00Z');
expect(verdict.lastSubmittedAt).toBe('2025-04-02T10:00:00Z');
});
it('is order-independent', () => {
const a = consolidateReviews([
evt('COMMENTED', '2025-04-01T10:00:00Z'),
evt('APPROVED', '2025-04-02T10:00:00Z'),
]);
const b = consolidateReviews([
evt('APPROVED', '2025-04-02T10:00:00Z'),
evt('COMMENTED', '2025-04-01T10:00:00Z'),
]);
expect(a).toEqual(b);
});
it('handles null submittedAt by treating it as the earliest', () => {
const verdict = consolidateReviews([
evt('APPROVED', '2025-04-01T10:00:00Z'),
evt('COMMENTED', null),
]);
expect(verdict.eventCount).toBe(2);
expect(verdict.state).toBe('APPROVED');
});
});
describe('buildReviewKey', () => {
it('joins pullRequestId and reviewerId with a colon', () => {
expect(buildReviewKey('pr-1', 'rev-2')).toBe('pr-1:rev-2');
});
it('uses "unknown" when reviewerId is null', () => {
expect(buildReviewKey('pr-1', null)).toBe('pr-1:unknown');
});
});
describe('buildConsolidatedTitle', () => {
it('formats reviewer login + PR number', () => {
expect(buildConsolidatedTitle('alice', 42)).toBe('alice on PR #42');
});
it('falls back gracefully on missing data', () => {
expect(buildConsolidatedTitle(null, null)).toBe('unknown on PR #?');
});
});
describe('buildConsolidatedRow', () => {
it('produces the expected upsert payload', () => {
const row = buildConsolidatedRow({
pullRequestId: 'pr-1',
reviewerId: 'rev-2',
prNumber: 7,
reviewerLogin: 'alice',
events: [
evt('COMMENTED', '2025-04-01T10:00:00Z'),
evt('APPROVED', '2025-04-02T10:00:00Z'),
evt('COMMENTED', '2025-04-03T10:00:00Z'),
],
});
expect(row).toEqual({
reviewKey: 'pr-1:rev-2',
title: 'alice on PR #7',
state: 'APPROVED',
firstSubmittedAt: '2025-04-02T10:00:00Z',
lastSubmittedAt: '2025-04-03T10:00:00Z',
eventCount: 3,
reviewerId: 'rev-2',
pullRequestId: 'pr-1',
});
});
});
@@ -0,0 +1,54 @@
export type ReviewEventState =
| 'APPROVED'
| 'CHANGES_REQUESTED'
| 'COMMENTED'
| 'DISMISSED';
export type ReviewEventForConsolidation = {
state: ReviewEventState;
submittedAt: string | null;
};
export type ConsolidatedVerdict = {
state: ReviewEventState;
firstSubmittedAt: string | null;
lastSubmittedAt: string | null;
eventCount: number;
};
const SUBSTANTIVE_STATES: ReadonlyArray<ReviewEventState> = [
'APPROVED',
'CHANGES_REQUESTED',
];
const isSubstantive = (state: ReviewEventState): boolean =>
SUBSTANTIVE_STATES.includes(state);
const compareSubmittedAt = (
a: ReviewEventForConsolidation,
b: ReviewEventForConsolidation,
): number => {
const av = a.submittedAt ?? '';
const bv = b.submittedAt ?? '';
if (av === bv) return 0;
return av < bv ? -1 : 1;
};
export const consolidateReviews = (
events: ReviewEventForConsolidation[],
): ConsolidatedVerdict => {
if (events.length === 0) {
throw new Error('consolidateReviews called with empty events');
}
const sorted = [...events].sort(compareSubmittedAt);
const substantive = sorted.find((e) => isSubstantive(e.state));
const chosen = substantive ?? sorted[0];
return {
state: chosen.state,
firstSubmittedAt: chosen.submittedAt,
lastSubmittedAt: sorted[sorted.length - 1].submittedAt,
eventCount: sorted.length,
};
};
@@ -0,0 +1,95 @@
import {
defineView,
ViewKey,
ViewSortDirection,
ViewType,
} from 'twenty-sdk/define';
import {
PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
REVIEW_STATE_FIELD_UNIVERSAL_IDENTIFIER,
REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
REVIEW_LAST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
REVIEW_EVENT_COUNT_FIELD_UNIVERSAL_IDENTIFIER,
PULL_REQUEST_REVIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/pull-request-review/objects/pull-request-review.object';
import { REVIEWER_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/fields/reviewer-on-review.field';
import { PULL_REQUEST_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/fields/pull-request-on-review.field';
export const ALL_REVIEWS_VIEW_UNIVERSAL_IDENTIFIER =
'3559d020-bdf4-424b-9b6a-bc3cbb3d4502';
export default defineView({
universalIdentifier: ALL_REVIEWS_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All Reviews',
objectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconEye',
key: ViewKey.INDEX,
position: 0,
fields: [
{
universalIdentifier: '63010877-3fd3-43f0-a105-66168a0c9782',
fieldMetadataUniversalIdentifier:
REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 240,
},
{
universalIdentifier: 'c7f3ad93-e4bc-47d6-a3c3-664d9975bb60',
fieldMetadataUniversalIdentifier:
REVIEW_STATE_FIELD_UNIVERSAL_IDENTIFIER,
position: 1,
isVisible: true,
size: 150,
},
{
universalIdentifier: 'ab0cd954-95e6-4e29-8a73-2ad614dc6b1f',
fieldMetadataUniversalIdentifier: REVIEWER_FIELD_UNIVERSAL_IDENTIFIER,
position: 2,
isVisible: true,
size: 160,
},
{
universalIdentifier: '43440edf-ef04-413a-a39c-3ae70b839166',
fieldMetadataUniversalIdentifier:
PULL_REQUEST_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER,
position: 3,
isVisible: true,
size: 200,
},
{
universalIdentifier: '20ddfe2f-0310-4c01-af85-b7c75d801e26',
fieldMetadataUniversalIdentifier:
REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
position: 4,
isVisible: true,
size: 170,
},
{
universalIdentifier: '4e274558-49e9-465a-98c3-44177c6138c3',
fieldMetadataUniversalIdentifier:
REVIEW_LAST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
position: 5,
isVisible: true,
size: 170,
},
{
universalIdentifier: 'd2964ab2-7e05-4ee0-9219-b88cfef78230',
fieldMetadataUniversalIdentifier:
REVIEW_EVENT_COUNT_FIELD_UNIVERSAL_IDENTIFIER,
position: 6,
isVisible: true,
size: 110,
},
],
sorts: [
{
universalIdentifier: 'add26365-5060-411e-b0a7-1ce32155d4f1',
fieldMetadataUniversalIdentifier:
PULL_REQUEST_REVIEW_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
direction: ViewSortDirection.DESC,
},
],
});
@@ -0,0 +1,25 @@
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';
import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { AUTHORED_PRS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/fields/authored-prs-on-contributor.field';
export const AUTHOR_FIELD_UNIVERSAL_IDENTIFIER =
'b2c3d4e5-1a2b-4c3d-8e5f-6a7b8c9d0e1f';
export default defineField({
universalIdentifier: AUTHOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'author',
label: 'Author',
icon: 'IconUser',
relationTargetObjectMetadataUniversalIdentifier:
CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
AUTHORED_PRS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'authorId',
},
});
@@ -0,0 +1,25 @@
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';
import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { MERGED_PULL_REQUESTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/fields/merged-pull-requests-on-contributor.field';
export const MERGER_FIELD_UNIVERSAL_IDENTIFIER =
'3a4a6af0-a6a7-4cb6-a91d-273a2842449e';
export default defineField({
universalIdentifier: MERGER_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'merger',
label: 'Merger',
icon: 'IconUser',
relationTargetObjectMetadataUniversalIdentifier:
CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
MERGED_PULL_REQUESTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'mergerId',
},
});
@@ -0,0 +1,23 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object';
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
import { LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/linked-pr-on-project-item.field';
export const PROJECT_ITEMS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER =
'ad5e6f7a-8b9c-4d0e-bf1a-2b3c4d5e6f7a';
export default defineField({
universalIdentifier: PROJECT_ITEMS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'projectItems',
label: 'Project Items',
icon: 'IconLayoutKanban',
relationTargetObjectMetadataUniversalIdentifier:
PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,23 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object';
import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object';
import { PULL_REQUEST_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/fields/pull-request-on-review-event.field';
export const REVIEW_EVENTS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER =
'299b3ef8-cba0-4f16-a785-81728df09c46';
export default defineField({
universalIdentifier: REVIEW_EVENTS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'reviewEvents',
label: 'Review Events',
icon: 'IconEye',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
PULL_REQUEST_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});

Some files were not shown because too many files have changed in this diff Show More