Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efc3db8ed6 | ||
|
|
8838aafd18 | ||
|
|
d7154df876 |
@@ -25,7 +25,6 @@ jobs:
|
||||
packages/twenty-server/**
|
||||
packages/twenty-front/src/generated/**
|
||||
packages/twenty-front/src/generated-metadata/**
|
||||
packages/twenty-front/src/generated-admin/**
|
||||
packages/twenty-client-sdk/**
|
||||
packages/twenty-emails/**
|
||||
packages/twenty-shared/**
|
||||
@@ -166,14 +165,13 @@ jobs:
|
||||
|
||||
npx nx run twenty-front:graphql:generate
|
||||
npx nx run twenty-front:graphql:generate --configuration=metadata
|
||||
npx nx run twenty-front:graphql:generate --configuration=admin
|
||||
|
||||
if ! git diff --quiet -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata packages/twenty-front/src/generated-admin; then
|
||||
echo "::error::GraphQL schema changes detected. Please run the three graphql:generate configurations ('data', 'metadata', 'admin') and commit the changes."
|
||||
if ! git diff --quiet -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata; then
|
||||
echo "::error::GraphQL schema changes detected. Please run 'npx nx run twenty-front:graphql:generate' and 'npx nx run twenty-front:graphql:generate --configuration=metadata' and commit the changes."
|
||||
echo ""
|
||||
echo "The following GraphQL schema changes were detected:"
|
||||
echo "==================================================="
|
||||
git diff -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata packages/twenty-front/src/generated-admin
|
||||
git diff -- packages/twenty-front/src/generated packages/twenty-front/src/generated-metadata
|
||||
echo "==================================================="
|
||||
echo ""
|
||||
HAS_ERRORS=true
|
||||
|
||||
+1
-2
@@ -1,8 +1,7 @@
|
||||
**/**/.env
|
||||
.DS_Store
|
||||
/.idea
|
||||
.claude/
|
||||
.cursor/debug-*.log
|
||||
.claude/settings.json
|
||||
**/**/node_modules/
|
||||
.cache
|
||||
|
||||
|
||||
@@ -4,161 +4,123 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h2 align="center" >The #1 Open-Source CRM</h2>
|
||||
<h2 align="center" >The #1 Open-Source CRM </h2>
|
||||
|
||||
<p align="center"><a href="https://twenty.com">🌐 Website</a> · <a href="https://docs.twenty.com">📚 Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.png" width="12" height="12"/> Figma</a></p>
|
||||
<br />
|
||||
|
||||
<p align="center"><a href="https://twenty.com"><img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="12" height="12"/> Website</a> · <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="12" height="12"/> Documentation</a> · <a href="https://github.com/orgs/twentyhq/projects/1"><img src="./packages/twenty-website/public/images/readme/map-icon.svg" width="12" height="12"/> Roadmap </a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://www.figma.com/file/xt8O9mFeLl46C5InWwoMrN/Twenty"><img src="./packages/twenty-website/public/images/readme/figma-icon.png" width="12" height="12"/> Figma</a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.twenty.com">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/github-cover-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/github-cover-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/github-cover-light.png" alt="Twenty banner" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/github-cover-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/github-cover-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/github-cover-light.png" alt="Cover" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
# Why Twenty
|
||||
|
||||
Twenty gives technical teams the building blocks for a custom CRM that meets complex business needs and quickly adapts as the business evolves. Twenty is the CRM you build, ship, and version like the rest of your stack.
|
||||
|
||||
<a href="https://twenty.com/why-twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="14" height="14"/> Learn more about why we built Twenty</a>
|
||||
|
||||
<br />
|
||||
|
||||
# Installation
|
||||
|
||||
### <img src="./packages/twenty-website/public/images/readme/globe-icon.svg" width="14" height="14"/> Cloud
|
||||
See:
|
||||
🚀 [Self-hosting](https://docs.twenty.com/developers/self-host/capabilities/docker-compose)
|
||||
🖥️ [Local Setup](https://docs.twenty.com/developers/contribute/capabilities/local-setup)
|
||||
|
||||
The fastest way to get started. Sign up at [twenty.com](https://twenty.com) and spin up a workspace in under a minute, with no infrastructure to manage and always up to date.
|
||||
# Why Twenty
|
||||
|
||||
### <img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Build an app
|
||||
We built Twenty for three reasons:
|
||||
|
||||
Scaffold a new app with the Twenty CLI:
|
||||
**CRMs are too expensive, and users are trapped.** Companies use locked-in customer data to hike prices. It shouldn't be that way.
|
||||
|
||||
```bash
|
||||
npx create-twenty-app my-app
|
||||
```
|
||||
**A fresh start is required to build a better experience.** We can learn from past mistakes and craft a cohesive experience inspired by new UX patterns from tools like Notion, Airtable or Linear.
|
||||
|
||||
Define objects, fields, and views as code:
|
||||
|
||||
```ts
|
||||
import { defineObject, FieldType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineObject({
|
||||
nameSingular: 'deal',
|
||||
namePlural: 'deals',
|
||||
labelSingular: 'Deal',
|
||||
labelPlural: 'Deals',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Name', type: FieldType.TEXT },
|
||||
{ name: 'amount', label: 'Amount', type: FieldType.CURRENCY },
|
||||
{ name: 'closeDate', label: 'Close Date', type: FieldType.DATE_TIME },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Then ship it to your workspace:
|
||||
|
||||
```bash
|
||||
npx twenty deploy
|
||||
```
|
||||
|
||||
See the [app development guide](https://docs.twenty.com/developers/extend/apps/getting-started) for objects, views, agents, and logic functions.
|
||||
|
||||
### <img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="14" height="14"/> Self-hosting
|
||||
|
||||
Run Twenty on your own infrastructure with [Docker Compose](https://docs.twenty.com/developers/self-host/capabilities/docker-compose), or contribute locally via the [local setup guide](https://docs.twenty.com/developers/contribute/capabilities/local-setup).
|
||||
**We believe in open-source and community.** Hundreds of developers are already building Twenty together. Once we have plugin capabilities, a whole ecosystem will grow around it.
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
# Everything you need
|
||||
# What You Can Do With Twenty
|
||||
|
||||
Twenty gives you the building blocks of a modern CRM (objects, views, workflows, and agents) and lets you extend them as code. Here's a tour of what's in the box.
|
||||
Please feel free to flag any specific needs you have by creating an issue.
|
||||
|
||||
Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="14" height="14"/> User Guide</a> for product walkthroughs, or the <a href="https://docs.twenty.com"><img src="./packages/twenty-website/public/images/readme/book-icon.svg" width="14" height="14"/> Documentation</a> for developer reference.
|
||||
Below are a few features we have implemented to date:
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-build-apps-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-build-apps-light.png" alt="Create your apps" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/getting-started"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="16" height="16"/> Learn more about apps in doc</a></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-version-control-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-version-control-light.png" alt="Stay on top with version control" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/publishing"><img src="./packages/twenty-website/public/images/readme/monitor-icon.svg" width="16" height="16"/> Learn more about version control in doc</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-all-tools-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-all-tools-light.png" alt="All the tools you need to build anything" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/developers/extend/apps/building"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="16" height="16"/> Learn more about primitives in doc</a></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-tools-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-tools-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-tools-light.png" alt="Customize your layouts" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/layout/overview"><img src="./packages/twenty-website/public/images/readme/planner-icon.svg" width="16" height="16"/> Learn more about layouts in doc</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-ai-agents-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-ai-agents-light.png" alt="AI agents and chats" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/ai/overview"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="16" height="16"/> Learn more about AI in doc</a></p>
|
||||
</td>
|
||||
<td width="50%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="./packages/twenty-website/public/images/readme/v2-crm-tools-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/v2-crm-tools-light.png" alt="Plus all the tools of a good CRM" />
|
||||
</picture>
|
||||
<p align="center"><a href="https://docs.twenty.com/user-guide/introduction"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="16" height="16"/> Learn more about CRM features in doc</a></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
+ [Personalize layouts with filters, sort, group by, kanban and table views](#personalize-layouts-with-filters-sort-group-by-kanban-and-table-views)
|
||||
+ [Customize your objects and fields](#customize-your-objects-and-fields)
|
||||
+ [Create and manage permissions with custom roles](#create-and-manage-permissions-with-custom-roles)
|
||||
+ [Automate workflow with triggers and actions](#automate-workflow-with-triggers-and-actions)
|
||||
+ [Emails, calendar events, files, and more](#emails-calendar-events-files-and-more)
|
||||
|
||||
|
||||
## Personalize layouts with filters, sort, group by, kanban and table views
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/views-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/views-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/views-light.png" alt="Companies Kanban Views" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
## Customize your objects and fields
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/data-model-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/data-model-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/data-model-light.png" alt="Setting Custom Objects" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
## Create and manage permissions with custom roles
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/permissions-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/permissions-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/permissions-light.png" alt="Permissions" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
## Automate workflow with triggers and actions
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/workflows-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/workflows-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/workflows-light.png" alt="Workflows" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
## Emails, calendar events, files, and more
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/plus-other-features-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/twentyhq/twenty/refs/heads/main/packages/twenty-website/public/images/readme/plus-other-features-light.png" />
|
||||
<img src="./packages/twenty-website/public/images/readme/plus-other-features-light.png" alt="Other Features" />
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
# Stack
|
||||
|
||||
- <a href="https://www.typescriptlang.org/"><img src="./packages/twenty-website/public/images/readme/stack-typescript.svg" width="14" height="14"/> TypeScript</a>
|
||||
- <a href="https://nx.dev/"><img src="./packages/twenty-website/public/images/readme/stack-nx.svg" width="14" height="14"/> Nx</a>
|
||||
- <a href="https://nestjs.com/"><img src="./packages/twenty-website/public/images/readme/stack-nestjs.svg" width="14" height="14"/> NestJS</a>, with <a href="https://bullmq.io/">BullMQ</a>, <a href="https://www.postgresql.org/"><img src="./packages/twenty-website/public/images/readme/stack-postgresql.svg" width="14" height="14"/> PostgreSQL</a>, <a href="https://redis.io/"><img src="./packages/twenty-website/public/images/readme/stack-redis.svg" width="14" height="14"/> Redis</a>
|
||||
- <a href="https://reactjs.org/"><img src="./packages/twenty-website/public/images/readme/stack-react.svg" width="14" height="14"/> React</a>, with <a href="https://jotai.org/">Jotai</a>, <a href="https://linaria.dev/">Linaria</a> and <a href="https://lingui.dev/">Lingui</a>
|
||||
- [TypeScript](https://www.typescriptlang.org/)
|
||||
- [Nx](https://nx.dev/)
|
||||
- [NestJS](https://nestjs.com/), with [BullMQ](https://bullmq.io/), [PostgreSQL](https://www.postgresql.org/), [Redis](https://redis.io/)
|
||||
- [React](https://reactjs.org/), with [Jotai](https://jotai.org/), [Linaria](https://linaria.dev/) and [Lingui](https://lingui.dev/)
|
||||
|
||||
|
||||
|
||||
# Thanks
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.png" height="28" alt="Chromatic" /></a>
|
||||
|
||||
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.png" height="28" alt="Greptile" /></a>
|
||||
|
||||
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.png" height="28" alt="Sentry" /></a>
|
||||
|
||||
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.png" height="28" alt="Crowdin" /></a>
|
||||
<a href="https://www.chromatic.com/"><img src="./packages/twenty-website/public/images/readme/chromatic.png" height="30" alt="Chromatic" /></a>
|
||||
<a href="https://greptile.com"><img src="./packages/twenty-website/public/images/readme/greptile.png" height="30" alt="Greptile" /></a>
|
||||
<a href="https://sentry.io/"><img src="./packages/twenty-website/public/images/readme/sentry.png" height="30" alt="Sentry" /></a>
|
||||
<a href="https://crowdin.com/"><img src="./packages/twenty-website/public/images/readme/crowdin.png" height="30" alt="Crowdin" /></a>
|
||||
<a href="https://e2b.dev/"><img src="./packages/twenty-website/public/images/readme/e2b.svg" height="30" alt="E2B" /></a>
|
||||
</p>
|
||||
|
||||
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
|
||||
@@ -166,4 +128,9 @@ Want to go deeper? Read the <a href="https://docs.twenty.com/user-guide/introduc
|
||||
|
||||
# Join the Community
|
||||
|
||||
<p><a href="https://github.com/twentyhq/twenty"><img src="./packages/twenty-website/public/images/readme/star-icon.svg" width="12" height="12"/> Star the repo</a> · <a href="https://discord.gg/cx5n4Jzs57"><img src="./packages/twenty-website/public/images/readme/discord-icon.svg" width="12" height="12"/> Discord</a> · <a href="https://github.com/twentyhq/twenty/discussions"><img src="./packages/twenty-website/public/images/readme/message-icon.svg" width="12" height="12"/> Feature requests</a> · <a href="https://github.com/orgs/twentyhq/projects/1/views/35"><img src="./packages/twenty-website/public/images/readme/rocket-icon.svg" width="12" height="12"/> Releases</a> · <a href="https://twitter.com/twentycrm"><img src="./packages/twenty-website/public/images/readme/x-icon.svg" width="12" height="12"/> X</a> · <a href="https://www.linkedin.com/company/twenty/"><img src="./packages/twenty-website/public/images/readme/linkedin-icon.svg" width="12" height="12"/> LinkedIn</a> · <a href="https://twenty.crowdin.com/twenty"><img src="./packages/twenty-website/public/images/readme/language-icon.svg" width="12" height="12"/> Crowdin</a> · <a href="https://github.com/twentyhq/twenty/contribute"><img src="./packages/twenty-website/public/images/readme/code-icon.svg" width="12" height="12"/> Contribute</a></p>
|
||||
- Star the repo
|
||||
- Subscribe to releases (watch -> custom -> releases)
|
||||
- Follow us on [Twitter](https://twitter.com/twentycrm) or [LinkedIn](https://www.linkedin.com/company/twenty/)
|
||||
- Join our [Discord](https://discord.gg/cx5n4Jzs57)
|
||||
- Improve translations on [Crowdin](https://twenty.crowdin.com/twenty)
|
||||
- [Contributions](https://github.com/twentyhq/twenty/contribute) are, of course, most welcome!
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-twenty-app",
|
||||
"version": "2.0.0",
|
||||
"version": "1.22.0",
|
||||
"description": "Command-line interface to create Twenty application",
|
||||
"main": "dist/cli.cjs",
|
||||
"bin": "dist/cli.cjs",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
|
||||
import {
|
||||
APP_DESCRIPTION,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineRole } from 'twenty-sdk/define';
|
||||
import { defineRole } from 'twenty-sdk';
|
||||
|
||||
import {
|
||||
APP_DISPLAY_NAME,
|
||||
|
||||
@@ -149,25 +149,6 @@ describe('copyBaseApplicationProject', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should create an empty public directory in the scaffolded project', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
appDisplayName: 'My Test App',
|
||||
appDescription: 'A test application',
|
||||
appDirectory: testAppDirectory,
|
||||
});
|
||||
|
||||
const publicDirectoryPath = join(testAppDirectory, 'public');
|
||||
|
||||
expect(await fs.pathExists(publicDirectoryPath)).toBe(true);
|
||||
|
||||
const publicDirectoryStats = await fs.stat(publicDirectoryPath);
|
||||
expect(publicDirectoryStats.isDirectory()).toBe(true);
|
||||
|
||||
const publicDirectoryContents = await fs.readdir(publicDirectoryPath);
|
||||
expect(publicDirectoryContents).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle empty description', async () => {
|
||||
await copyBaseApplicationProject({
|
||||
appName: 'my-test-app',
|
||||
|
||||
@@ -23,8 +23,6 @@ export const copyBaseApplicationProject = async ({
|
||||
|
||||
await renameDotfiles({ appDirectory });
|
||||
|
||||
await addEmptyPublicDirectory({ appDirectory });
|
||||
|
||||
await generateUniversalIdentifiers({
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
@@ -51,14 +49,6 @@ const renameDotfiles = async ({ appDirectory }: { appDirectory: string }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const addEmptyPublicDirectory = async ({
|
||||
appDirectory,
|
||||
}: {
|
||||
appDirectory: string;
|
||||
}) => {
|
||||
await fs.ensureDir(join(appDirectory, 'public'));
|
||||
};
|
||||
|
||||
const generateUniversalIdentifiers = async ({
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DEFAULT_ROLE_UNIVERSAL_IDENTIFIER } from 'src/roles/default-role';
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
import { defineApplication } from 'twenty-sdk';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: 'ac1d2ed1-8835-4bd4-9043-28b46fdda465',
|
||||
|
||||
+5
-1
@@ -1,4 +1,8 @@
|
||||
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: 'da15cfc6-3657-457d-8757-4ba11b5bb6e1',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: '505532f5-1fc5-4a58-8074-ba9b48650dbc',
|
||||
|
||||
+5
-1
@@ -1,4 +1,8 @@
|
||||
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: 'be15e062-b065-48b4-979c-65b9a50e0cb1',
|
||||
|
||||
+5
-1
@@ -1,4 +1,8 @@
|
||||
import { defineField, FieldType, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
import {
|
||||
defineField,
|
||||
FieldType,
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
|
||||
} from 'twenty-sdk';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: 'c90ae72d-4ddf-4f22-882f-eef98c91e40e',
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import styled from '@emotion/styled';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { OAuthApplicationVariables } from 'src/logic-functions/get-oauth-application-variables';
|
||||
import { VERIFY_PAGE_PATH } from 'src/logic-functions/get-verify-page';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { defineFrontComponent } from 'twenty-sdk';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { defineLogicFunction, RoutePayload } from "twenty-sdk/define";
|
||||
import { defineLogicFunction, RoutePayload } from "twenty-sdk";
|
||||
import { MetadataApiClient } from 'twenty-sdk/clients';
|
||||
|
||||
export const OAUTH_TOKEN_PAIRS_PATH = '/oauth/token-pairs';
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { defineLogicFunction } from 'twenty-sdk/define';
|
||||
import { defineLogicFunction } from 'twenty-sdk';
|
||||
|
||||
export type OAuthApplicationVariables = {
|
||||
apolloClientId: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineLogicFunction } from "twenty-sdk/define";
|
||||
import { defineLogicFunction } from "twenty-sdk";
|
||||
|
||||
export const VERIFY_PAGE_PATH = '/oauth/verify';
|
||||
|
||||
|
||||
+5
-1
@@ -1,4 +1,8 @@
|
||||
import { defineLogicFunction, type DatabaseEventPayload, type ObjectRecordUpdateEvent } from 'twenty-sdk/define';
|
||||
import {
|
||||
defineLogicFunction,
|
||||
type DatabaseEventPayload,
|
||||
type ObjectRecordUpdateEvent,
|
||||
} from 'twenty-sdk';
|
||||
import { CoreApiClient } from 'twenty-sdk/clients';
|
||||
|
||||
type CompanyRecord = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk/define';
|
||||
import { definePostInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||||
|
||||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||||
console.log('Post install logic function executed successfully!', payload.previousVersion);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk/define';
|
||||
import { definePreInstallLogicFunction, type InstallLogicFunctionPayload } from 'twenty-sdk';
|
||||
|
||||
const handler = async (payload: InstallLogicFunctionPayload): Promise<void> => {
|
||||
console.log('Pre install logic function executed successfully!', payload.previousVersion);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineRole } from 'twenty-sdk/define';
|
||||
import { defineRole } from 'twenty-sdk';
|
||||
|
||||
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
|
||||
'b8faae3f-e174-43fa-ab94-715712ae26cb';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ApplicationConfig } from 'twenty-sdk/define';
|
||||
import { type ApplicationConfig } from 'twenty-sdk';
|
||||
|
||||
const config: ApplicationConfig = {
|
||||
universalIdentifier: 'a4df0c0f-c65e-44e5-8436-24814182d4ac',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Object } from 'twenty-sdk/define';
|
||||
import { Object } from 'twenty-sdk';
|
||||
|
||||
@Object({
|
||||
universalIdentifier: 'd1831348-b4a4-4426-9c0b-0af19e7a9c27',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FunctionConfig } from 'twenty-sdk/define';
|
||||
import { type FunctionConfig } from 'twenty-sdk';
|
||||
import type { ProcessResult } from './types';
|
||||
import { WebhookHandler } from './webhook-handler';
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn
|
||||
|
||||
# codegen
|
||||
generated
|
||||
|
||||
# dev
|
||||
/dist/
|
||||
.twenty
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
*.d.ts
|
||||
@@ -1 +0,0 @@
|
||||
24.5.0
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript"],
|
||||
"categories": {
|
||||
"correctness": "off"
|
||||
},
|
||||
"ignorePatterns": ["node_modules", "dist"],
|
||||
"rules": {
|
||||
"no-unused-vars": "off",
|
||||
|
||||
"typescript/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"typescript/no-explicit-any": "off"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -1,254 +0,0 @@
|
||||
# GitHub Connector
|
||||
|
||||
Sync pull requests, issues, contributors and project items from GitHub into
|
||||
Twenty, and react to GitHub webhook events in real time.
|
||||
|
||||
This app showcases how to build a non-trivial third-party connector with the
|
||||
Twenty SDK: custom objects with rich relationships, navigation menu items,
|
||||
table views, a dashboard page layout, logic functions for periodic syncs, an
|
||||
HTTP webhook handler, and authenticated GraphQL/REST calls against an
|
||||
external provider.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## What it adds to your workspace
|
||||
|
||||
### Custom objects
|
||||
|
||||
Six custom objects, each with fields, relationships and table views:
|
||||
|
||||
- `pullRequest`
|
||||
- `pullRequestReview`
|
||||
- `pullRequestReviewEvent`
|
||||
- `issue`
|
||||
- `projectItem`
|
||||
- `contributor`
|
||||
|
||||
### Navigation
|
||||
|
||||
A top-level **GitHub** folder in the left sidebar with:
|
||||
|
||||
- Pull Requests
|
||||
- Issues
|
||||
- Project Items
|
||||
- Contributors
|
||||
- Pull Request Reviews
|
||||
- Pull Request Review Events
|
||||
- GitHub Dashboard (a page layout that aggregates PR activity over time and
|
||||
surfaces top contributors)
|
||||
|
||||
### Logic functions
|
||||
|
||||
| Function | Trigger |
|
||||
| --------------------------------- | ------------------------------------------------ |
|
||||
| `count-prs` | HTTP `POST /github/count-prs` |
|
||||
| `fetch-prs` | HTTP `POST /github/fetch-prs` |
|
||||
| `count-issues` | HTTP `POST /github/count-issues` |
|
||||
| `fetch-issues` | HTTP `POST /github/fetch-issues` |
|
||||
| `count-contributors` | HTTP `POST /github/count-contributors` |
|
||||
| `fetch-contributors` | HTTP `POST /github/fetch-contributors` |
|
||||
| `count-project-items` | HTTP `POST /github/count-project-items` |
|
||||
| `fetch-project-items` | HTTP `POST /github/fetch-project-items` |
|
||||
| `handle-github-webhook` | HTTP `POST /github/webhook` (no auth, signed) |
|
||||
| `search-contributors` | HTTP `POST /contributors/search` |
|
||||
| `contributor-stats` | HTTP `POST /contributors/stats` |
|
||||
| `top-contributors` | HTTP `POST /contributors/top` |
|
||||
| `recompute-pull-request-reviews` | HTTP `POST /pull-request-reviews/recompute` |
|
||||
|
||||
### Front components
|
||||
|
||||
Seven front components surface the connector inside the Twenty UI:
|
||||
|
||||
- **Fetch Pull Requests** — command on the Pull Request object
|
||||
- **Fetch Issues** — command on the Issue object
|
||||
- **Fetch Contributors** — command on the Contributor object
|
||||
- **Fetch Project Items** — command on the Project Item object
|
||||
- **Contributor Stats** — panel on the Contributor object that renders a
|
||||
bar chart of PRs authored / merged / reviewed over the selected period
|
||||
- **Top PR Authors** — dashboard widget that ranks the top 20 PR authors
|
||||
over the last 90 days
|
||||
- **Top Reviewers** — dashboard widget that ranks the top 20 PR reviewers
|
||||
over the last 90 days
|
||||
|
||||
## Install
|
||||
|
||||
You have two options. Use **dev mode** for a tight edit/test loop while
|
||||
iterating on the app, or **install** for a one-shot deploy.
|
||||
|
||||
### Option A — Live development (`yarn twenty dev`)
|
||||
|
||||
Use this when you want every code change to be re-synced into your local
|
||||
Twenty server automatically.
|
||||
|
||||
```bash
|
||||
cd packages/twenty-apps/community/github-connector
|
||||
yarn install
|
||||
|
||||
# Register your local Twenty server as a remote (interactive prompt).
|
||||
# When asked for the URL use http://localhost:2021 and paste an API key
|
||||
# from Settings -> Developers in the Twenty UI.
|
||||
yarn twenty remote add
|
||||
|
||||
# Build, install, and watch for changes.
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
The first `yarn twenty dev` run installs the app on the remote and starts
|
||||
watching `src/`. Edit any file and the change is re-synced within seconds.
|
||||
|
||||
### Option B — One-shot install
|
||||
|
||||
```bash
|
||||
cd packages/twenty-apps/community/github-connector
|
||||
yarn install
|
||||
yarn twenty remote add # same prompts as above
|
||||
yarn twenty install # builds and installs once
|
||||
```
|
||||
|
||||
## Configure authentication
|
||||
|
||||
Once the app is installed, open the Twenty UI and go to
|
||||
**Settings → Apps → GitHub Connector**. You only need one of the two auth
|
||||
methods.
|
||||
|
||||
### Option 1 — Personal Access Token (recommended for trying it out)
|
||||
|
||||
| Variable | Required | Notes |
|
||||
| --------------- | -------- | -------------------------------------------------------------------- |
|
||||
| `GITHUB_TOKEN` | yes | Fine-grained PAT (`github_pat_…`). See permissions below. |
|
||||
|
||||
Create a fine-grained PAT at
|
||||
<https://github.com/settings/personal-access-tokens>:
|
||||
|
||||
1. **Resource owner**: the org (or user) that owns the repos in
|
||||
`GITHUB_REPOS` and the projects in `GITHUB_PROJECTS`. Org-owned tokens
|
||||
must be approved by an org admin before they can read org resources.
|
||||
2. **Repository access**: pick the specific repos (or "All repositories").
|
||||
3. **Repository permissions** — set to **Read-only**:
|
||||
- `Contents`
|
||||
- `Issues`
|
||||
- `Pull requests`
|
||||
- `Metadata` (selected automatically)
|
||||
4. **Organization permissions** — only if you want to sync GitHub Projects
|
||||
(v2): set `Projects` to **Read-only**.
|
||||
5. Generate, then copy the `github_pat_…` value.
|
||||
|
||||
Classic PATs are intentionally not supported — fine-grained tokens are
|
||||
scoped per-repo/per-org and avoid the all-or-nothing `repo` scope.
|
||||
|
||||
When `GITHUB_TOKEN` is set, it always wins regardless of any GitHub App
|
||||
config below.
|
||||
|
||||
### Option 2 — GitHub App (recommended for production / org-wide installs)
|
||||
|
||||
| Variable | Required | Notes |
|
||||
| ---------------------------- | -------- | ---------------------------------------------------------------------- |
|
||||
| `GITHUB_APP_ID` | yes | Numeric App ID from the GitHub App settings page. |
|
||||
| `GITHUB_APP_PRIVATE_KEY` | yes | PEM private key (BEGIN/END PRIVATE KEY block). Newlines are tolerant. |
|
||||
| `GITHUB_APP_INSTALLATION_ID` | yes | The installation id of the App on your org/user. |
|
||||
|
||||
To create one:
|
||||
|
||||
1. <https://github.com/settings/apps/new> (or
|
||||
`https://github.com/organizations/<org>/settings/apps/new`).
|
||||
2. Grant the App these **repository permissions**: `Contents: Read`,
|
||||
`Issues: Read`, `Pull Requests: Read`, `Metadata: Read`. For Projects v2
|
||||
add `Organization → Projects: Read`.
|
||||
3. Generate a private key (downloads a `.pem`).
|
||||
4. Install the App on your org/user — the URL bar of the post-install page
|
||||
contains the installation id, e.g. `.../installations/12345678`.
|
||||
5. Paste the App ID, the PEM contents, and the installation ID into the
|
||||
variables above.
|
||||
|
||||
The connector exchanges the App credentials for a short-lived installation
|
||||
token (cached in-memory until shortly before expiry) and uses it for all
|
||||
GitHub calls.
|
||||
|
||||
### Common variables
|
||||
|
||||
| Variable | Required | Notes |
|
||||
| ------------------------- | -------- | --------------------------------------------------------------------------------------- |
|
||||
| `GITHUB_REPOS` | yes | Comma-separated `owner/repo` list, e.g. `octocat/hello-world,octo-org/octo-repo`. |
|
||||
| `GITHUB_PROJECTS` | no | Comma-separated GitHub Projects (v2). See format below. |
|
||||
| `GITHUB_WEBHOOK_SECRET` | no | Shared secret to verify `X-Hub-Signature-256`. When unset, signatures are not verified. |
|
||||
|
||||
`GITHUB_PROJECTS` accepts entries in either of these forms:
|
||||
|
||||
- `owner/number` — e.g. `twentyhq/24,octo/3`. Owner can be an org or a user.
|
||||
- Full project URL — e.g. `https://github.com/orgs/twentyhq/projects/24` or
|
||||
`https://github.com/users/octocat/projects/3`.
|
||||
|
||||
## Running a sync
|
||||
|
||||
In the Twenty UI, open any of the GitHub objects (e.g. **Pull Requests**) and
|
||||
trigger the matching command from the command palette (`Cmd/Ctrl+K`):
|
||||
|
||||
| View | Command | Reads from |
|
||||
| ----------------- | --------------------- | ----------------- |
|
||||
| Pull Requests | Fetch Pull Requests | `GITHUB_REPOS` |
|
||||
| Issues | Fetch Issues | `GITHUB_REPOS` |
|
||||
| Contributors | Fetch Contributors | `GITHUB_REPOS` |
|
||||
| Project Items | Fetch Project Items | `GITHUB_PROJECTS` |
|
||||
|
||||
Each command iterates over every entry in the relevant variable and shows a
|
||||
progress bar.
|
||||
|
||||
## Webhooks
|
||||
|
||||
Point a GitHub repo or App webhook at the public URL of your Twenty server,
|
||||
path `POST /github/webhook`. Recommended event subscriptions:
|
||||
|
||||
- Pull requests
|
||||
- Pull request reviews
|
||||
- Issues
|
||||
- Project (v2) items
|
||||
|
||||
Set the same value as `GITHUB_WEBHOOK_SECRET` on both sides to enable HMAC
|
||||
verification. For local testing, expose your dev server with
|
||||
[smee.io](https://smee.io/) or `ngrok` and use that URL as the webhook URL on
|
||||
GitHub.
|
||||
|
||||
> **Heads up — raw body required for signatures.** HMAC verification needs
|
||||
> the original request body bytes. The Twenty SDK currently parses JSON
|
||||
> requests before handing them to logic functions, so when the runtime
|
||||
> delivers an already-parsed body the connector logs a warning and rejects
|
||||
> the delivery instead of silently accepting it. Until the SDK exposes the
|
||||
> raw bytes for HTTP routes, either leave `GITHUB_WEBHOOK_SECRET` unset (and
|
||||
> rely on a hard-to-guess `/github/webhook` URL plus IP allow-listing), or
|
||||
> terminate signature verification at a reverse proxy in front of Twenty.
|
||||
|
||||
## How auth resolution works
|
||||
|
||||
`src/modules/github/connector/auth.ts` returns a token using the following
|
||||
order:
|
||||
|
||||
1. `GITHUB_TOKEN` (fine-grained PAT, `github_pat_…`) if present. Classic
|
||||
PATs are rejected at startup.
|
||||
2. Cached installation token, if still valid.
|
||||
3. Fresh installation token minted from the GitHub App credentials.
|
||||
|
||||
This makes the example easy to try in 30 seconds with a PAT, while still
|
||||
demonstrating the production-grade GitHub App flow.
|
||||
|
||||
## Tests
|
||||
|
||||
The app ships with a small integration test suite that runs against a local
|
||||
`twenty-app-dev-test` container.
|
||||
|
||||
```bash
|
||||
docker run -d --name twenty-app-dev-test \
|
||||
-p 2021:2021 twentycrm/twenty-app-dev:v2.0.3
|
||||
|
||||
cd packages/twenty-apps/community/github-connector
|
||||
yarn test
|
||||
```
|
||||
|
||||
The suite installs the app into the container, then asserts that every
|
||||
object/field/logic-function is wired up and that webhook signature
|
||||
verification behaves correctly.
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"name": "github-connector",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^24.5.0",
|
||||
"npm": "please-use-yarn",
|
||||
"yarn": ">=4.0.2"
|
||||
},
|
||||
"keywords": [
|
||||
"twenty-app"
|
||||
],
|
||||
"packageManager": "yarn@4.9.2",
|
||||
"scripts": {
|
||||
"twenty": "twenty",
|
||||
"lint": "oxlint -c .oxlintrc.json .",
|
||||
"lint:fix": "oxlint --fix -c .oxlintrc.json .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"twenty-client-sdk": "2.0.0",
|
||||
"twenty-sdk": "2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"oxlint": "^0.16.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"vitest": "^3.1.1"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 49 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 71 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 91 KiB |
@@ -1,54 +0,0 @@
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
|
||||
|
||||
const APP_PATH = process.cwd();
|
||||
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
|
||||
|
||||
function writeConfig(apiUrl: string, apiKey: string) {
|
||||
const payload = JSON.stringify(
|
||||
{
|
||||
remotes: {
|
||||
local: { apiUrl, apiKey, accessToken: apiKey },
|
||||
},
|
||||
defaultRemote: 'local',
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
|
||||
}
|
||||
|
||||
export async function setup() {
|
||||
const apiUrl = process.env.TWENTY_API_URL!;
|
||||
const apiKey = process.env.TWENTY_API_KEY!;
|
||||
|
||||
writeConfig(apiUrl, apiKey);
|
||||
|
||||
await appUninstall({ appPath: APP_PATH }).catch(() => {});
|
||||
|
||||
const result = await appDevOnce({
|
||||
appPath: APP_PATH,
|
||||
onProgress: (message: string) => console.log(`[dev] ${message}`),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function teardown() {
|
||||
const uninstallResult = await appUninstall({ appPath: APP_PATH });
|
||||
|
||||
if (!uninstallResult.success) {
|
||||
console.warn(
|
||||
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
|
||||
const metadata = () =>
|
||||
new MetadataApiClient({
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
|
||||
},
|
||||
});
|
||||
|
||||
export async function findObjectByName(name: string) {
|
||||
const client = metadata();
|
||||
const result = await client.query({
|
||||
objects: {
|
||||
__args: {
|
||||
filter: { isCustom: { is: true } },
|
||||
paging: { first: 50 },
|
||||
},
|
||||
edges: {
|
||||
node: {
|
||||
nameSingular: true,
|
||||
fields: {
|
||||
__args: { paging: { first: 500 } },
|
||||
edges: { node: { name: true, type: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return result.objects?.edges?.find((e) => e.node.nameSingular === name)?.node;
|
||||
}
|
||||
|
||||
type ExecutionResult = {
|
||||
data: unknown;
|
||||
status: string;
|
||||
duration: number;
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
export async function findLogicFunctionId(
|
||||
universalIdentifier: string,
|
||||
): Promise<string> {
|
||||
const client = metadata();
|
||||
const result = await client.query({
|
||||
findManyLogicFunctions: {
|
||||
id: true,
|
||||
universalIdentifier: true,
|
||||
},
|
||||
});
|
||||
|
||||
const fn = result.findManyLogicFunctions?.find(
|
||||
(f) => f.universalIdentifier === universalIdentifier,
|
||||
);
|
||||
|
||||
if (!fn) {
|
||||
throw new Error(`Logic function ${universalIdentifier} not found`);
|
||||
}
|
||||
|
||||
return fn.id;
|
||||
}
|
||||
|
||||
export async function executeLogicFunction(
|
||||
id: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<ExecutionResult> {
|
||||
const client = metadata();
|
||||
const result = await client.mutation({
|
||||
executeOneLogicFunction: {
|
||||
__args: { input: { id, payload } },
|
||||
data: true,
|
||||
status: true,
|
||||
duration: true,
|
||||
error: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result.executeOneLogicFunction as ExecutionResult;
|
||||
}
|
||||
|
||||
const BASE_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2021';
|
||||
|
||||
export async function callRoute(
|
||||
path: string,
|
||||
body: Record<string, unknown> | string,
|
||||
options: {
|
||||
method?: string;
|
||||
auth?: boolean;
|
||||
headers?: Record<string, string>;
|
||||
} = {},
|
||||
): Promise<{ status: number; data: unknown }> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers ?? {}),
|
||||
};
|
||||
|
||||
if (options.auth) {
|
||||
headers['Authorization'] = `Bearer ${process.env.TWENTY_API_KEY}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE_URL}/s${path}`, {
|
||||
method: options.method ?? 'POST',
|
||||
headers,
|
||||
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
});
|
||||
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
data = await res.text().catch(() => null);
|
||||
}
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
export async function findInstalledApp(universalIdentifier: string) {
|
||||
const client = metadata();
|
||||
const result = await client.query({
|
||||
findManyApplications: {
|
||||
id: true,
|
||||
name: true,
|
||||
universalIdentifier: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result.findManyApplications?.find(
|
||||
(app) => app.universalIdentifier === universalIdentifier,
|
||||
);
|
||||
}
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
executeLogicFunction,
|
||||
findLogicFunctionId,
|
||||
} from './helpers/metadata';
|
||||
|
||||
const COUNT_PRS_FN_UI = '082227ae-2acc-4320-8d31-62ad6c443da6';
|
||||
const COUNT_ISSUES_FN_UI = 'd8cc32bf-6be9-44fc-920a-8bba510f045f';
|
||||
const COUNT_PROJECT_ITEMS_FN_UI = 'f7a3e1b2-5c4d-4e6f-8a9b-0d1c2e3f4a5b';
|
||||
const COUNT_CONTRIBUTORS_FN_UI = 'fe0a6f00-0d63-4cb9-9b3c-1d8186181830';
|
||||
const HANDLE_WEBHOOK_FN_UI = '22b199b3-2851-4a4f-99fd-4e79c188fe7d';
|
||||
|
||||
const fnIds: Record<string, string> = {};
|
||||
|
||||
beforeAll(async () => {
|
||||
fnIds.prs = await findLogicFunctionId(COUNT_PRS_FN_UI);
|
||||
fnIds.issues = await findLogicFunctionId(COUNT_ISSUES_FN_UI);
|
||||
fnIds.projectItems = await findLogicFunctionId(COUNT_PROJECT_ITEMS_FN_UI);
|
||||
fnIds.contributors = await findLogicFunctionId(COUNT_CONTRIBUTORS_FN_UI);
|
||||
fnIds.webhook = await findLogicFunctionId(HANDLE_WEBHOOK_FN_UI);
|
||||
});
|
||||
|
||||
describe('logic functions are wired up', () => {
|
||||
it('count-prs is reachable and returns the expected payload shape', async () => {
|
||||
const result = await executeLogicFunction(fnIds.prs, {
|
||||
body: { repos: ['fixture-org/fixture-repo'] },
|
||||
});
|
||||
expect(['SUCCESS', 'ERROR']).toContain(result.status);
|
||||
if (result.status === 'SUCCESS') {
|
||||
const data = result.data as {
|
||||
totalPages: number;
|
||||
repos: Array<{ owner: string; repo: string; pages: number }>;
|
||||
};
|
||||
expect(typeof data.totalPages).toBe('number');
|
||||
expect(Array.isArray(data.repos)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('count-issues is reachable', async () => {
|
||||
const result = await executeLogicFunction(fnIds.issues, {
|
||||
body: { repos: ['fixture-org/fixture-repo'] },
|
||||
});
|
||||
expect(['SUCCESS', 'ERROR']).toContain(result.status);
|
||||
});
|
||||
|
||||
it('count-project-items is reachable', async () => {
|
||||
const result = await executeLogicFunction(fnIds.projectItems, {
|
||||
body: { projects: [{ owner: 'fixture-org', number: 9999999 }] },
|
||||
});
|
||||
expect(['SUCCESS', 'ERROR']).toContain(result.status);
|
||||
});
|
||||
|
||||
it('count-contributors iterates configured repos and returns the per-repo split', async () => {
|
||||
const result = await executeLogicFunction(fnIds.contributors, {
|
||||
body: { repos: ['fixture-org/fixture-repo'] },
|
||||
});
|
||||
expect(['SUCCESS', 'ERROR']).toContain(result.status);
|
||||
if (result.status === 'SUCCESS') {
|
||||
const data = result.data as {
|
||||
totalPages: number;
|
||||
repos: Array<{ owner: string; repo: string; pages: number }>;
|
||||
};
|
||||
expect(typeof data.totalPages).toBe('number');
|
||||
expect(Array.isArray(data.repos)).toBe(true);
|
||||
expect('orgMembers' in (data as Record<string, unknown>)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('handle-github-webhook is registered (but rejects unsigned requests gracefully)', async () => {
|
||||
expect(fnIds.webhook).toBeDefined();
|
||||
});
|
||||
});
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/modules/shared/universal-identifiers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { findInstalledApp, findObjectByName } from './helpers/metadata';
|
||||
|
||||
describe('App installation', () => {
|
||||
it('finds the installed GitHub Connector app', async () => {
|
||||
const app = await findInstalledApp(APPLICATION_UNIVERSAL_IDENTIFIER);
|
||||
expect(app).toBeDefined();
|
||||
expect(app?.name).toMatch(/github/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contributor object', () => {
|
||||
it('exists with the expected GitHub-only fields', async () => {
|
||||
const obj = await findObjectByName('contributor');
|
||||
expect(obj).toBeDefined();
|
||||
|
||||
const names = obj!.fields.edges.map((e) => e.node.name);
|
||||
expect(names).toContain('name');
|
||||
expect(names).toContain('ghLogin');
|
||||
expect(names).toContain('githubId');
|
||||
expect(names).toContain('avatarUrl');
|
||||
expect(names).toContain('contributions');
|
||||
|
||||
expect(names).not.toContain('isCoreTeam');
|
||||
expect(names).not.toContain('discordId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PullRequest object', () => {
|
||||
it('exists with the expected fields and relations', async () => {
|
||||
const obj = await findObjectByName('pullRequest');
|
||||
expect(obj).toBeDefined();
|
||||
|
||||
const names = obj!.fields.edges.map((e) => e.node.name);
|
||||
expect(names).toContain('name');
|
||||
expect(names).toContain('githubNumber');
|
||||
expect(names).toContain('uniqueIdentifier');
|
||||
expect(names).toContain('url');
|
||||
expect(names).toContain('state');
|
||||
expect(names).toContain('mergedAt');
|
||||
expect(names).toContain('closedAt');
|
||||
expect(names).toContain('githubCreatedAt');
|
||||
expect(names).toContain('author');
|
||||
expect(names).toContain('merger');
|
||||
expect(names).toContain('reviews');
|
||||
expect(names).toContain('projectItems');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PullRequestReviewEvent object', () => {
|
||||
it('exists with the expected fields and relations', async () => {
|
||||
const obj = await findObjectByName('pullRequestReviewEvent');
|
||||
expect(obj).toBeDefined();
|
||||
|
||||
const names = obj!.fields.edges.map((e) => e.node.name);
|
||||
expect(names).toContain('title');
|
||||
expect(names).toContain('githubReviewId');
|
||||
expect(names).toContain('state');
|
||||
expect(names).toContain('submittedAt');
|
||||
expect(names).toContain('reviewer');
|
||||
expect(names).toContain('pullRequest');
|
||||
expect(names).toContain('review');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PullRequestReview object', () => {
|
||||
it('exists with the expected fields and relations', async () => {
|
||||
const obj = await findObjectByName('pullRequestReview');
|
||||
expect(obj).toBeDefined();
|
||||
|
||||
const names = obj!.fields.edges.map((e) => e.node.name);
|
||||
expect(names).toContain('title');
|
||||
expect(names).toContain('reviewKey');
|
||||
expect(names).toContain('state');
|
||||
expect(names).toContain('firstSubmittedAt');
|
||||
expect(names).toContain('lastSubmittedAt');
|
||||
expect(names).toContain('eventCount');
|
||||
expect(names).toContain('reviewer');
|
||||
expect(names).toContain('pullRequest');
|
||||
expect(names).toContain('reviewEvents');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Issue object', () => {
|
||||
it('exists with the expected fields and relations', async () => {
|
||||
const obj = await findObjectByName('issue');
|
||||
expect(obj).toBeDefined();
|
||||
|
||||
const names = obj!.fields.edges.map((e) => e.node.name);
|
||||
expect(names).toContain('title');
|
||||
expect(names).toContain('githubNumber');
|
||||
expect(names).toContain('uniqueIdentifier');
|
||||
expect(names).toContain('githubUrl');
|
||||
expect(names).toContain('state');
|
||||
expect(names).toContain('labels');
|
||||
expect(names).toContain('githubCreatedAt');
|
||||
expect(names).toContain('closedAt');
|
||||
expect(names).toContain('repo');
|
||||
expect(names).toContain('author');
|
||||
expect(names).toContain('projectItems');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProjectItem object', () => {
|
||||
it('exists with the expected fields and relations', async () => {
|
||||
const obj = await findObjectByName('projectItem');
|
||||
expect(obj).toBeDefined();
|
||||
|
||||
const names = obj!.fields.edges.map((e) => e.node.name);
|
||||
expect(names).toContain('name');
|
||||
expect(names).toContain('githubProjectItemId');
|
||||
expect(names).toContain('status');
|
||||
expect(names).toContain('sprint');
|
||||
expect(names).toContain('assignees');
|
||||
expect(names).toContain('priority');
|
||||
expect(names).toContain('mainAssignee');
|
||||
expect(names).toContain('linkedIssue');
|
||||
expect(names).toContain('linkedPullRequest');
|
||||
expect(names).toContain('githubUrl');
|
||||
expect(names).toContain('repo');
|
||||
});
|
||||
});
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createHmac } from 'crypto';
|
||||
|
||||
import {
|
||||
getRawBodyForSignature,
|
||||
verifyGitHubSignature,
|
||||
} from 'src/modules/github/connector/webhook-signature';
|
||||
|
||||
const SECRET = 'super-secret-shared-string';
|
||||
|
||||
function sign(body: string): string {
|
||||
return `sha256=${createHmac('sha256', SECRET).update(body).digest('hex')}`;
|
||||
}
|
||||
|
||||
describe('verifyGitHubSignature', () => {
|
||||
it('accepts a valid signature', () => {
|
||||
const body = '{"action":"opened","number":42}';
|
||||
const result = verifyGitHubSignature({
|
||||
rawBody: body,
|
||||
signatureHeader: sign(body),
|
||||
secret: SECRET,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a tampered body', () => {
|
||||
const body = '{"action":"opened","number":42}';
|
||||
const signature = sign(body);
|
||||
const result = verifyGitHubSignature({
|
||||
rawBody: body.replace('42', '43'),
|
||||
signatureHeader: signature,
|
||||
secret: SECRET,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a wrong secret', () => {
|
||||
const body = '{"action":"opened","number":42}';
|
||||
const result = verifyGitHubSignature({
|
||||
rawBody: body,
|
||||
signatureHeader: sign(body),
|
||||
secret: 'other-secret',
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a missing header', () => {
|
||||
const result = verifyGitHubSignature({
|
||||
rawBody: 'anything',
|
||||
signatureHeader: undefined,
|
||||
secret: SECRET,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
reason: 'missing X-Hub-Signature-256 header',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a header without sha256= prefix', () => {
|
||||
const result = verifyGitHubSignature({
|
||||
rawBody: 'anything',
|
||||
signatureHeader: 'sha1=deadbeef',
|
||||
secret: SECRET,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
reason: 'malformed signature header',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when length differs', () => {
|
||||
const result = verifyGitHubSignature({
|
||||
rawBody: 'anything',
|
||||
signatureHeader: 'sha256=tooshort',
|
||||
secret: SECRET,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
reason: 'signature length mismatch',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRawBodyForSignature', () => {
|
||||
it('returns the string as-is for string body', () => {
|
||||
expect(
|
||||
getRawBodyForSignature({ body: '{"a":1}', isBase64Encoded: false }),
|
||||
).toBe('{"a":1}');
|
||||
});
|
||||
|
||||
it('decodes base64 bodies', () => {
|
||||
const original = '{"a":1}';
|
||||
const b64 = Buffer.from(original, 'utf8').toString('base64');
|
||||
expect(getRawBodyForSignature({ body: b64, isBase64Encoded: true })).toBe(
|
||||
original,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null for parsed object bodies (raw bytes lost)', () => {
|
||||
expect(getRawBodyForSignature({ body: { a: 1 } })).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(getRawBodyForSignature({ body: null })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyGitHubSignature with parsed body', () => {
|
||||
it('rejects with a clear reason when the runtime parsed the JSON', () => {
|
||||
const result = verifyGitHubSignature({
|
||||
rawBody: null,
|
||||
signatureHeader: 'sha256=deadbeef',
|
||||
secret: SECRET,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
reason:
|
||||
'raw request body is unavailable (the runtime parsed it as JSON); HMAC cannot be verified',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
APP_ABOUT_DESCRIPTION,
|
||||
APP_DESCRIPTION,
|
||||
APP_DISPLAY_NAME,
|
||||
APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/modules/shared/universal-identifiers';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: APP_DISPLAY_NAME,
|
||||
description: APP_DESCRIPTION,
|
||||
aboutDescription: APP_ABOUT_DESCRIPTION,
|
||||
icon: 'IconBrandGithub',
|
||||
defaultRoleUniversalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
screenshots: [
|
||||
'public/screenshots/app-listing.png',
|
||||
'public/screenshots/github-dashboard.png',
|
||||
'public/screenshots/pull-requests-view.png',
|
||||
'public/screenshots/contributor-stats.png',
|
||||
],
|
||||
applicationVariables: {
|
||||
GITHUB_TOKEN: {
|
||||
universalIdentifier: 'fb1d2e91-3a75-4c89-9d6b-1e2f7a4c5d8e',
|
||||
description:
|
||||
'Fine-grained Personal Access Token (github_pat_…) from https://github.com/settings/personal-access-tokens — needs Read-only access to Contents, Issues, Pull requests and Metadata (and Organization → Projects for Projects v2). Classic PATs are not supported. When set, takes precedence over the GitHub App credentials below.',
|
||||
isSecret: true,
|
||||
},
|
||||
GITHUB_APP_ID: {
|
||||
universalIdentifier: '2a196d91-4c1b-4f8d-bc34-6693fcdaa771',
|
||||
description:
|
||||
'GitHub App ID. Used together with GITHUB_APP_PRIVATE_KEY and GITHUB_APP_INSTALLATION_ID when GITHUB_TOKEN is unset.',
|
||||
isSecret: false,
|
||||
},
|
||||
GITHUB_APP_PRIVATE_KEY: {
|
||||
universalIdentifier: 'c5591dd4-f653-4b92-bec9-970ddc8e10cc',
|
||||
description: 'GitHub App PEM private key (BEGIN/END PRIVATE KEY block).',
|
||||
isSecret: true,
|
||||
},
|
||||
GITHUB_APP_INSTALLATION_ID: {
|
||||
universalIdentifier: '0c39a59a-ee2e-49a9-88c7-3fbe6cb04ad0',
|
||||
description: 'GitHub App installation ID for your org.',
|
||||
isSecret: false,
|
||||
},
|
||||
GITHUB_WEBHOOK_SECRET: {
|
||||
universalIdentifier: 'b9f3c2d8-1e7a-4d56-9c8b-3a2f1e5d7c9a',
|
||||
description:
|
||||
'Shared secret used to verify the X-Hub-Signature-256 HMAC on incoming GitHub webhooks. When unset, signature verification is skipped (use only in dev/test).',
|
||||
isSecret: true,
|
||||
},
|
||||
GITHUB_REPOS: {
|
||||
universalIdentifier: '7d1e9c84-2f63-4a58-9b0d-5e8a3c1f7b29',
|
||||
description:
|
||||
'Comma-separated list of `owner/repo` to sync (e.g. `twentyhq/twenty,octo/hello`). Used by the manual fetch routes.',
|
||||
isSecret: false,
|
||||
},
|
||||
GITHUB_PROJECTS: {
|
||||
universalIdentifier: 'e3a8c7d2-4b95-4e1f-8a6c-9d2b5f7e1c84',
|
||||
description:
|
||||
'Comma-separated list of GitHub Projects (v2) to sync. Each entry is `owner/number` (e.g. `twentyhq/24,octo/3`). Owner can be an organization or a user. Full URLs like `https://github.com/orgs/twentyhq/projects/24` or `https://github.com/users/octo/projects/3` are also accepted.',
|
||||
isSecret: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { defineRole } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
APP_DISPLAY_NAME,
|
||||
DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/modules/shared/universal-identifiers';
|
||||
|
||||
export default defineRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: `${APP_DISPLAY_NAME} default function role`,
|
||||
description: `${APP_DISPLAY_NAME} default function role`,
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: true,
|
||||
canSoftDeleteAllObjectRecords: true,
|
||||
canDestroyAllObjectRecords: false,
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
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;
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
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);
|
||||
}
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
-157
@@ -1,157 +0,0 @@
|
||||
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;
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
export type GitHubUser = {
|
||||
login: string;
|
||||
id: number;
|
||||
avatar_url?: string | null;
|
||||
};
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
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' };
|
||||
}
|
||||
-282
@@ -1,282 +0,0 @@
|
||||
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>
|
||||
);
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
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;
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-693
@@ -1,693 +0,0 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
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,
|
||||
});
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
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,
|
||||
});
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
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;
|
||||
}
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
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[]>;
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
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;
|
||||
}
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
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,
|
||||
}));
|
||||
}
|
||||
-376
@@ -1,376 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-257
@@ -1,257 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
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()];
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
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;
|
||||
};
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
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);
|
||||
}
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
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[]>;
|
||||
}
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
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;
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-93
@@ -1,93 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
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,
|
||||
});
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
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;
|
||||
};
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
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;
|
||||
};
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
-260
@@ -1,260 +0,0 @@
|
||||
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',
|
||||
],
|
||||
},
|
||||
});
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
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,
|
||||
});
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
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,
|
||||
});
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
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,
|
||||
});
|
||||
-281
@@ -1,281 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';
|
||||
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
|
||||
import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object';
|
||||
import { PROJECT_ITEMS_ON_ISSUE_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/fields/project-items-on-issue.field';
|
||||
|
||||
export const LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'7a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'linkedIssue',
|
||||
label: 'Linked Issue',
|
||||
icon: 'IconBug',
|
||||
relationTargetObjectMetadataUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
PROJECT_ITEMS_ON_ISSUE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'linkedIssueId',
|
||||
},
|
||||
});
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';
|
||||
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
|
||||
import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object';
|
||||
import { PROJECT_ITEMS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/project-items-on-pull-request.field';
|
||||
|
||||
export const LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'9c4d5e6f-7a8b-4c9d-ae0f-1a2b3c4d5e6f';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'linkedPullRequest',
|
||||
label: 'Linked PR',
|
||||
icon: 'IconGitPullRequest',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
PULL_REQUEST_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
PROJECT_ITEMS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'linkedPullRequestId',
|
||||
},
|
||||
});
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';
|
||||
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
|
||||
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
|
||||
import { ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/fields/assigned-project-items-on-contributor.field';
|
||||
|
||||
export const MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'5bc9d7b3-ca5a-4006-bd74-0420d1f3df85';
|
||||
|
||||
export default defineField({
|
||||
universalIdentifier: MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'mainAssignee',
|
||||
label: 'Main Assignee',
|
||||
icon: 'IconUser',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'mainAssigneeId',
|
||||
},
|
||||
});
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
enqueueSnackbar,
|
||||
objectMetadataItem,
|
||||
unmountFrontComponent,
|
||||
updateProgress,
|
||||
} from 'twenty-sdk/front-component';
|
||||
import { callAppRoute } from 'src/modules/shared/call-app-route';
|
||||
|
||||
type CountResponse = {
|
||||
totalPages: number;
|
||||
projects: Array<{
|
||||
owner: string;
|
||||
number: number;
|
||||
totalCount: number;
|
||||
pages: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
type FetchPageResponse = {
|
||||
itemCount: number;
|
||||
totalCount: number;
|
||||
hasMore: boolean;
|
||||
endCursor: string | null;
|
||||
};
|
||||
|
||||
type SyncStatus = 'syncing' | 'done' | 'error';
|
||||
|
||||
const FetchProjectItems = () => {
|
||||
const [status, setStatus] = useState<SyncStatus>('syncing');
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
try {
|
||||
const counts = (await callAppRoute(
|
||||
'/github/count-project-items',
|
||||
{},
|
||||
)) as CountResponse;
|
||||
|
||||
if (counts.projects.length === 0) {
|
||||
throw new Error(
|
||||
'No projects resolved. Set GITHUB_PROJECTS in the application variables (e.g. `twentyhq/24`).',
|
||||
);
|
||||
}
|
||||
|
||||
const totalPages = Math.max(counts.totalPages, 1);
|
||||
let pagesProcessed = 0;
|
||||
let totalItems = 0;
|
||||
|
||||
for (const { owner, number } of counts.projects) {
|
||||
let cursor: string | null = null;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const data = (await callAppRoute('/github/fetch-project-items', {
|
||||
owner,
|
||||
number,
|
||||
cursor,
|
||||
})) as FetchPageResponse;
|
||||
|
||||
totalItems += data.itemCount;
|
||||
hasMore = data.hasMore && data.itemCount > 0;
|
||||
cursor = data.endCursor;
|
||||
pagesProcessed++;
|
||||
|
||||
updateProgress(Math.min(Math.round((pagesProcessed / totalPages) * 100), 99));
|
||||
}
|
||||
}
|
||||
|
||||
updateProgress(100);
|
||||
enqueueSnackbar({
|
||||
message: `Fetched ${totalItems} project items`,
|
||||
variant: 'success',
|
||||
});
|
||||
setStatus('done');
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Failed to fetch project items';
|
||||
enqueueSnackbar({ message, variant: 'error' });
|
||||
setStatus('error');
|
||||
} finally {
|
||||
unmountFrontComponent();
|
||||
}
|
||||
};
|
||||
run();
|
||||
}, []);
|
||||
|
||||
if (status === 'syncing') return <div>Fetching project items...</div>;
|
||||
if (status === 'error') return <div>Failed to fetch project items.</div>;
|
||||
return <div>Done</div>;
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: '7c397b0c-8b19-4fac-924a-8f6aa1dece78',
|
||||
name: 'Fetch Project Items',
|
||||
description: 'Fetches project items from GitHub Projects V2',
|
||||
isHeadless: true,
|
||||
component: FetchProjectItems,
|
||||
command: {
|
||||
universalIdentifier: '719cfe1c-d570-4c8c-89e6-88671c6ba1ea',
|
||||
label: 'Fetch Project Items',
|
||||
icon: 'IconLayoutKanban',
|
||||
isPinned: false,
|
||||
conditionalAvailabilityExpression:
|
||||
objectMetadataItem.nameSingular === 'projectItem',
|
||||
},
|
||||
});
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user