Compare commits

..
Author SHA1 Message Date
neo773 f10395770c change limit 2026-04-23 14:10:38 +05:30
neo773 2e163d81bb Merge branch 'workspace-export-optimize' of https://github.com/twentyhq/twenty into workspace-export-optimize 2026-04-23 14:09:56 +05:30
neo773 d7b0975e9e add test 2026-04-23 13:29:04 +05:30
neo773 f48e603485 wip 2026-04-23 13:29:01 +05:30
neo773 180d3e1c4a wip 2026-04-23 13:28:57 +05:30
neo773 ceadd755ca optimize workspace export 2026-04-23 13:28:55 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
77f5b9d7c8 chore(deps): bump @ai-sdk/google from 3.0.31 to 3.0.64 (#19998)
Bumps [@ai-sdk/google](https://github.com/vercel/ai) from 3.0.31 to
3.0.64.
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/vercel/ai/commit/e2f82795704d7badbd90f9f4a798d8bf2d40b2df"><code>e2f8279</code></a>
Version Packages (<a
href="https://redirect.github.com/vercel/ai/issues/14527">#14527</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/71c52e0c053571dd6893be6fa56d275ec2d9e216"><code>71c52e0</code></a>
Backport: chore(provider/google): update available models (<a
href="https://redirect.github.com/vercel/ai/issues/14524">#14524</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/e662efda9bb35838d5c2a03a6320197d1192db4c"><code>e662efd</code></a>
Version Packages (<a
href="https://redirect.github.com/vercel/ai/issues/14510">#14510</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/83434a97d6856c34d440e22038ac083cd5134017"><code>83434a9</code></a>
Backport: feat (provider/gateway): add provider routing sort options (<a
href="https://redirect.github.com/vercel/ai/issues/14508">#14508</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/3aa3a68cfec59cae4dccb1ad1446d30fbe93f17c"><code>3aa3a68</code></a>
Version Packages (<a
href="https://redirect.github.com/vercel/ai/issues/14507">#14507</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/a27a631939dedc310b13fd48de144760cc2a3145"><code>a27a631</code></a>
Backport: feat (provider/gateway): make model list resilient to unknown
model...</li>
<li><a
href="https://github.com/vercel/ai/commit/a2d619a76be80c4fdd60ce047d21fdf95879e08f"><code>a2d619a</code></a>
Version Packages (<a
href="https://redirect.github.com/vercel/ai/issues/14502">#14502</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/b937f3e7fa0c943c6ce8a379b57dd5bb7abf07b4"><code>b937f3e</code></a>
Backport: fix(xai): support encrypted reasoning round-trip for ZDR (<a
href="https://redirect.github.com/vercel/ai/issues/14497">#14497</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/b0e5ab3a75052fbc0469066b19196ec419e49c20"><code>b0e5ab3</code></a>
Version Packages (<a
href="https://redirect.github.com/vercel/ai/issues/14494">#14494</a>)</li>
<li><a
href="https://github.com/vercel/ai/commit/8c4abafaaa28f82f031a498ff335c9f394a33d48"><code>8c4abaf</code></a>
Backport: chore(provider/gateway): update gateway model settings files
v6 (<a
href="https://redirect.github.com/vercel/ai/issues/1">#1</a>...</li>
<li>Additional commits viewable in <a
href="https://github.com/vercel/ai/compare/@ai-sdk/google@3.0.31...@ai-sdk/google@3.0.64">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@ai-sdk/google&package-manager=npm_and_yarn&previous-version=3.0.31&new-version=3.0.64)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-23 07:37:58 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Abdullah
80e8f6d516 chore(deps): bump @blocknote/server-util from 0.47.1 to 0.47.3 (#19997)
Bumps
[@blocknote/server-util](https://github.com/TypeCellOS/BlockNote/tree/HEAD/packages/server-util)
from 0.47.1 to 0.47.3.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/TypeCellOS/BlockNote/releases"><code>@​blocknote/server-util</code>'s
releases</a>.</em></p>
<blockquote>
<h2>v0.47.3</h2>
<h2>0.47.3 (2026-03-25)</h2>
<h3>🩹 Fixes</h3>
<ul>
<li><strong>core:</strong> preserve whitespace edge cases but collapse
html formatting newlines (BLO-1065) (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2551">#2551</a>,
<a
href="https://redirect.github.com/TypeCellOS/BlockNote/issues/2230">#2230</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>Yousef</li>
</ul>
<h2>v0.47.2</h2>
<h2>0.47.2 (2026-03-20)</h2>
<h3>🩹 Fixes</h3>
<ul>
<li>use <code>&lt;details&gt;</code> &amp; <code>&lt;summary&gt;</code>
for toggle block HTML export (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2524">#2524</a>)</li>
<li>remove <code>@hocuspocus/provider</code> peer dependency by inlining
tiptap comment types BLO-1064 (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2564">#2564</a>)</li>
<li><strong>core:</strong> slash menu fails in custom blocks after space
BLO-1036 (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2553">#2553</a>)</li>
<li><strong>i18n:</strong> fix typo in russian translation (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2560">#2560</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>Drone</li>
<li>Yousef</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/TypeCellOS/BlockNote/blob/main/CHANGELOG.md"><code>@​blocknote/server-util</code>'s
changelog</a>.</em></p>
<blockquote>
<h2>0.47.3 (2026-03-25)</h2>
<h3>🩹 Fixes</h3>
<ul>
<li><strong>core:</strong> preserve whitespace edge cases but collapse
html formatting newlines (BLO-1065) (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2551">#2551</a>,
<a
href="https://redirect.github.com/TypeCellOS/BlockNote/issues/2230">#2230</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>Yousef</li>
</ul>
<h2>0.47.2 (2026-03-20)</h2>
<h3>🩹 Fixes</h3>
<ul>
<li>use <!-- raw HTML omitted -->/<!-- raw HTML omitted --> for toggle
block HTML export (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2524">#2524</a>)</li>
<li>remove <code>@​hocuspocus/provider</code> peer dependency by
inlining tiptap comment types BLO-1064 (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2564">#2564</a>)</li>
<li><strong>core:</strong> slash menu fails in custom blocks after space
BLO-1036 (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2553">#2553</a>)</li>
<li><strong>i18n:</strong> fix typo in russian translation (<a
href="https://redirect.github.com/TypeCellOS/BlockNote/pull/2560">#2560</a>)</li>
</ul>
<h3>❤️ Thank You</h3>
<ul>
<li>Claude Opus 4.6</li>
<li>Drone</li>
<li>Yousef</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/TypeCellOS/BlockNote/commit/cd92dc21be49397b658fef4e308e01ce8f5c04ad"><code>cd92dc2</code></a>
chore(release): publish 0.47.3</li>
<li><a
href="https://github.com/TypeCellOS/BlockNote/commit/b63b4096daa575f821980ef897fd90f4c76d9e42"><code>b63b409</code></a>
chore(release): publish 0.47.2</li>
<li><a
href="https://github.com/TypeCellOS/BlockNote/commit/d76fd68e016da698f3896f9d349a935a26a52d5f"><code>d76fd68</code></a>
test: get snapshots working again (<a
href="https://github.com/TypeCellOS/BlockNote/tree/HEAD/packages/server-util/issues/2554">#2554</a>)</li>
<li>See full diff in <a
href="https://github.com/TypeCellOS/BlockNote/commits/v0.47.3/packages/server-util">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@blocknote/server-util&package-manager=npm_and_yarn&previous-version=0.47.1&new-version=0.47.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Abdullah <125115953+mabdullahabaid@users.noreply.github.com>
2026-04-23 07:29:45 +00:00
neo773 07baf95126 add test 2026-04-23 01:10:53 +05:30
neo773 0b9641dd06 wip 2026-04-23 00:55:20 +05:30
neo773 96abbbf99c wip 2026-04-23 00:51:54 +05:30
neo773 dd8c4f1dc3 optimize workspace export 2026-04-22 23:53:35 +05:30
39831338f7 i18n - translations (#19988)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-22 18:46:31 +02:00
WeikoandGitHub 876214bc1d scaffold record page layout + fields view when adding an object (#19977)
## Summary

Extends `yarn twenty add` → **Object** so it scaffolds a complete record
page out
of the box:

- A **record-page-fields view** (`<name>-record-page-fields.ts`,
FIELDS_WIDGET)
pre-populated with the `name` field plus the auto-generated default
fields (`createdAt`,
`updatedAt`, `createdBy`, `updatedBy`) — the default-field entries are
emitted as
`generateDefaultFieldUniversalIdentifier({ objectUniversalIdentifier,
fieldName: '...' })`
calls rather than pre-computed UUIDs, so the generated file
double-serves as
documentation for the public util.
- A **record page layout** (`<name>-record-page-layout.ts`) with a Home
tab whose Fields
widget points at the new view (via `viewUniversalIdentifier`), plus a
Timeline tab.
- The companion prompt now covers all three artefacts (was view + nav
menu item).

Fix: Server-side, renames `viewId` → `viewUniversalIdentifier` on the
universal-flat FIELDS
widget configuration so it is consistent with other universal-flat
references. The DB-side
DTO keeps `viewId` (now typed as `SerializedRelation`), and the
conversion utils map
between the two.

<img width="337" height="349" alt="Screenshot 2026-04-22 at 15 40 22"
src="https://github.com/user-attachments/assets/59e36540-1761-46b0-808d-648c68604268"
/>
2026-04-22 16:30:45 +00:00
Raphaël BosiandGitHub 0d996a5629 Resend app improvements (#19986)
## Summary
Major overhaul of the `twenty-for-twenty` Resend app to make sync more
reliable, observable, and feature-complete.
### SDK upgrade
- Bumps `twenty-sdk` to `2.0.0` and `twenty-client-sdk` to
`1.23.0-canary.1`
- Pins React back to `^18.2.0` to match the SDK
### Sync engine rewrite
- Splits the single `sync-resend-data` job into 4 staggered cron-driven
logic functions: **Emails**, **Contacts**, **Broadcasts (+ segments +
dependencies)**, **Templates** — each running every 5 minutes on a
different minute offset with per-slot timeouts
- Adds a new `ResendSyncCursor` object + `with-sync-cursor`
orchestration so each step persists its progress, last run timestamp,
and last run status
- Introduces an `INITIAL_SYNC_MODE` app variable +
`resend-initial-sync-mode-monitor` that flips to intermediate sync once
every cursor is empty (intermediate sync only refetches the last 7 days
of emails)
- Stops auto-creating People from Resend contacts; instead backfills
`personId` on Resend contacts/emails by matching existing People by
email
- Renames `on-*-deleted` handlers to `on-*-destroyed` and removes from
Resend on destroy (not soft delete)
- Adds rate-limit retry, paginated `for-each-page`, typed-client, and
existing-IDs lookup helpers

### New objects & fields
- New `ResendTopic` object with relation to `ResendBroadcast` (+
navigation menu item, view, page layout)
- New `ResendSyncCursor` object (step / cursor / last run at / last run
status)
- Adds `html` and `text` fields on `ResendBroadcast`; removes raw
`htmlBody`/`textBody`/`tags` from `ResendEmail`

### New UI
- **Sync Status standalone page** (`ResendSyncStatus` front component +
nav item) showing live cursor / last run state per step
- **Person Resend Email Stats** front component: deliverability rate +
per-status breakdown with progress bars
- **Email Broadcast HTML viewer** front component renders an individual
email against its parent broadcast's HTML; new dedicated **Broadcast
HTML viewer**
- Adds Resend Broadcast record page layout (Home / Preview / Timeline /
Tasks / Notes / Files tabs)
### Tests
- ~25 new unit / integration test files covering sync utilities, cursor
lifecycle, webhook handler, email-stats computation, sync-status page
resolution, and rate-limit retry
- Replaces legacy `fetch-all-paginated` tests with `for-each-page` tests
2026-04-22 18:17:27 +02:00
Charles BochetandGitHub 3ebeb3a3e8 feat(community): add github-connector example app (#19961)
## Summary

Adds a new community app at
`packages/twenty-apps/community/github-connector` that demonstrates a
complete, production-style GitHub integration built on the Twenty SDK.

It is extracted (and decoupled) from the internal `twenty-eng` workspace
so external developers can use it as a reference for their own
connectors.

What it ships:

- **Six synced objects**: `pullRequest`, `pullRequestReview`,
`pullRequestReviewEvent`, `issue`, `projectItem`, `engineer`
- **Logic functions** for periodic backfills (PRs, reviews, issues,
project items, contributors) and a single signed-webhook route trigger
(`POST /github/webhook`) that performs idempotent upserts for
`pull_request`, `pull_request_review`, `issues`, and `projects_v2_item`
events
- **Views, navigation menu items and a GitHub folder** so the data is
discoverable in the UI out of the box
- **Configurable repos / project numbers** via `GITHUB_REPOS` and
`GITHUB_PROJECT_NUMBERS` application variables — no hardcoded org

## Authentication

Two interchangeable modes (PAT preferred for quick setup, GitHub App
recommended for production):

1. **Personal Access Token** — set `GITHUB_TOKEN`. Used as-is for both
REST and GraphQL.
2. **GitHub App** — set `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`,
`GITHUB_APP_INSTALLATION_ID`. Issues a signed JWT, exchanges it for a
short-lived installation token, and caches the token until expiry.

Webhook signature verification (`X-Hub-Signature-256`) is enforced when
`GITHUB_WEBHOOK_SECRET` is set.

## Notes

- Built on `twenty-sdk@2.0.0` / `twenty-client-sdk@2.0.0`
- Decoupled from internal modules (`quality/bug`, `discord`, `release`,
`code-build`, `project-management`) — `mustBeQa` is inlined and a local
`github` nav folder replaces shared ones
- `npx twenty typecheck`, `yarn lint`, and `npx twenty build` all run
cleanly
- Includes a comprehensive README with setup, env vars, webhook
configuration, and the auth resolution flow
2026-04-22 18:17:08 +02:00
f30ef2432f i18n - translations (#19987)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-22 17:55:26 +02:00
Paul RastoinandGitHub 921a0f01c8 Forbid permissions update cross app role retarget (#19982)
closes https://github.com/twentyhq/twenty/issues/19807
2026-04-22 15:39:26 +00:00
WeikoandGitHub 0696290af4 fix(page-layout): hide deactivated fields from FIELDS widget and layout editor (#19984)
## Context

- The FIELDS widget resolved every viewField against
objectMetadataItem.fields, which
includes deactivated field metadata — so fields deactivated after being
added to a view
kept rendering on records.
- Same leak existed in the layout editor: deactivated fields appeared as
toggleable hidden
viewFields, and newly-deactivated object fields were auto-proposed via
the "missing
fields" flow.

## Fix
- pre-filter objectMetadataItem.fields to field.isActive at each entry
point
(useFieldsWidgetGroups, useFieldsWidgetEditorGroupsData,
useFieldsWidgetHiddenFields) and
inside buildDefaultFieldsWidgetGroups for the no-view fallback.
2026-04-22 15:28:29 +00:00
EtienneGitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>Etienne
32e0425a65 Docs - Update getting started (#19976)
- Add product tour video
- Add new graphic design assets

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Etienne <etiennejouan@users.noreply.github.com>
2026-04-22 14:52:52 +00:00
f34ba6ac12 i18n - docs translations (#19983)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-22 16:50:26 +02:00
Paul RastoinandGitHub f0a625c3f8 Cleanup application and app registration test util (#19981)
## Introduction
Centralizing integ test app cleanup
Role will be deleted by the app uninstall if exists
2026-04-22 14:39:58 +00:00
Félix MalfaitGitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
b010599000 fix(server): preserve kanban/calendar fields in view manifest sync (#19946)
## Summary

The `fromViewManifestToUniversalFlatView` converter hardcoded five view
fields to `null` instead of reading them from the manifest:
- `mainGroupByFieldMetadataUniversalIdentifier`
- `kanbanAggregateOperation`
- `kanbanAggregateOperationFieldMetadataUniversalIdentifier`
- `calendarLayout`
- `calendarFieldMetadataUniversalIdentifier`

As a result **any** Kanban view in an app manifest is rejected by
`validateFlatViewCreation` with `"Kanban view must have a main group by
field"`, and any Calendar view would trip the `view.entity.ts` check
constraint requiring `calendarLayout` + `calendarFieldMetadataId` to be
non-null. Discovered while trying to install
[`twenty-crm-meeting-baas`](https://github.com/Meeting-BaaS/twenty-crm-meeting-baas)
which ships a Kanban view.

## Changes

- **Server converter**: read all five fields from the manifest (with `??
null` fallback).
- **`ViewManifest` type** (`twenty-shared`): add the five fields so SDK
users can set them type-safely.
- **Move `ViewCalendarLayout`** from `twenty-server` to `twenty-shared`
so the manifest type can reference it. Seven import sites updated; the
front-end imports via generated GraphQL types and is unaffected.
- **Unit tests**: extend
`from-view-manifest-to-universal-flat-view.util.spec.ts` with
preservation + null-default cases for both Kanban and Calendar (5 tests
total).
- **Regression coverage**: add a Kanban view
(`post-cards-by-status.view.ts`) to the `rich-app` fixture grouped by
the existing `status` SELECT field. The existing
`applications-install-delete-reinstall` e2e test now exercises the
Kanban path end-to-end — a future regression here would fail CI.

Note: `expected-manifest.ts` and the `views.length` assertion in
`manifest.tests.ts` were updated to reflect the new fixture view.

## Test plan

- [x] `nx test twenty-server --
from-view-manifest-to-universal-flat-view` → 5/5 pass
- [x] `nx typecheck twenty-shared` / `twenty-sdk` / `twenty-server` → no
new errors (one pre-existing unrelated error in
`admin-panel.module-factory.ts`)
- [x] `nx lint twenty-shared` / `twenty-sdk` → clean
- [x] Manual install of the Meeting BaaS app on a dev workspace succeeds
with the Kanban view after this fix
- [ ] CI: SDK e2e `applications-install-delete-reinstall` passes against
the new fixture view
- [ ] CI: integration test `calendar-field-deactivation-deletes-views`
still passes after the enum move

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-04-22 16:31:00 +02:00
Félix MalfaitandGitHub c2cf3eac50 feat(sdk): confirm authentication method on remote add (#19947)
## Summary

`yarn twenty remote add` only prints `✓ Default remote set to X.` after
authenticating. When using the OAuth path, the browser flow happens
silently — there's no line that says _"you authenticated"_ — so users
(including me this morning while installing a Twenty app) are left
wondering whether auth actually completed and which method was used.

This PR adds explicit confirmation of the auth step:

**New remote via OAuth**
```
✓ Remote "myremote" added (https://app.twenty.com) via OAuth.
✓ Default remote set to "myremote".
```

**New remote via API key**
```
✓ Remote "myremote" added (https://app.twenty.com) via API key.
✓ Default remote set to "myremote".
```

**Re-authenticating an existing remote**
```
✓ Re-authenticated "myremote" via OAuth.
✓ Default remote set to "myremote".
```

## Implementation

- `authenticate()` now returns the method actually used (`'OAuth' | 'API
key'`) instead of `void`. This correctly surfaces OAuth → API-key
fallback: if OAuth fails and the user drops into the API-key prompt, the
success line reflects that.
- New-remote and re-auth paths print distinct messages so the user can
tell which path they took.
- No new API calls — method name comes from which branch of
`authenticate()` succeeded.

## Test plan

- [x] `nx typecheck twenty-sdk` — clean
- [x] `nx lint twenty-sdk` — clean
- [ ] Manual smoke test: `yarn twenty remote add --as test --api-url
...` via OAuth, API key, and re-auth

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-04-22 13:18:25 +00:00
789f8aba5d i18n - translations (#19975)
Created by Github action

---------

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-22 15:23:41 +02:00
68d509e98d Update settings application illustrations and app metadata previews (#19964)
## Summary
- Refresh the settings application visuals with new light/dark PNG
covers for the data model card
- Replace the custom and standard application carousel assets with the
new provided illustrations
- Align app chips, type tags, and application detail previews with the
updated icon and description treatment
- Keep the data model cover container and overlay button behavior intact
while swapping the underlying imagery

## Testing
- Not run (not requested)
- Existing frontend typecheck and formatting checks were exercised
during implementation

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
2026-04-22 13:07:41 +00:00
0c929e7903 refactor(tool-provider): rename web_search to exa_web_search, drop XOR toggle (#19969)
## Summary

- Today `WEB_SEARCH_PREFER_NATIVE` forces a **mutual exclusion**: either
the custom Exa tool preloads as `web_search` or the SDK-native
`web_search` binds. Same name, different backends.
- This PR lets them **coexist**. Custom Exa becomes `exa_web_search`;
native keeps `web_search`. The model picks based on tool descriptions.
- `WEB_SEARCH_PREFER_NATIVE` and `shouldUseNativeSearch()` are deleted.
Exa enablement follows `WEB_SEARCH_DRIVER` (existing). Native enablement
follows the agent's `modelConfiguration.webSearch.enabled` (existing).

## Key changes

**Config / service**
- Deleted `WEB_SEARCH_PREFER_NATIVE` (config-variables.ts)
- Deleted `WebSearchService.shouldUseNativeSearch()`
- `WebSearchService.isEnabled()` unchanged — still gates Exa
availability

**Custom tool rename**
- `ActionToolProvider.toolMap`: `'web_search'` → `'exa_web_search'`
- Descriptor name matches
- `WebSearchTool.description` rewritten to position Exa as
structured/entity-aware, complementary to native

**Native tool binder**
- `NativeToolBinder.bind()` drops the `shouldUseNativeSearch` gate.
Per-agent `modelConfiguration.webSearch.enabled` (inside
`getNativeModelTools`) stays authoritative.

**Chat**
- Preload list now always includes `exa_web_search` —
`ActionToolProvider` silently skips the descriptor when Exa is disabled,
so `getToolsByName` degrades gracefully
- Native tools always attempted; returns empty ToolSet when the model
doesn't support them
- `directTools = { ...preloadedTools, ...nativeSearchTools }` — both
present when both enabled
- `billNativeWebSearchUsage` called unconditionally (the function
already short-circuits on count ≤ 0)

**Workflow agent**
- Same unconditional billing pattern
- `WebSearchService` dependency removed

**System prompt**
- Dropped the special-cased `web_search` branch. Preloaded tools list
uniformly now.

**Frontend**
- `exa_web_search` reuses the same "Searching the web for X" display as
native
- Test coverage added

## Billing isolation (verified)

- `countNativeWebSearchCallsFromSteps` counts `toolName ===
'web_search'` only. After the rename, only native calls match. Exa calls
(`exa_web_search`) are billed separately via
`WebSearchService.emitUsageEvent` inside `search()`.
- No double-billing path.

## Behavior deltas (intended)

| Scenario | Before | After |
|---|---|---|
| Anthropic model + Exa enabled + PREFER_NATIVE=true | native only |
**both** |
| Anthropic + Exa enabled + PREFER_NATIVE=false | Exa only (as
`web_search`) | **both** |
| Non-native model + Exa enabled | Exa as `web_search` | Exa as
`exa_web_search` |
| Any model + Exa disabled + native supported | native only | native
only |
| Workflow agent with `webSearch.enabled=true` + Anthropic + Exa enabled
| native only | **both** |

## Known regression (accepted)

Customers who set `WEB_SEARCH_PREFER_NATIVE=false` to force Exa-only
will now **also** see native `web_search` if the model supports it.
There's no chat-level kill switch after this PR. Per discussion, this is
accepted — future model-level capability gating (in the model JSON) will
be the right place for that control.

## Stats

- 10 files, +63 / −73 (net deletion)
- Typecheck clean (server: 7 pre-existing unrelated, front: 13
pre-existing unrelated — zero new either side)
- Prettier clean

## Test plan

- [ ] `npx nx typecheck twenty-server` and `npx nx typecheck
twenty-front` pass
- [ ] With Anthropic + Exa enabled: chat shows both `web_search` and
`exa_web_search` in preloaded list; model can call either
- [ ] With Anthropic + Exa disabled: chat shows only native `web_search`
- [ ] With non-native model + Exa enabled: chat shows only
`exa_web_search`
- [ ] Workflow agent with `modelConfiguration.webSearch.enabled=true` +
Exa enabled: both available
- [ ] Billing: native calls billed via `billNativeWebSearchUsage`; Exa
calls billed via `WebSearchService.emitUsageEvent`; no double-billing
- [ ] Frontend: `exa_web_search` renders "Searching the web for X" the
same as `web_search`

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:57:44 +02:00
f018f17133 i18n - docs translations (#19970)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-22 14:44:57 +02:00
Yash RajandGitHub a486ead39d fix: restore Try Twenty button text visibility on docs navbar (#19968)
# [Docs]: Fix blank Try Twenty button text in dark mode

## 🐛 Problem
The "Try Twenty" CTA button in the docs navbar appears blank/invisible
when users enable dark mode, making it impossible to click through to
the sign-up page.

**Issue:** #19965

##  Root Cause
CSS specificity conflict in `packages/twenty-docs/custom.css`:
- The dark mode CSS rule targets only the `<a>` element
- Mintlify renders button text in a nested `<span>` with class
`text-white` (white color)
- The `text-white` Tailwind utility directly applies `color: rgb(255 255
255)` to the span
- This overrides the inherited dark color from the parent `<a>` element
- **Result:** White text on white background = invisible button

##  Solution
Update the CSS selector in `packages/twenty-docs/custom.css` to target
both the `<a>` element AND the nested `<span>`:

**Before:**
```css
:is(.dark, [data-theme="dark"]) #topbar-cta-button a {
  background-color: #ffffff !important;
  color: #141414 !important;
}
2026-04-22 13:59:42 +02:00
44309a6fd9 refactor(tool-provider): rename NativeModelToolProvider to NativeToolBinderService (#19966)
**Stacked on top of #19962.**

## Summary

- `NativeModelToolProvider` lived under `providers/` and had the
`*-tool.provider.ts` suffix, but it never implemented `ToolProvider`,
wasn't in `TOOL_PROVIDERS`, had no descriptors, and wasn't executed by
`ToolExecutorService`. The shape misled readers.
- It's actually a **parallel concept**: a binder that produces
SDK-native tool objects (Anthropic `webSearch`, OpenAI `webSearch`,
etc.) which the AI SDK passes straight to the model. Opaque, not
serializable, never in the catalog, never dispatched by the executor.
- This PR renames + moves it to reflect that.

## Renames

| Before | After |
|---|---|
| `NativeModelToolProvider` (class) | `NativeToolBinderService` |
| `NativeToolProvider` (interface) | `NativeToolBinder` |
| `generateTools(context)` (method) | `bind(context)` |
| `providers/native-model-tool.provider.ts` |
`native/native-tool-binder.service.ts` |
| `interfaces/native-tool-provider.interface.ts` |
`native/native-tool-binder.interface.ts` |

## What doesn't change

- `ToolCategory.NATIVE_MODEL` enum stays (still used by
`getToolsByCategories`).
- `isAvailable()` signature unchanged.
- `WebSearchService.shouldUseNativeSearch()` toggle untouched — that's
product-level and belongs to a separate PR that handles the Exa
coexistence story.
- No behavior change. Pure rename + move.

## Why this matters for the broader architecture

This rename makes the native/binder concept **visible in the type system
and directory structure**. That's what later enables coexisting native +
custom tools (e.g., `web_search` native alongside `exa_web_search`
custom) without the current naming collision, because native tools are
no longer masquerading as a registry provider.

## Stats

- 5 files, +30 / −28.
- Blast radius: 4 files modified, 1 file renamed (git tracks as rename).
- Typecheck clean (7 pre-existing unrelated errors, zero new).
- Prettier clean.

## Test plan

- [ ] `npx nx typecheck twenty-server` passes
- [ ] AI chat: native `web_search` still works end-to-end when enabled
- [ ] Workflow AI agent: `ToolCategory.NATIVE_MODEL` still works (goes
through `bind()` now)
- [ ] MCP: unaffected (doesn't use native tools)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 13:43:30 +02:00
2a5d5b36db refactor(tool-provider): kill execute_tool's dual dispatch (#19962)
**Stacked on top of #19960.**

## Summary

- `execute_tool` used to check `directTools[toolName]` first, falling
back to the registry. Same tool name, different wrapping: preloaded went
through `wrapToolsWithOutputSerialization`, fallback didn't. Silent
divergence — a model calling a CRUD tool via
\`learn_tools\`/\`execute_tool\` got raw output, while calling it as a
preloaded direct tool got compacted output.
- Now: `execute_tool` always routes through
`toolRegistry.resolveAndExecute`. One path, no fast-path.
- Output serialization (`compactToolOutput`) moves into the registry,
gated by a new `serializeOutput` flag on `hydrateToolSet` /
`resolveAndExecute` / `getToolsByName` / `getToolsByCategories` /
`ToolRetrievalOptions`. Chat passes `true`, MCP and workflow pass
`false`.

## Key changes

**Registry (`tool-registry.service.ts`)**
- `hydrateToolSet` options gain `serializeOutput?: boolean`; when true
the execute closure wraps dispatch result with `compactToolOutput`.
- `resolveAndExecute` signature: replaces unused \`_options:
ToolExecutionOptions\` with `{ serializeOutput?: boolean }`.
- `getToolsByName` and `getToolsByCategories` thread `serializeOutput`
through to `hydrateToolSet`.

**Meta-tool (`execute-tool.tool.ts`)**
- API changes from positional `(toolRegistry, context, directTools?,
excludeTools?)` to `(toolRegistry, context, options?: { excludeTools?,
serializeOutput? })`.
- `directTools` fallback removed. All invocations go to the registry.

**Chat (`chat-execution.service.ts`)**
- Passes `serializeOutput: true` to `getToolsByName` — preloaded tools
get compacted output from the hydrator, no external wrap needed.
- Drops the external `wrapToolsWithOutputSerialization(preloadedTools)`
call.
- `createExecuteToolTool` call now passes `{ serializeOutput: true }`.
Direct-tool and `execute_tool` paths produce identical output shape.

**MCP (`mcp-protocol.service.ts`)**
- `createExecuteToolTool` call updated to new options shape with `{
excludeTools: MCP_EXCLUDED_TOOLS }`. No `serializeOutput` flag → raw
output as today.

**Deletes**
- `output-serialization/wrap-tools-with-output-serialization.util.ts` —
sole caller removed.

## Behavior changes

- **Chat, `execute_tool` fallback path**: now produces compacted output
(matches direct path). Net effect: fewer tokens for CRUD results reached
via discovery. Intended improvement.
- **Chat, `execute_tool({toolName: 'web_search'})` edge**: today
silently hits the native tool via `directTools`; now returns \"tool not
found, use get_tool_catalog\". Self-correcting, rare — native tools are
always directly available to the model.
- **MCP**: no change. No `serializeOutput` flag → identical raw output.
- **Workflow agent**: no change. Doesn't use `execute_tool`.

## Test plan

- [ ] `npx nx typecheck twenty-server` passes (verified: 7 pre-existing
unrelated errors, zero new)
- [ ] \`npx jest
packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts\`
passes in CI
- [ ] AI chat: call a preloaded tool (e.g. \`search_help_center\`)
directly → compacted output
- [ ] AI chat: call a non-preloaded CRUD tool via
\`learn_tools\`/\`execute_tool\` → compacted output (this is the
behavior change)
- [ ] AI chat: native \`web_search\` still works when model calls it
directly
- [ ] MCP: \`tools/call\` on a registry tool → raw output (nulls
preserved)
- [ ] Workflow AI agent: tool dispatch unchanged

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:49:35 +02:00
66a68d8e1c i18n - docs translations (#19967)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-22 12:47:15 +02:00
b77c44fd20 refactor(tool-provider): dedupe descriptor/generator paths (#19960)
## Summary

- Every tool provider used to implement `generateDescriptors()` **and**
register a category generator at `onModuleInit()` that re-ran the same
factories at execute time. `ToolExecutorService` carried two registries
(`staticToolHandlers`, `categoryGenerators`) to route between them.
- Providers now own execution of their own tools via a new
`executeStaticTool()` method. `ToolExecutorService` drops both maps and
delegates by `descriptor.category`. Each factory-backed provider has a
single `buildToolSet()` used by both descriptor generation and
execution.
- Extracts `resolveObjectIcon` shared util (was duplicated verbatim in
workflow + dashboard providers), and deletes the orphaned
`ToolGeneratorModule` whose consumers were removed in the earlier AI
chat simplification refactor.

No behavior change. Same factories run, same permission checks, same
tools execute. Net diff: 18 files, +311 / −480.

## Key changes

- `ToolProvider` interface gains `executeStaticTool(name, args,
context)`.
- `ToolExecutorService` loses its `staticToolHandlers` and
`categoryGenerators` maps, injects `TOOL_PROVIDERS`, and does
`providers.find(p => p.category ===
descriptor.category).executeStaticTool(...)` for `kind: 'static'`
descriptors.
- `ActionToolProvider` drops the register-handler loop in its
constructor; `executeStaticTool` looks up in the existing `toolMap`.
- `View`, `Metadata`, `Workflow`, `Dashboard`, `ViewField` providers
each have a single `buildToolSet(context)` private method used by both
`generateDescriptors` and `executeStaticTool`. No more `onModuleInit`,
no `ToolExecutorService` dependency.
- `DatabaseToolProvider` and `LogicFunctionToolProvider` implement
`executeStaticTool` with an invariant-violation throw — they only emit
`database_crud` / `logic_function` kinds, so the static-tool path is
unreachable for them.
- Deletes `tool-generator/` (dead code — zero consumers).

## Dependency graph before/after

**Before:** provider → `ToolExecutorService` (for `register*` calls)
**After:** `ToolExecutorService` → `TOOL_PROVIDERS` → providers.
Cleaner, no cycle.

## Test plan

- [ ] `npx nx typecheck twenty-server` passes (verified: same 7
pre-existing unrelated errors)
- [ ] `npx nx lint twenty-server` passes
- [ ] AI chat: trigger a tool call that hits `execute_tool` fallback
(e.g. a view/metadata tool not in the preloaded set) — verify it still
executes
- [ ] AI chat: trigger a preloaded action tool (e.g.
`search_help_center`) — verify it still executes
- [ ] MCP: `tools/list` and `tools/call` for both preloaded and
catalog-discovered tools
- [ ] Workflow AI agent: run a workflow with AI agent step that calls
DATABASE_CRUD tools
- [ ] Verify the `web_search` / `code_interpreter` tools (if enabled)
still dispatch correctly

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 12:32:03 +02:00
Félix MalfaitandGitHub 3d164640c8 Remove Product Hunt banner section (#19959)
Removed Product Hunt banner from README.
2026-04-22 10:58:32 +02:00
e184732af4 i18n - docs translations (#19958)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-22 10:55:06 +02:00
710e3e2dfe i18n - docs translations (#19957)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-22 08:59:13 +02:00
40b1cdaba6 i18n - docs translations (#19956)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-22 07:15:43 +02:00
800d0f28ff i18n - docs translations (#19955)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-22 05:24:07 +02:00
11fd3bd6ee i18n - docs translations (#19954)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-22 02:59:51 +02:00
b77c65946f i18n - docs translations (#19952)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-22 00:27:38 +02:00
2a6fed5f5f i18n - docs translations (#19948)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-21 22:33:35 +02:00
92c8965dd3 i18n - docs translations (#19944)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-21 20:43:22 +02:00
Abdul RahmanandGitHub f19b3bfd38 Fix left/right arrow keys not working in dropdown search inputs (#19759)
Closes #12847 

Implements a two-mode focus management pattern for `SelectableList`
components with search inputs, resolving the conflict between text input
cursor movement and grid/list navigation.

### How it works

**Input mode** (search input focused):
- Left/right arrow keys move the text cursor normally
(`enableOnFormTags: false` on ArrowLeft/ArrowRight hotkeys)
- Up/down arrow keys blur the input and transfer focus to the grid,
entering grid mode

**Grid mode** (search input blurred):
- All arrow keys navigate the selectable list grid
- Pressing up arrow from the top row clears the grid selection and
refocuses the search input, returning to input mode
- Typing any printable character refocuses the search input (wildcard
hotkey with `enableOnFormTags: false`)

### Demo 


https://github.com/user-attachments/assets/825ad603-a5f8-4863-8269-3ecf35965847


https://github.com/user-attachments/assets/9d07346d-18a0-40fa-8874-21040c11f03d
2026-04-21 18:25:44 +00:00
0aaffe3212 i18n - docs translations (#19943)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-21 18:44:52 +02:00
Paul RastoinandGitHub 66857ca77b Remove cross version upgrade placeholder (#19940)
Moving to somewhere else to leverage docker cache
2026-04-21 16:16:47 +00:00
WeikoandGitHub 76582dc04a Export generateDefaultFieldUniversalIdentifier from SDK (#19937)
## Context
- Moved generateDefaultFieldUniversalIdentifier from the internal
cli/utilities/build/manifest/utils/ folder to sdk/define/objects/ and
re-exported it from
the public twenty-sdk/define entry point, so app authors can compute the
deterministic UID
of a default field (id, name, createdAt, …) from their own code.
- Narrowed the signature from { objectConfig: ObjectConfig; fieldName }
to {
objectUniversalIdentifier: string; fieldName: string } — the only thing
the helper needs,
and what app code has on hand.
- Updated the two internal callers and the spec to the new import path
and signature.

```typescript
import {
  defineView,
  generateDefaultFieldUniversalIdentifier,
} from "twenty-sdk/define";

export default defineView({
  universalIdentifier: "70f10d44-144a-4da8-8c6f-3ec2422138c0",
  name: "all-thing",
  objectUniversalIdentifier: "c782b61c-70fd-4c88-9cd6-4e61ab8d7591",
  icon: "IconList",
  position: 0,
  fields: [
    {
      universalIdentifier: "75a90bc4-d901-4df4-85e0-af29db5e0104",
      fieldMetadataUniversalIdentifier: generateDefaultFieldUniversalIdentifier(
        {
          objectUniversalIdentifier: "c782b61c-70fd-4c88-9cd6-4e61ab8d7591",
          fieldName: "createdAt",
        },
      ),
      position: 0,
      isVisible: true,
      size: 200,
    },
    {
      universalIdentifier: "75a90bc5-d901-4df4-85e0-af29db5e0105",
      fieldMetadataUniversalIdentifier: generateDefaultFieldUniversalIdentifier(
        {
          objectUniversalIdentifier: "c782b61c-70fd-4c88-9cd6-4e61ab8d7591",
          fieldName: "createdBy",
        },
      ),
      position: 0,
      isVisible: true,
      size: 200,
    },
  ],
});
```
2026-04-21 16:07:09 +00:00
neo773andGitHub a2099d22b5 Optimize website images (#19933)
AVIF has broad support in 2026 we should leverage it for smaller image
size


<img width="1496" height="847" alt="Screenshot 2026-04-21 at 5 49 58 PM"
src="https://github.com/user-attachments/assets/8366a2c7-f72b-4cff-891e-78a372d9a84b"
/>

```
     /halftone/materials/glass/environment.jpg                                    41KB       31KB      23%
     /illustrations/generated/handshake.png                                       61KB       49KB      19%
     /illustrations/generated/home-background-bridge.png                          19KB       13KB      27%
     /illustrations/generated/mic.png                                             47KB       32KB      32%
     /illustrations/generated/milestone.jpg                                      101KB       89KB      12%
     /illustrations/generated/partner-meeting.webp                                78KB       37KB      52%
     /images/case-studies/header-pattern.png                                     250KB      165KB      33%
     /images/home/hero/background.webp                                           101KB       73KB      26%
     /images/home/hero/foreground.webp                                            60KB       37KB      38%
     /images/home/hero/sales-dashboard/distribution.webp                          11KB        7KB      29%
     /images/home/hero/sales-dashboard/revenue.webp                               15KB        9KB      37%
     /images/home/hero/sales-dashboard/visits.webp                                14KB       11KB      21%
     /images/home/hero/twenty-demo-logo.webp                                       0KB        0KB     -99%
     /images/home/logo-bar/bayer.webp                                             52KB       37KB      29%
     /images/home/logo-bar/french-republic.webp                                    6KB        6KB       5%
     /images/home/logo-bar/nic.webp                                                4KB        4KB      -3%
     /images/home/logo-bar/pwc.webp                                                3KB        2KB      10%
     /images/home/logo-bar/shiawase-home.webp                                      7KB        8KB      -2%
     /images/home/logo-bar/windmill-logo.png                                       2KB        3KB     -17%
     /images/home/logo-bar/windmill-original.webp                                  2KB        2KB       8%
     /images/home/problem/monolith-problem.webp                                   23KB       19KB      13%
     /images/home/stepper/background-shape.webp                                    1KB        0KB      34%
     /images/home/stepper/background.webp                                         78KB       36KB      53%
     /images/home/stepper/download-worker.webp                                    21KB       19KB       6%
     /images/home/stepper/gears.jpg                                               24KB       22KB       6%
     /images/home/three-cards-feature/familiar-interface-gradient.webp             7KB        8KB     -11%
     /images/home/three-cards-feature/familiar-interface.webp                     62KB       45KB      27%
     /images/home/three-cards-feature/fast-path-background-noise.webp             49KB       16KB      66%
     /images/home/three-cards-feature/fast-path-gradient.webp                      7KB        8KB      -7%
     /images/home/three-cards-feature/fast-path.webp                              52KB       41KB      21%
     /images/home/three-cards-feature/live-data-gradient.webp                      3KB        2KB      35%
     /images/home/three-cards-feature/live-data.webp                              46KB       36KB      20%
     /images/partner/hero/hero.webp                                              123KB       97KB      20%
     /images/partner/hero/partners-hero.webp                                      42KB       32KB      23%
     /images/partner/testimonials/amrendra-singh.webp                              6KB        5KB       7%
     /images/partner/testimonials/benjamin-reynolds.webp                         111KB       80KB      27%
     /images/partner/testimonials/bertrams.jpeg                                   12KB       10KB       9%
     /images/partner/testimonials/joseph-chiang.jpg                               13KB       11KB      13%
     /images/partner/testimonials/mike-babiy.png                                  24KB       19KB      20%
     /images/partner/testimonials/olivier-reinaud.jpg                             11KB        9KB      20%
     /images/pricing/engagement-band/overlay.webp                                 72KB       56KB      22%
     /images/pricing/plans/organization-icon.png                                   4KB        3KB      10%
     /images/pricing/plans/pro-icon.png                                            2KB        3KB      -7%
     /images/pricing/salesforce/help-icon.webp                                     0KB        0KB     -45%
     /images/product/demo/background.webp                                         84KB       70KB      16%
     /images/product/demo/kanban.webp                                             63KB       57KB       9%
     /images/product/feature/contacts.webp                                        44KB       32KB      28%
     /images/product/feature/dashboards.webp                                      24KB       21KB      14%
     /images/product/feature/data.webp                                            23KB       20KB      13%
     /images/product/feature/emails.webp                                          21KB       18KB      12%
     /images/product/feature/files.webp                                           14KB       12KB      14%
     /images/product/feature/mask.webp                                           148KB       82KB      44%
     /images/product/feature/pipeline.webp                                        30KB       26KB      11%
     /images/product/feature/tasks.webp                                           37KB       33KB       9%
     /images/product/stepper/background-shape.webp                                 1KB        1KB      25%
     /images/product/stepper/background.webp                                      61KB       52KB      13%
     /images/product/stepper/step-one.webp                                         7KB        7KB       0%
     /images/product/stepper/step-three.webp                                      17KB       14KB      13%
     /images/product/stepper/step-two.webp                                         7KB        7KB       3%
     /images/product/tabs/background-shape.webp                                    7KB        1KB      83%
     /images/product/tabs/background.webp                                        180KB      171KB       5%
     /images/product/tabs/deals.webp                                              93KB       89KB       4%
     /images/product/tabs/history.webp                                            66KB       59KB      10%
     /images/product/tabs/tasks.webp                                              90KB       85KB       5%
     /images/product/tabs/workflow.webp                                           60KB       55KB       7%
     /images/releases/0.10/0.10-currency.webp                                     40KB       28KB      29%
     /images/releases/0.10/0.10-datetime.webp                                     34KB       18KB      45%
     /images/releases/0.10/0.10-json.webp                                         35KB       25KB      26%
     /images/releases/0.10/0.10-multi-select.webp                                 46KB       36KB      22%
     /images/releases/0.10/0.10-remote.webp                                       45KB       27KB      39%
     /images/releases/0.11/0.11-calendar.webp                                     69KB       56KB      18%
     /images/releases/0.11/0.11-speed.webp                                        30KB       21KB      29%
     /images/releases/0.12/0.12-database-diagram.webp                             35KB       25KB      29%
     /images/releases/0.12/0.12-link-field.webp                                   50KB       35KB      30%
     /images/releases/0.12/0.12-loader.webp                                       57KB       35KB      38%
     /images/releases/0.12/0.12-notifications.webp                                78KB       64KB      17%
     /images/releases/0.2.3_relations.webp                                        55KB       39KB      28%
     /images/releases/0.2.3_webhooks.webp                                         30KB       25KB      16%
     /images/releases/0.20/0.20-blocklist.webp                                    35KB       27KB      22%
     /images/releases/0.20/0.20-onboarding.webp                                   68KB       53KB      21%
     /images/releases/0.20/0.20-timeline.webp                                     81KB       55KB      32%
     /images/releases/0.21/0.21-advanced-email-settings.webp                      42KB       30KB      27%
     /images/releases/0.21/0.21-many-many.webp                                    60KB       46KB      22%
     /images/releases/0.22/0.22-kanban-improvements.webp                          50KB       34KB      31%
     /images/releases/0.22/0.22-mass-deletion.webp                                32KB       20KB      37%
     /images/releases/0.22/0.22-navbar.webp                                       35KB       23KB      34%
     /images/releases/0.23/0.23-created-by.webp                                   78KB       60KB      23%
     /images/releases/0.23/0.23-filter-webhooks.webp                              54KB       39KB      28%
     /images/releases/0.23/0.23-notes-tasks.webp                                  83KB       62KB      25%
     /images/releases/0.24/0.24-soft-delete.webp                                  70KB       47KB      31%
     /images/releases/0.3.0_rating.webp                                           50KB       46KB       8%
     /images/releases/0.3.1_contributors.webp                                     40KB       32KB      20%
     /images/releases/0.3.2_new_layout.webp                                       52KB       37KB      28%
     /images/releases/0.3.3_emails.webp                                           48KB       38KB      21%
     /images/releases/0.3.3_kanban.webp                                           44KB       35KB      19%
     /images/releases/0.3.3_sign_up.webp                                          24KB       17KB      25%
     /images/releases/0.30/0.30-array-field.webp                                  90KB       68KB      24%
     /images/releases/0.30/0.30-emails.webp                                       44KB       30KB      31%
     /images/releases/0.30/0.30-new-settings.webp                                 31KB       19KB      37%
     /images/releases/0.31/0.31-advanced-settings.webp                            44KB       30KB      31%
     /images/releases/0.31/0.31-search.webp                                       18KB       12KB      31%
     /images/releases/0.32/0.32-improved-cmdk.webp                                50KB       33KB      33%
     /images/releases/0.32/0.32-webhooks.webp                                     41KB       30KB      26%
     /images/releases/0.33/0.33-multiselect-filter.webp                           53KB       36KB      31%
     /images/releases/0.33/0.33-percentage-number.webp                            33KB       20KB      38%
     /images/releases/0.34/0.34-subdomains.webp                                   57KB       41KB      28%
     /images/releases/0.35/0.35-Favorites.webp                                    61KB       41KB      32%
     /images/releases/0.4/0.4-address-field-type.webp                             23KB       15KB      32%
     /images/releases/0.4/0.4-expand-relation-card.webp                           28KB       18KB      34%
     /images/releases/0.4/0.4-multi-workspace.webp                                48KB       35KB      25%
     /images/releases/0.40/0.40-aggregates.webp                                   72KB       57KB      21%
     /images/releases/0.40/0.40-group-by.webp                                     47KB       32KB      31%
     /images/releases/0.41/0.41-labs.webp                                         55KB       40KB      27%
     /images/releases/0.42/0.42-document-viewer.webp                              86KB       72KB      15%
     /images/releases/0.42/0.42-microsoft.webp                                    79KB       64KB      18%
     /images/releases/0.42/0.42-translation.webp                                  66KB       53KB      19%
     /images/releases/0.43.0/email-privacy.webp                                   47KB       33KB      27%
     /images/releases/0.43.0/search-upgrade.webp                                  44KB       30KB      31%
     /images/releases/0.44/0.44-admin-panel.webp                                  57KB       42KB      25%
     /images/releases/0.44/0.44-side-panel.webp                                   67KB       54KB      20%
     /images/releases/0.50/0.50-advanced-filters.webp                             55KB       36KB      33%
     /images/releases/0.50/0.50-permissions.webp                                  51KB       37KB      25%
     /images/releases/0.51.0/0.51-options-menu.webp                               59KB       40KB      31%
     /images/releases/0.52.0/0.52-custom-date-format.webp                         37KB       23KB      36%
     /images/releases/0.52.0/0.52-filtered-views-records.webp                     46KB       31KB      31%
     /images/releases/1.00/1.00-import-update.webp                                63KB       39KB      37%
     /images/releases/1.00/1.00-performance-improvement.webp                      52KB       35KB      31%
     /images/releases/1.00/1.00-permissions.webp                                  72KB       52KB      28%
     /images/releases/1.00/1.00-subfield-filtering.webp                           73KB       49KB      33%
     /images/releases/1.00/1.00-workflow.webp                                     52KB       36KB      29%
     /images/releases/1.1/1.1-multi-manual-trigger.webp                           51KB       37KB      26%
     /images/releases/1.10/1.10.0-calendar.webp                                   54KB       38KB      28%
     /images/releases/1.10/1.10.0-dashboards.webp                                 33KB       20KB      37%
     /images/releases/1.11/1.11.0-morph-relations.webp                            53KB       40KB      24%
     /images/releases/1.11/1.11.0-unlisted-views.webp                             50KB       34KB      32%
     /images/releases/1.12/1.12.0-folder-sync.webp                                38KB       25KB      32%
     /images/releases/1.12/1.12.0-side-panel.webp                                 45KB       34KB      22%
     /images/releases/1.13/1.13.0-stop-workflow-button.png                        39KB       26KB      34%
     /images/releases/1.14/1.14.0-resize-navbar-and-side-panel.png                58KB       40KB      30%
     /images/releases/1.15/1.15.0-updated-by-official.webp                        36KB       23KB      35%
     /images/releases/1.15/1.15.0-updated-by.png                                  36KB       27KB      24%
     /images/releases/1.15/1.15.0-updated-by.webp                                 36KB       23KB      35%
     /images/releases/1.16/1.16.0-files-in-records.webp                           43KB       29KB      32%
     /images/releases/1.16/1.16.0-flexible-relations.webp                         42KB       30KB      28%
     /images/releases/1.17/1.17.0-ai-chat.webp                                    83KB       67KB      18%
     /images/releases/1.18/1.18.0-live-updates.webp                               56KB       37KB      33%
     /images/releases/1.18/1.18.0-sidebar-items.webp                              36KB       27KB      26%
     /images/releases/1.19/1.19.0-invite-roles.webp                               40KB       26KB      34%
     /images/releases/1.2/1.2-any-fields.webp                                     47KB       30KB      35%
     /images/releases/1.2/1.2-import-relations.webp                               42KB       31KB      26%
     /images/releases/1.20/1.20.0-easier-field-editing.webp                       37KB       26KB      27%
     /images/releases/1.20/1.20.0-field-widgets.webp                              38KB       29KB      24%
     /images/releases/1.21/1.21.0-email-replies.webp                              50KB       38KB      25%
     /images/releases/1.21/1.21.0-maintenance-mode.webp                           55KB       39KB      28%
     /images/releases/1.22/1.22.0-rich-text-layouts.webp                          54KB       41KB      24%
     /images/releases/1.23/1.23.0-easier-layouts.webp                             46KB       30KB      34%
     /images/releases/1.3/1.3-IMAP.webp                                           83KB       59KB      29%
     /images/releases/1.3/1.3-merge.webp                                          64KB       44KB      30%
     /images/releases/1.4/1.4-field-permissions.webp                              50KB       37KB      24%
     /images/releases/1.4/1.4-two-factor-auth.webp                                67KB       50KB      25%
     /images/releases/1.4/1.4-workflow-filters.webp                               38KB       26KB      31%
     /images/releases/1.5/1.5-workflow-branches.webp                              31KB       21KB      31%
     /images/releases/1.6/1.6-workflows-improvements.webp                         45KB       31KB      29%
     /images/releases/1.7/1.7-impersonating.webp                                  79KB       60KB      23%
     /images/releases/1.7/1.7-upsert.webp                                         51KB       31KB      38%
     /images/releases/1.8/1.8-bulk-select.webp                                    49KB       34KB      29%
     /images/releases/1.8/1.8-search-limit.webp                                   34KB       23KB      31%
     /images/releases/1.8/1.8-workflow-iterator.webp                              38KB       23KB      39%
     /images/releases/labs/translation.webp                                       65KB       46KB      28%
     /images/shared/companies/logos/a16z.png                                       0KB        0KB     -48%
     /images/shared/companies/logos/accel.png                                      0KB        0KB    -129%
     /images/shared/companies/logos/airbnb.png                                     0KB        0KB     -52%
     /images/shared/companies/logos/airtable.png                                   0KB        1KB     -42%
     /images/shared/companies/logos/anthropic.png                                  0KB        0KB     -45%
     /images/shared/companies/logos/apple-1977.png                                 0KB        1KB     -54%
     /images/shared/companies/logos/apple.png                                      0KB        0KB     -89%
     /images/shared/companies/logos/calendar.png                                   0KB        0KB     -48%
     /images/shared/companies/logos/claude.png                                    40KB       31KB      21%
     /images/shared/companies/logos/cursor.png                                     0KB        0KB     -58%
     /images/shared/companies/logos/docusign.png                                   0KB        0KB     -48%
     /images/shared/companies/logos/figma.png                                      0KB        0KB     -46%
     /images/shared/companies/logos/founders-fund.png                              0KB        0KB     -61%
     /images/shared/companies/logos/github.png                                     0KB        0KB     -37%
     /images/shared/companies/logos/gmail.png                                      0KB        1KB     -60%
     /images/shared/companies/logos/google.png                                     0KB        1KB     -36%
     /images/shared/companies/logos/hubspot.png                                    0KB        0KB      -2%
     /images/shared/companies/logos/kleiner-perkins.png                            0KB        0KB    -105%
     /images/shared/companies/logos/lemlist.png                                    0KB        0KB     -64%
     /images/shared/companies/logos/linkedin.png                                   0KB        0KB     -66%
     /images/shared/companies/logos/mailchimp.png                                  0KB        0KB     -39%
     /images/shared/companies/logos/meet.png                                       0KB        0KB     -51%
     /images/shared/companies/logos/metabase.png                                   1KB        1KB     -32%
     /images/shared/companies/logos/microsoft.png                                  0KB        0KB       4%
     /images/shared/companies/logos/notion.png                                     0KB        1KB     -19%
     /images/shared/companies/logos/okta.png                                       0KB        0KB     -35%
     /images/shared/companies/logos/openai.png                                     0KB        1KB     -13%
     /images/shared/companies/logos/outlook.png                                    0KB        0KB     -26%
     /images/shared/companies/logos/outreach.png                                   0KB        0KB     -53%
     /images/shared/companies/logos/postgresql.png                                 1KB        1KB      -2%
     /images/shared/companies/logos/qonto.png                                      0KB        0KB     -61%
     /images/shared/companies/logos/salesforce.png                                 0KB        0KB     -39%
     /images/shared/companies/logos/segment.png                                    0KB        1KB     -49%
     /images/shared/companies/logos/sequoia.png                                    0KB        0KB     -59%
     /images/shared/companies/logos/slack.png                                      0KB        0KB     -40%
     /images/shared/companies/logos/stripe.png                                     0KB        0KB     -51%
     /images/shared/companies/logos/tally.png                                      0KB        0KB     -53%
     /images/shared/companies/logos/twenty.png                                     0KB        0KB     -56%
     /images/shared/companies/logos/whatsapp.png                                   0KB        0KB     -64%
     /images/shared/companies/logos/zapier.png                                     0KB        0KB     -27%
     /images/shared/engagement-band/halftone-on-gray.png                           7KB       17KB    -121%
     /images/shared/engagement-band/halftone-on-white.png                          2KB        2KB      -2%
     /images/shared/light-noise.webp                                               0KB        0KB    -225%
     /images/shared/menu/developers-preview.png                                   65KB       33KB      49%
     /images/shared/menu/partners-preview.png                                     52KB       24KB      53%
     /images/shared/menu/user-guide-preview.png                                   58KB       50KB      14%
     /images/shared/people/avatars/alexandre-prot.jpg                              0KB        0KB     -32%
     /images/shared/people/avatars/anonymous-anna.jpg                              0KB        0KB     -81%
     /images/shared/people/avatars/anonymous-fabrice.jpg                           0KB        0KB     -28%
     /images/shared/people/avatars/anonymous-felix.jpg                             0KB        0KB     -40%
     /images/shared/people/avatars/anonymous-indira.jpg                            0KB        0KB     -44%
     /images/shared/people/avatars/anonymous-laura.jpg                             0KB        0KB     -52%
     /images/shared/people/avatars/anonymous-mike.jpg                              0KB        0KB     -78%
     /images/shared/people/avatars/anonymous-thomas.jpg                            0KB        0KB     -44%
     /images/shared/people/avatars/ben-chestnut.jpg                                0KB        0KB     -74%
     /images/shared/people/avatars/brian-chesky.jpg                                0KB        0KB     -52%
     /images/shared/people/avatars/chris-wanstrath.jpg                             0KB        0KB     -46%
     /images/shared/people/avatars/craig-federighi.jpg                             0KB        0KB     -63%
     /images/shared/people/avatars/dario-amodei.jpg                               15KB       14KB       4%
     /images/shared/people/avatars/dylan-field.jpg                                 0KB        0KB     -62%
     /images/shared/people/avatars/eddy-cue.jpg                                    0KB        0KB     -54%
     /images/shared/people/avatars/ivan-zhao.jpg                                   0KB        0KB     -69%
     /images/shared/people/avatars/jeff-williams.jpg                               0KB        0KB     -59%
     /images/shared/people/avatars/joe-gebbia.jpg                                  0KB        0KB     -52%
     /images/shared/people/avatars/katherine-adams.jpg                             0KB        0KB     -64%
     /images/shared/people/avatars/patrick-collison.jpg                            0KB        0KB     -20%
     /images/shared/people/avatars/peter-reinhardt.jpg                             0KB        0KB     -87%
     /images/shared/people/avatars/peter-thiel.jpg                                 0KB        0KB     -57%
     /images/shared/people/avatars/phil-schiller.jpg                               0KB        0KB     -52%
     /images/shared/people/avatars/ping-li.jpg                                    13KB       12KB      10%
     /images/shared/people/avatars/ray-damm.jpg                                    0KB        0KB     -41%
     /images/shared/people/avatars/reid-hoffman.jpg                                0KB        0KB     -60%
     /images/shared/people/avatars/roelof-botha.jpg                                0KB        0KB     -41%
     /images/shared/people/avatars/ryan-roslansky.jpg                              0KB        0KB     -46%
     /images/shared/people/avatars/steve-anavi.jpg                                 0KB        0KB     -32%
     /images/shared/people/avatars/stewart-butterfield.jpg                         0KB        0KB     -36%
     /images/shared/people/avatars/sundar-pichai.jpg                               0KB        0KB     -41%
     /images/shared/people/avatars/thomas-dohmke.jpg                              15KB       14KB       4%
     /images/shared/people/avatars/tim-cook.jpg                                    0KB        0KB     -68%
     /images/why-twenty/hero/background.webp                                      10KB        9KB      11%

     === TOTALS ===
     Files: 249
     Total WebP: 8.2 MB
     Total AVIF: 6.1 MB
     Total saved: 2.1 MB (26%)
```
2026-04-21 18:15:38 +02:00
neo773andGitHub a7b10b281c Optimize 3d models (#19935)
Remove unused textures and UV data from 3D models
Also simplified meshes where possible and re-compressed
Verified that no visual regression was seen but needs to be double
checked in case I missed something

Total model size: 5.1MB -> 1.5MB
2026-04-21 18:13:03 +02:00
Abdul RahmanandGitHub 3eabdf302e Allow closing navbar folder while viewing an active child item (#19936)
### Before


https://github.com/user-attachments/assets/f8c5c075-b086-4a4c-9514-c7cbbf9b7c56




### After


https://github.com/user-attachments/assets/37427428-046c-4815-a57e-bc5c4fcc7f68
2026-04-21 14:49:31 +00:00
8bb98c309a i18n - docs translations (#19939)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-21 16:52:23 +02:00
EtienneandGitHub 8f34a02fea Import - Fix (#19938)
Fixes
https://discord.com/channels/1130383047699738754/1496121832619774042/1496121832619774042

Before
<img width="1089" height="734" alt="Screenshot 2026-04-21 at 16 23 27"
src="https://github.com/user-attachments/assets/6fe17bc1-b1ec-4b5d-a922-d472ff4c4f2f"
/>
After
<img width="1089" height="731" alt="Screenshot 2026-04-21 at 16 22 56"
src="https://github.com/user-attachments/assets/ab7e8fe4-8188-4222-a237-992b73a1594e"
/>
2026-04-21 14:39:34 +00:00
Paul RastoinandGitHub 61fdb613e6 Reset default app packages command (#19931)
# Introduction
If a self host creates its twenty instance using storage type local, and
then edit through the admin panel the storage type, the apps default
deps file won't be swapped to the new storage location

This command allow to manually rebuild them

## What could be done in addition
- We could display a modal in the admin panel when the user is editing
env variable that might have a side effect
- We might wanna rebuild the deps by default if we detect such a change
through the UI though we can't really if it's through the `.env` so I'm
not sure we wanna prio such logic
2026-04-21 13:32:59 +00:00
Abdullah.andGitHub 7947b1b843 Fix self-hosting pricing page design. (#19930)
Resolve:
https://discord.com/channels/1130383047699738754/1496108238909739079
2026-04-21 13:03:17 +00:00
Abdullah.andGitHub 2b399fc94e [Website] Fix flickering of faq illustration. (#19920)
Before:


https://github.com/user-attachments/assets/da189675-5de2-47be-b491-0d52eb11331e

After:


https://github.com/user-attachments/assets/7e382523-3539-4978-873c-e4ea79e264a7
2026-04-21 15:05:58 +02:00
44ba7725ae i18n - docs translations (#19934)
Created by Github action

Co-authored-by: github-actions <github-actions@twenty.com>
2026-04-21 14:46:37 +02:00
Paul RastoinandGitHub 65e01400c0 Cross version ci placeholder (#19932)
Created this empty workflow so it appears on main and is pickable from a
different branch to start testing the whole flow
2026-04-21 14:19:28 +02:00
792 changed files with 29174 additions and 5654 deletions
-6
View File
@@ -1,9 +1,3 @@
<p align="center">
<a href="https://www.producthunt.com/products/twenty-crm?launch=twenty-2-0">
<img src="./packages/twenty-website/public/images/readme/product-hunt-banner.png" alt="We're live on Product Hunt — Support us" />
</a>
</p>
<p align="center">
<a href="https://www.twenty.com">
<img src="./packages/twenty-website/public/images/core/logo.svg" width="100px" alt="Twenty logo" />
@@ -0,0 +1,32 @@
# 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
@@ -0,0 +1 @@
24.5.0
@@ -0,0 +1,19 @@
{
"$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"
}
}
@@ -0,0 +1 @@
nodeLinker: node-modules
@@ -0,0 +1,254 @@
# 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.
![GitHub Connector marketplace listing in Settings → Apps with About / Content / Permissions / Settings tabs](public/screenshots/app-listing.png)
![GitHub Dashboard with PR / review counters, weekly histograms, and top-contributor leaderboards](public/screenshots/github-dashboard.png)
![Pull Requests view with the Fetch Pull Requests command](public/screenshots/pull-requests-view.png)
![Contributor detail page with the Contributor Stats panel showing PRs authored, merged and reviewed over time](public/screenshots/contributor-stats.png)
## 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.
@@ -0,0 +1,35 @@
{
"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.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

@@ -0,0 +1,54 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { appDevOnce, appUninstall } from 'twenty-sdk/cli';
const APP_PATH = process.cwd();
const CONFIG_DIR = path.join(os.homedir(), '.twenty');
function writeConfig(apiUrl: string, apiKey: string) {
const payload = JSON.stringify(
{
remotes: {
local: { apiUrl, apiKey, accessToken: apiKey },
},
defaultRemote: 'local',
},
null,
2,
);
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload);
}
export async function setup() {
const apiUrl = process.env.TWENTY_API_URL!;
const apiKey = process.env.TWENTY_API_KEY!;
writeConfig(apiUrl, apiKey);
await appUninstall({ appPath: APP_PATH }).catch(() => {});
const result = await appDevOnce({
appPath: APP_PATH,
onProgress: (message: string) => console.log(`[dev] ${message}`),
});
if (!result.success) {
throw new Error(
`Dev sync failed: ${result.error?.message ?? 'Unknown error'}`,
);
}
}
export async function teardown() {
const uninstallResult = await appUninstall({ appPath: APP_PATH });
if (!uninstallResult.success) {
console.warn(
`App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`,
);
}
}
@@ -0,0 +1,128 @@
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
const metadata = () =>
new MetadataApiClient({
headers: {
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
});
export async function findObjectByName(name: string) {
const client = metadata();
const result = await client.query({
objects: {
__args: {
filter: { isCustom: { is: true } },
paging: { first: 50 },
},
edges: {
node: {
nameSingular: true,
fields: {
__args: { paging: { first: 500 } },
edges: { node: { name: true, type: true } },
},
},
},
},
});
return result.objects?.edges?.find((e) => e.node.nameSingular === name)?.node;
}
type ExecutionResult = {
data: unknown;
status: string;
duration: number;
error: unknown;
};
export async function findLogicFunctionId(
universalIdentifier: string,
): Promise<string> {
const client = metadata();
const result = await client.query({
findManyLogicFunctions: {
id: true,
universalIdentifier: true,
},
});
const fn = result.findManyLogicFunctions?.find(
(f) => f.universalIdentifier === universalIdentifier,
);
if (!fn) {
throw new Error(`Logic function ${universalIdentifier} not found`);
}
return fn.id;
}
export async function executeLogicFunction(
id: string,
payload: Record<string, unknown>,
): Promise<ExecutionResult> {
const client = metadata();
const result = await client.mutation({
executeOneLogicFunction: {
__args: { input: { id, payload } },
data: true,
status: true,
duration: true,
error: true,
},
});
return result.executeOneLogicFunction as ExecutionResult;
}
const BASE_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2021';
export async function callRoute(
path: string,
body: Record<string, unknown> | string,
options: {
method?: string;
auth?: boolean;
headers?: Record<string, string>;
} = {},
): Promise<{ status: number; data: unknown }> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers ?? {}),
};
if (options.auth) {
headers['Authorization'] = `Bearer ${process.env.TWENTY_API_KEY}`;
}
const res = await fetch(`${BASE_URL}/s${path}`, {
method: options.method ?? 'POST',
headers,
body: typeof body === 'string' ? body : JSON.stringify(body),
});
let data: unknown = null;
try {
data = await res.json();
} catch {
data = await res.text().catch(() => null);
}
return { status: res.status, data };
}
export async function findInstalledApp(universalIdentifier: string) {
const client = metadata();
const result = await client.query({
findManyApplications: {
id: true,
name: true,
universalIdentifier: true,
},
});
return result.findManyApplications?.find(
(app) => app.universalIdentifier === universalIdentifier,
);
}
@@ -0,0 +1,73 @@
import { beforeAll, describe, expect, it } from 'vitest';
import {
executeLogicFunction,
findLogicFunctionId,
} from './helpers/metadata';
const COUNT_PRS_FN_UI = '082227ae-2acc-4320-8d31-62ad6c443da6';
const COUNT_ISSUES_FN_UI = 'd8cc32bf-6be9-44fc-920a-8bba510f045f';
const COUNT_PROJECT_ITEMS_FN_UI = 'f7a3e1b2-5c4d-4e6f-8a9b-0d1c2e3f4a5b';
const COUNT_CONTRIBUTORS_FN_UI = 'fe0a6f00-0d63-4cb9-9b3c-1d8186181830';
const HANDLE_WEBHOOK_FN_UI = '22b199b3-2851-4a4f-99fd-4e79c188fe7d';
const fnIds: Record<string, string> = {};
beforeAll(async () => {
fnIds.prs = await findLogicFunctionId(COUNT_PRS_FN_UI);
fnIds.issues = await findLogicFunctionId(COUNT_ISSUES_FN_UI);
fnIds.projectItems = await findLogicFunctionId(COUNT_PROJECT_ITEMS_FN_UI);
fnIds.contributors = await findLogicFunctionId(COUNT_CONTRIBUTORS_FN_UI);
fnIds.webhook = await findLogicFunctionId(HANDLE_WEBHOOK_FN_UI);
});
describe('logic functions are wired up', () => {
it('count-prs is reachable and returns the expected payload shape', async () => {
const result = await executeLogicFunction(fnIds.prs, {
body: { repos: ['fixture-org/fixture-repo'] },
});
expect(['SUCCESS', 'ERROR']).toContain(result.status);
if (result.status === 'SUCCESS') {
const data = result.data as {
totalPages: number;
repos: Array<{ owner: string; repo: string; pages: number }>;
};
expect(typeof data.totalPages).toBe('number');
expect(Array.isArray(data.repos)).toBe(true);
}
});
it('count-issues is reachable', async () => {
const result = await executeLogicFunction(fnIds.issues, {
body: { repos: ['fixture-org/fixture-repo'] },
});
expect(['SUCCESS', 'ERROR']).toContain(result.status);
});
it('count-project-items is reachable', async () => {
const result = await executeLogicFunction(fnIds.projectItems, {
body: { projects: [{ owner: 'fixture-org', number: 9999999 }] },
});
expect(['SUCCESS', 'ERROR']).toContain(result.status);
});
it('count-contributors iterates configured repos and returns the per-repo split', async () => {
const result = await executeLogicFunction(fnIds.contributors, {
body: { repos: ['fixture-org/fixture-repo'] },
});
expect(['SUCCESS', 'ERROR']).toContain(result.status);
if (result.status === 'SUCCESS') {
const data = result.data as {
totalPages: number;
repos: Array<{ owner: string; repo: string; pages: number }>;
};
expect(typeof data.totalPages).toBe('number');
expect(Array.isArray(data.repos)).toBe(true);
expect('orgMembers' in (data as Record<string, unknown>)).toBe(false);
}
});
it('handle-github-webhook is registered (but rejects unsigned requests gracefully)', async () => {
expect(fnIds.webhook).toBeDefined();
});
});
@@ -0,0 +1,124 @@
import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/modules/shared/universal-identifiers';
import { describe, expect, it } from 'vitest';
import { findInstalledApp, findObjectByName } from './helpers/metadata';
describe('App installation', () => {
it('finds the installed GitHub Connector app', async () => {
const app = await findInstalledApp(APPLICATION_UNIVERSAL_IDENTIFIER);
expect(app).toBeDefined();
expect(app?.name).toMatch(/github/i);
});
});
describe('Contributor object', () => {
it('exists with the expected GitHub-only fields', async () => {
const obj = await findObjectByName('contributor');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('name');
expect(names).toContain('ghLogin');
expect(names).toContain('githubId');
expect(names).toContain('avatarUrl');
expect(names).toContain('contributions');
expect(names).not.toContain('isCoreTeam');
expect(names).not.toContain('discordId');
});
});
describe('PullRequest object', () => {
it('exists with the expected fields and relations', async () => {
const obj = await findObjectByName('pullRequest');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('name');
expect(names).toContain('githubNumber');
expect(names).toContain('uniqueIdentifier');
expect(names).toContain('url');
expect(names).toContain('state');
expect(names).toContain('mergedAt');
expect(names).toContain('closedAt');
expect(names).toContain('githubCreatedAt');
expect(names).toContain('author');
expect(names).toContain('merger');
expect(names).toContain('reviews');
expect(names).toContain('projectItems');
});
});
describe('PullRequestReviewEvent object', () => {
it('exists with the expected fields and relations', async () => {
const obj = await findObjectByName('pullRequestReviewEvent');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('title');
expect(names).toContain('githubReviewId');
expect(names).toContain('state');
expect(names).toContain('submittedAt');
expect(names).toContain('reviewer');
expect(names).toContain('pullRequest');
expect(names).toContain('review');
});
});
describe('PullRequestReview object', () => {
it('exists with the expected fields and relations', async () => {
const obj = await findObjectByName('pullRequestReview');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('title');
expect(names).toContain('reviewKey');
expect(names).toContain('state');
expect(names).toContain('firstSubmittedAt');
expect(names).toContain('lastSubmittedAt');
expect(names).toContain('eventCount');
expect(names).toContain('reviewer');
expect(names).toContain('pullRequest');
expect(names).toContain('reviewEvents');
});
});
describe('Issue object', () => {
it('exists with the expected fields and relations', async () => {
const obj = await findObjectByName('issue');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('title');
expect(names).toContain('githubNumber');
expect(names).toContain('uniqueIdentifier');
expect(names).toContain('githubUrl');
expect(names).toContain('state');
expect(names).toContain('labels');
expect(names).toContain('githubCreatedAt');
expect(names).toContain('closedAt');
expect(names).toContain('repo');
expect(names).toContain('author');
expect(names).toContain('projectItems');
});
});
describe('ProjectItem object', () => {
it('exists with the expected fields and relations', async () => {
const obj = await findObjectByName('projectItem');
expect(obj).toBeDefined();
const names = obj!.fields.edges.map((e) => e.node.name);
expect(names).toContain('name');
expect(names).toContain('githubProjectItemId');
expect(names).toContain('status');
expect(names).toContain('sprint');
expect(names).toContain('assignees');
expect(names).toContain('priority');
expect(names).toContain('mainAssignee');
expect(names).toContain('linkedIssue');
expect(names).toContain('linkedPullRequest');
expect(names).toContain('githubUrl');
expect(names).toContain('repo');
});
});
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import { createHmac } from 'crypto';
import {
getRawBodyForSignature,
verifyGitHubSignature,
} from 'src/modules/github/connector/webhook-signature';
const SECRET = 'super-secret-shared-string';
function sign(body: string): string {
return `sha256=${createHmac('sha256', SECRET).update(body).digest('hex')}`;
}
describe('verifyGitHubSignature', () => {
it('accepts a valid signature', () => {
const body = '{"action":"opened","number":42}';
const result = verifyGitHubSignature({
rawBody: body,
signatureHeader: sign(body),
secret: SECRET,
});
expect(result.ok).toBe(true);
});
it('rejects a tampered body', () => {
const body = '{"action":"opened","number":42}';
const signature = sign(body);
const result = verifyGitHubSignature({
rawBody: body.replace('42', '43'),
signatureHeader: signature,
secret: SECRET,
});
expect(result.ok).toBe(false);
});
it('rejects a wrong secret', () => {
const body = '{"action":"opened","number":42}';
const result = verifyGitHubSignature({
rawBody: body,
signatureHeader: sign(body),
secret: 'other-secret',
});
expect(result.ok).toBe(false);
});
it('rejects a missing header', () => {
const result = verifyGitHubSignature({
rawBody: 'anything',
signatureHeader: undefined,
secret: SECRET,
});
expect(result).toMatchObject({
ok: false,
reason: 'missing X-Hub-Signature-256 header',
});
});
it('rejects a header without sha256= prefix', () => {
const result = verifyGitHubSignature({
rawBody: 'anything',
signatureHeader: 'sha1=deadbeef',
secret: SECRET,
});
expect(result).toMatchObject({
ok: false,
reason: 'malformed signature header',
});
});
it('rejects when length differs', () => {
const result = verifyGitHubSignature({
rawBody: 'anything',
signatureHeader: 'sha256=tooshort',
secret: SECRET,
});
expect(result).toMatchObject({
ok: false,
reason: 'signature length mismatch',
});
});
});
describe('getRawBodyForSignature', () => {
it('returns the string as-is for string body', () => {
expect(
getRawBodyForSignature({ body: '{"a":1}', isBase64Encoded: false }),
).toBe('{"a":1}');
});
it('decodes base64 bodies', () => {
const original = '{"a":1}';
const b64 = Buffer.from(original, 'utf8').toString('base64');
expect(getRawBodyForSignature({ body: b64, isBase64Encoded: true })).toBe(
original,
);
});
it('returns null for parsed object bodies (raw bytes lost)', () => {
expect(getRawBodyForSignature({ body: { a: 1 } })).toBeNull();
});
it('returns empty string for null/undefined', () => {
expect(getRawBodyForSignature({ body: null })).toBe('');
});
});
describe('verifyGitHubSignature with parsed body', () => {
it('rejects with a clear reason when the runtime parsed the JSON', () => {
const result = verifyGitHubSignature({
rawBody: null,
signatureHeader: 'sha256=deadbeef',
secret: SECRET,
});
expect(result).toMatchObject({
ok: false,
reason:
'raw request body is unavailable (the runtime parsed it as JSON); HMAC cannot be verified',
});
});
});
@@ -0,0 +1,66 @@
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,
},
},
});
@@ -0,0 +1,16 @@
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,
});
@@ -0,0 +1,122 @@
import { createSign } from 'crypto';
export function normalizePem(raw: string): string {
let pem = raw.replace(/\\n/g, '\n').trim();
if (!pem.includes('\n')) {
const beginMatch = pem.match(/^(-----BEGIN [A-Z ]+-----)/);
const endMatch = pem.match(/(-----END [A-Z ]+-----)$/);
if (beginMatch && endMatch) {
const header = beginMatch[1];
const footer = endMatch[1];
const body = pem.slice(header.length, pem.length - footer.length);
const lines = body.match(/.{1,64}/g) ?? [];
pem = [header, ...lines, footer].join('\n');
}
}
return pem;
}
function base64url(input: Buffer | string): string {
const buf = typeof input === 'string' ? Buffer.from(input) : input;
return buf.toString('base64url');
}
function signJwt(appId: string, privateKey: string): string {
const now = Math.floor(Date.now() / 1000);
const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const payload = base64url(
JSON.stringify({ iat: now - 60, exp: now + 600, iss: appId }),
);
const signature = createSign('RSA-SHA256')
.update(`${header}.${payload}`)
.sign(privateKey, 'base64url');
return `${header}.${payload}.${signature}`;
}
type CachedToken = { token: string; expiresAt: number };
let cached: CachedToken | null = null;
const TOKEN_MARGIN_MS = 5 * 60 * 1000;
async function requestInstallationToken(
appId: string,
privateKey: string,
installationId: string,
): Promise<CachedToken> {
const jwt = signJwt(appId, privateKey);
const res = await fetch(
`https://api.github.com/app/installations/${installationId}/access_tokens`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${jwt}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
},
);
if (!res.ok) {
const body = await res.text();
throw new Error(
`GitHub App token exchange failed (${res.status}): ${body}`,
);
}
const data = (await res.json()) as {
token: string;
expires_at: string;
};
return {
token: data.token,
expiresAt: new Date(data.expires_at).getTime(),
};
}
function assertFineGrainedPat(token: string): void {
if (token.startsWith('github_pat_')) return;
throw new Error(
'GITHUB_TOKEN must be a fine-grained Personal Access Token (starts with `github_pat_`). Classic PATs are not supported — create one at https://github.com/settings/personal-access-tokens.',
);
}
export async function getGitHubToken(): Promise<string> {
const pat = process.env.GITHUB_TOKEN?.trim();
if (pat) {
assertFineGrainedPat(pat);
return pat;
}
if (cached && Date.now() < cached.expiresAt - TOKEN_MARGIN_MS) {
return cached.token;
}
const appId = process.env.GITHUB_APP_ID;
const rawKey = process.env.GITHUB_APP_PRIVATE_KEY;
const installationId = process.env.GITHUB_APP_INSTALLATION_ID;
if (!appId || !rawKey || !installationId) {
const missing = [
!appId && 'GITHUB_APP_ID',
!rawKey && 'GITHUB_APP_PRIVATE_KEY',
!installationId && 'GITHUB_APP_INSTALLATION_ID',
]
.filter(Boolean)
.join(', ');
throw new Error(
`Missing GitHub credentials. Set GITHUB_TOKEN (fine-grained PAT) or all of: ${missing}.`,
);
}
const privateKey = normalizePem(rawKey);
cached = await requestInstallationToken(appId, privateKey, installationId);
return cached.token;
}
@@ -0,0 +1,55 @@
export type GithubRepo = { owner: string; repo: string };
export function parseGithubRepo(entry: string): GithubRepo | null {
const trimmed = entry.trim();
if (!trimmed) return null;
const match = trimmed.match(/^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/);
if (!match) return null;
return { owner: match[1], repo: match[2] };
}
export function getGithubRepos(): string[] {
const raw = process.env.GITHUB_REPOS ?? '';
return raw
.split(',')
.map(parseGithubRepo)
.filter((r): r is GithubRepo => r !== null)
.map((r) => `${r.owner}/${r.repo}`);
}
export type GithubProject = { owner: string; number: number };
export function parseGithubProject(entry: string): GithubProject | null {
const trimmed = entry.trim();
if (!trimmed) return null;
const urlMatch = trimmed.match(
/github\.com\/(?:orgs|users)\/([^/]+)\/projects\/(\d+)/i,
);
if (urlMatch) {
const owner = urlMatch[1];
const number = Number.parseInt(urlMatch[2], 10);
if (owner && Number.isFinite(number) && number > 0) {
return { owner, number };
}
}
const shortMatch = trimmed.match(/^([^/\s]+)\/(\d+)$/);
if (shortMatch) {
const owner = shortMatch[1];
const number = Number.parseInt(shortMatch[2], 10);
if (owner && Number.isFinite(number) && number > 0) {
return { owner, number };
}
}
return null;
}
export function getGithubProjects(): GithubProject[] {
const raw = process.env.GITHUB_PROJECTS ?? '';
return raw
.split(',')
.map(parseGithubProject)
.filter((p): p is GithubProject => p !== null);
}
@@ -0,0 +1,45 @@
import {
getGithubRepos,
parseGithubRepo,
} from 'src/modules/github/connector/config';
export type RepoCount = {
owner: string;
repo: string;
totalCount: number;
pages: number;
};
export type CountAcrossReposResult = {
totalPages: number;
repos: RepoCount[];
};
const PAGE_SIZE = 100;
export async function countAcrossRepos(
bodyRepos: string[] | undefined,
count: (owner: string, repo: string) => Promise<number>,
logTag: string,
): Promise<CountAcrossReposResult> {
const repos =
bodyRepos && bodyRepos.length > 0 ? bodyRepos : getGithubRepos();
const results: RepoCount[] = [];
let totalPages = 0;
for (const fullRepo of repos) {
const parsed = parseGithubRepo(fullRepo);
if (!parsed) {
console.warn(`[${logTag}] Skipping malformed repo entry: ${fullRepo}`);
continue;
}
const { owner, repo } = parsed;
const totalCount = await count(owner, repo);
const pages = Math.max(Math.ceil(totalCount / PAGE_SIZE), 1);
results.push({ owner, repo, totalCount, pages });
totalPages += pages;
}
return { totalPages, repos: results };
}
@@ -0,0 +1,157 @@
import { getGitHubToken } from 'src/modules/github/connector/auth';
const GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql';
const LOW_REMAINING_THRESHOLD = 200;
export class GitHubRateLimitError extends Error {
constructor(
message: string,
public readonly kind: 'primary' | 'secondary',
public readonly remaining: number | null,
public readonly limit: number | null,
public readonly resetAt: Date | null,
public readonly retryAfterSeconds: number | null,
) {
super(message);
this.name = 'GitHubRateLimitError';
}
}
function readRateLimitHeaders(res: Response) {
const num = (h: string) => {
const v = res.headers.get(h);
return v === null ? null : Number(v);
};
const reset = num('x-ratelimit-reset');
return {
limit: num('x-ratelimit-limit'),
remaining: num('x-ratelimit-remaining'),
used: num('x-ratelimit-used'),
resource: res.headers.get('x-ratelimit-resource'),
resetAt:
reset === null || Number.isNaN(reset) ? null : new Date(reset * 1000),
retryAfter: num('retry-after'),
};
}
export async function githubGraphql<T>(
query: string,
variables: Record<string, unknown>,
): Promise<T> {
const queryName = query.match(/query\s*\(/)
? query.slice(0, 60).replace(/\s+/g, ' ').trim()
: 'mutation';
console.log(
`[github-gql] Executing: ${queryName} vars=${JSON.stringify(Object.keys(variables))}`,
);
const token = await getGitHubToken();
const res = await fetch(GITHUB_GRAPHQL_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables }),
});
const rl = readRateLimitHeaders(res);
if (rl.remaining !== null && rl.limit !== null) {
const resetIn = rl.resetAt
? Math.max(0, Math.round((rl.resetAt.getTime() - Date.now()) / 1000))
: null;
const isLow =
rl.remaining <= LOW_REMAINING_THRESHOLD ||
rl.remaining / rl.limit < 0.1;
const tag = isLow
? '[github-gql][rate-limit-low]'
: '[github-gql][rate-limit]';
console.log(
`${tag} resource=${rl.resource ?? 'graphql'} remaining=${rl.remaining}/${rl.limit} used=${rl.used ?? '?'} resetIn=${resetIn ?? '?'}s`,
);
}
if (!res.ok) {
const body = await res.text();
const lower = body.toLowerCase();
const isSecondary =
res.status === 403 &&
(lower.includes('secondary rate limit') ||
lower.includes('abuse detection'));
const isPrimary =
(res.status === 403 || res.status === 429) &&
lower.includes('rate limit') &&
!isSecondary;
if (isPrimary || isSecondary) {
throw new GitHubRateLimitError(
`GitHub ${isSecondary ? 'secondary' : 'primary'} rate limit hit (${res.status}): ${body.slice(0, 200)}`,
isSecondary ? 'secondary' : 'primary',
rl.remaining,
rl.limit,
rl.resetAt,
rl.retryAfter,
);
}
throw new Error(
`GitHub GraphQL ${res.status} ${res.statusText}: ${body.slice(0, 500)}`,
);
}
const json = (await res.json()) as {
data: T;
errors?: Array<{ type?: string; message: string }>;
};
if (json.errors?.length) {
const isRateLimited = json.errors.some(
(e) => e.type === 'RATE_LIMITED' || /rate limit/i.test(e.message),
);
if (isRateLimited) {
throw new GitHubRateLimitError(
`GitHub GraphQL RATE_LIMITED: ${json.errors.map((e) => e.message).join(', ')}`,
'primary',
rl.remaining,
rl.limit,
rl.resetAt,
rl.retryAfter,
);
}
throw new Error(
`GraphQL errors: ${json.errors.map((e) => e.message).join(', ')}`,
);
}
return json.data;
}
export async function githubGraphqlOptional<T>(
query: string,
variables: Record<string, unknown>,
): Promise<T | null> {
try {
return await githubGraphql<T>(query, variables);
} catch (err) {
const msg = err instanceof Error ? err.message : '';
if (/Could not resolve to a (User|Organization)/i.test(msg)) return null;
throw err;
}
}
export type GithubPage<T> = {
totalCount: number;
pageInfo: { hasNextPage: boolean; endCursor: string | null };
nodes: T[];
};
export type Paged<TItems extends Record<string, unknown[]>> = TItems & {
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
export const EMPTY_PAGE = {
totalCount: 0,
hasMore: false,
endCursor: null,
} as const;
@@ -0,0 +1,5 @@
export type GitHubUser = {
login: string;
id: number;
avatar_url?: string | null;
};
@@ -0,0 +1,15 @@
import type { GitHubUser } from 'src/modules/github/connector/github-user';
import type { GitHubPullRequest } from 'src/modules/github/pull-request/types/github-pull-request';
import type { GitHubReview } from 'src/modules/github/pull-request-review-event/types/github-review';
import type { GitHubIssue } from 'src/modules/github/issue/types/github-issue';
import type { GitHubProjectV2Item } from 'src/modules/github/project-item/types/github-project-v2-item';
export type GitHubWebhookPayload = {
action: string;
pull_request?: GitHubPullRequest;
review?: GitHubReview;
issue?: GitHubIssue;
projects_v2_item?: GitHubProjectV2Item;
sender: GitHubUser;
repository?: { full_name: string };
};
@@ -0,0 +1,54 @@
import { createHmac, timingSafeEqual } from 'crypto';
export type SignatureVerificationResult =
| { ok: true }
| { ok: false; reason: string };
export function getRawBodyForSignature(event: {
body: unknown;
isBase64Encoded?: boolean;
}): string | null {
const raw = event.body;
if (raw == null) return '';
if (typeof raw === 'string') {
return event.isBase64Encoded
? Buffer.from(raw, 'base64').toString('utf8')
: raw;
}
return null;
}
export function verifyGitHubSignature({
rawBody,
signatureHeader,
secret,
}: {
rawBody: string | null;
signatureHeader: string | undefined;
secret: string;
}): SignatureVerificationResult {
if (rawBody === null) {
return {
ok: false,
reason:
'raw request body is unavailable (the runtime parsed it as JSON); HMAC cannot be verified',
};
}
if (!signatureHeader) {
return { ok: false, reason: 'missing X-Hub-Signature-256 header' };
}
const prefix = 'sha256=';
if (!signatureHeader.startsWith(prefix)) {
return { ok: false, reason: 'malformed signature header' };
}
const provided = signatureHeader.slice(prefix.length);
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
if (provided.length !== expected.length) {
return { ok: false, reason: 'signature length mismatch' };
}
const ok = timingSafeEqual(
Buffer.from(provided, 'utf8'),
Buffer.from(expected, 'utf8'),
);
return ok ? { ok: true } : { ok: false, reason: 'signature mismatch' };
}
@@ -0,0 +1,282 @@
import { type ReactNode } from 'react';
import { THEME } from 'src/modules/github/contributor/components/theme';
export type LeaderboardEntry = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: string | null;
count: number;
};
const ROW_HEIGHT = 32;
const CELL_PADDING_X = 8;
const RANK_COL_WIDTH = 36;
const COUNT_COL_WIDTH = 72;
const styles = {
root: {
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
minHeight: 0,
fontFamily: THEME.fontFamily,
color: THEME.fontPrimary,
background: THEME.bgPrimary,
boxSizing: 'border-box',
overflow: 'hidden',
} as const,
table: {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
overflowY: 'auto',
overflowX: 'hidden',
width: '100%',
} as const,
headerRow: {
display: 'flex',
alignItems: 'stretch',
height: ROW_HEIGHT,
flexShrink: 0,
borderBottom: `1px solid ${THEME.borderLight}`,
background: THEME.bgPrimary,
} as const,
headerCell: {
display: 'flex',
alignItems: 'center',
height: '100%',
padding: `0 ${CELL_PADDING_X}px`,
borderRight: `1px solid ${THEME.borderLight}`,
color: THEME.fontTertiary,
fontSize: 12,
fontWeight: 500,
boxSizing: 'border-box',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
userSelect: 'none',
} as const,
headerCellLast: {
borderRight: 'none',
} as const,
row: {
display: 'flex',
alignItems: 'stretch',
height: ROW_HEIGHT,
flexShrink: 0,
width: '100%',
background: THEME.bgPrimary,
color: THEME.fontPrimary,
textDecoration: 'none',
boxSizing: 'border-box',
} as const,
cell: {
display: 'flex',
alignItems: 'center',
height: '100%',
padding: `0 ${CELL_PADDING_X}px`,
borderBottom: `1px solid ${THEME.borderLight}`,
borderRight: `1px solid ${THEME.borderLight}`,
fontSize: 13,
boxSizing: 'border-box',
minWidth: 0,
overflow: 'hidden',
transition: 'background-color 80ms ease',
} as const,
cellLast: {
borderRight: 'none',
} as const,
rankCell: {
width: RANK_COL_WIDTH,
flexShrink: 0,
justifyContent: 'flex-end',
color: THEME.fontTertiary,
fontSize: 12,
fontVariantNumeric: 'tabular-nums',
} as const,
contributorCell: {
flex: 1,
minWidth: 0,
gap: 6,
} as const,
countCell: {
width: COUNT_COL_WIDTH,
flexShrink: 0,
justifyContent: 'flex-end',
color: THEME.fontSecondary,
fontVariantNumeric: 'tabular-nums',
fontWeight: 500,
} as const,
avatar: {
width: 16,
height: 16,
borderRadius: '50%',
objectFit: 'cover',
background: THEME.bgTertiary,
flexShrink: 0,
} as const,
nameWrapper: {
display: 'flex',
alignItems: 'baseline',
gap: 6,
minWidth: 0,
flex: 1,
lineHeight: 1.2,
} as const,
name: {
fontSize: 13,
fontWeight: 500,
color: THEME.fontPrimary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const,
login: {
fontSize: 12,
fontWeight: 400,
color: THEME.fontTertiary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const,
empty: {
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: THEME.fontTertiary,
fontSize: 13,
padding: 16,
textAlign: 'center',
} as const,
};
type LeaderboardProps = {
contributorColumnLabel: string;
countColumnLabel: string;
entries: LeaderboardEntry[];
loading: boolean;
error: string | null;
emptyMessage?: string;
};
const renderState = (content: ReactNode) => (
<div style={styles.empty}>{content}</div>
);
const Row = ({
rank,
entry,
countLabel,
}: {
rank: number;
entry: LeaderboardEntry;
countLabel: string;
}) => {
const label = entry.name ?? entry.ghLogin ?? 'Unknown';
const profileHref = entry.ghLogin
? `https://github.com/${entry.ghLogin}`
: undefined;
const onEnter = (e: React.MouseEvent<HTMLElement>) => {
e.currentTarget.style.backgroundColor = THEME.bgSecondary;
};
const onLeave = (e: React.MouseEvent<HTMLElement>) => {
e.currentTarget.style.backgroundColor = THEME.bgPrimary;
};
const cells = (
<>
<div style={{ ...styles.cell, ...styles.rankCell }}>{rank}</div>
<div style={{ ...styles.cell, ...styles.contributorCell }}>
{entry.avatarUrl ? (
<img src={entry.avatarUrl} alt="" style={styles.avatar} />
) : (
<div style={styles.avatar} />
)}
<div style={styles.nameWrapper}>
<span style={styles.name}>{label}</span>
{entry.ghLogin && entry.ghLogin !== label && (
<span style={styles.login}>@{entry.ghLogin}</span>
)}
</div>
</div>
<div
style={{ ...styles.cell, ...styles.countCell, ...styles.cellLast }}
title={countLabel}
>
{entry.count}
</div>
</>
);
if (profileHref) {
return (
<a
href={profileHref}
target="_blank"
rel="noopener noreferrer"
style={{ ...styles.row, cursor: 'pointer' }}
onMouseEnter={onEnter}
onMouseLeave={onLeave}
>
{cells}
</a>
);
}
return (
<div style={styles.row} onMouseEnter={onEnter} onMouseLeave={onLeave}>
{cells}
</div>
);
};
export const Leaderboard = ({
contributorColumnLabel,
countColumnLabel,
entries,
loading,
error,
emptyMessage = 'No activity in the last 90 days',
}: LeaderboardProps) => (
<div style={styles.root}>
<div style={styles.headerRow}>
<div style={{ ...styles.headerCell, ...styles.rankCell }}>#</div>
<div style={{ ...styles.headerCell, ...styles.contributorCell }}>
{contributorColumnLabel}
</div>
<div
style={{
...styles.headerCell,
...styles.countCell,
...styles.headerCellLast,
}}
>
{countColumnLabel}
</div>
</div>
{error ? (
renderState(error)
) : loading && entries.length === 0 ? (
renderState('Loading...')
) : entries.length === 0 ? (
renderState(emptyMessage)
) : (
<div style={styles.table}>
{entries.map((entry, i) => (
<Row
key={entry.id}
rank={i + 1}
entry={entry}
countLabel={countColumnLabel}
/>
))}
</div>
)}
</div>
);
@@ -0,0 +1,23 @@
export const THEME = {
fontFamily:
'var(--t-font-family, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)',
borderRadius: 'var(--t-border-radius-sm, 4px)',
fontPrimary: 'var(--t-font-color-primary)',
fontSecondary: 'var(--t-font-color-secondary)',
fontTertiary: 'var(--t-font-color-tertiary)',
fontLight: 'var(--t-font-color-light)',
borderLight: 'var(--t-border-color-light)',
borderMedium: 'var(--t-border-color-medium)',
bgPrimary: 'var(--t-background-primary)',
bgSecondary: 'var(--t-background-secondary)',
bgTertiary: 'var(--t-background-tertiary)',
bgTransparentLight: 'var(--t-background-transparent-light)',
bgTransparentLighter: 'var(--t-background-transparent-lighter)',
chartAuthored: 'var(--t-color-purple)',
chartMerged: 'var(--t-color-green)',
chartReviewed: 'var(--t-color-blue)',
} as const;
@@ -0,0 +1,24 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
import { MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/main-assignee-on-project-item.field';
export const ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'b35aed82-2c01-4f37-b3f9-cf67dc269d2c';
export default defineField({
universalIdentifier:
ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'assignedProjectItems',
label: 'Assigned Items',
icon: 'IconLayoutKanban',
relationTargetObjectMetadataUniversalIdentifier:
PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,22 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object';
import { ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/fields/author-on-issue.field';
export const AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'31f185c0-d2a2-47e2-9132-8cbac05f174a';
export default defineField({
universalIdentifier: AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'authoredIssues',
label: 'Authored Issues',
icon: 'IconBug',
relationTargetObjectMetadataUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,23 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object';
import { AUTHOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/author-on-pull-request.field';
export const AUTHORED_PRS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'c3d4e5f6-2b3c-4d4e-9f6a-7b8c9d0e1f2a';
export default defineField({
universalIdentifier: AUTHORED_PRS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'authoredPrs',
label: 'Authored PRs',
icon: 'IconGitPullRequest',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
AUTHOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,24 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object';
import { MERGER_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/merger-on-pull-request.field';
export const MERGED_PULL_REQUESTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'02ec0165-fb5a-46f9-b7c7-a0fd605befa2';
export default defineField({
universalIdentifier:
MERGED_PULL_REQUESTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'mergedPullRequests',
label: 'Merged Pull Requests',
icon: 'IconGitMerge',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
MERGER_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,23 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object';
import { REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/fields/reviewer-on-review-event.field';
export const REVIEW_EVENTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'e4f5a6b7-8c9d-4e0f-a1b2-c3d4e5f6a7b8';
export default defineField({
universalIdentifier: REVIEW_EVENTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'reviewEvents',
label: 'Review Events',
icon: 'IconEye',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,23 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/objects/pull-request-review.object';
import { REVIEWER_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/fields/reviewer-on-review.field';
export const REVIEWS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER =
'564e7f97-74dc-4e9b-a4bc-ed4215ab9ac7';
export default defineField({
universalIdentifier: REVIEWS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'reviews',
label: 'Reviews',
icon: 'IconEye',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
REVIEWER_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,693 @@
import { useEffect, useMemo, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
objectMetadataItem,
useRecordId,
} from 'twenty-sdk/front-component';
import { THEME } from 'src/modules/github/contributor/components/theme';
import { callAppRoute } from 'src/modules/shared/call-app-route';
type Period = 'week' | 'month' | '3months' | 'year';
type Bucket = {
key: string;
label: string;
start: string;
end: string;
prAuthored: number;
prMerged: number;
prReviewed: number;
};
type ContributorInfo = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: string | null;
};
type StatsResponse = {
contributor: ContributorInfo;
period: Period;
granularity: 'day' | 'week' | 'month';
buckets: Bucket[];
totals: { prAuthored: number; prMerged: number; prReviewed: number };
truncated: {
prAuthored: boolean;
prMerged: boolean;
prReviewed: boolean;
};
error?: string;
};
type SearchResponse = {
contributors: ContributorInfo[];
};
const readSerializedValue = (
e: React.SyntheticEvent<HTMLElement>,
): string | undefined => {
const obj = e as { detail?: { value?: string }; value?: string };
if (typeof obj.detail?.value === 'string') return obj.detail.value;
if (typeof obj.value === 'string') return obj.value;
return undefined;
};
const onValueChange =
(fn: (value: string) => void) =>
(e: React.SyntheticEvent<HTMLElement>) => {
const v = readSerializedValue(e);
if (typeof v === 'string') fn(v);
};
const PERIOD_OPTIONS: Array<{ value: Period; label: string }> = [
{ value: 'week', label: 'Last week' },
{ value: 'month', label: 'Last month' },
{ value: '3months', label: 'Last 3 months' },
{ value: 'year', label: 'Last year' },
];
const WIDGET_HEADER_HEIGHT = 24;
const styles = {
root: {
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
minHeight: 0,
boxSizing: 'border-box',
padding: 12,
gap: 12,
fontFamily: THEME.fontFamily,
color: THEME.fontPrimary,
background: THEME.bgSecondary,
overflow: 'hidden',
} as const,
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
flexWrap: 'wrap',
padding: '8px 12px',
background: THEME.bgPrimary,
border: `1px solid ${THEME.borderLight}`,
borderRadius: THEME.borderRadius,
boxSizing: 'border-box',
flexShrink: 0,
} as const,
contributor: {
display: 'flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
} as const,
avatar: {
width: 24,
height: 24,
borderRadius: '50%',
objectFit: 'cover',
background: THEME.bgTertiary,
flexShrink: 0,
} as const,
smallAvatar: {
width: 16,
height: 16,
borderRadius: '50%',
objectFit: 'cover',
background: THEME.bgTertiary,
flexShrink: 0,
} as const,
nameCol: {
display: 'flex',
alignItems: 'baseline',
minWidth: 0,
gap: 6,
lineHeight: 1.2,
} as const,
name: {
fontSize: 14,
fontWeight: 500,
color: THEME.fontPrimary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const,
login: {
fontSize: 12,
fontWeight: 400,
color: THEME.fontTertiary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const,
controls: {
display: 'flex',
alignItems: 'center',
gap: 6,
flexWrap: 'wrap',
} as const,
select: {
fontFamily: THEME.fontFamily,
fontSize: 13,
height: 28,
lineHeight: '26px',
padding: '0 24px 0 10px',
border: `1px solid ${THEME.borderMedium}`,
borderRadius: THEME.borderRadius,
background: `${THEME.bgPrimary} url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 20 20' fill='gray'><path d='M10 13l-5-5h10z'/></svg>") no-repeat right 8px center`,
color: THEME.fontPrimary,
cursor: 'pointer',
outline: 'none',
appearance: 'none',
WebkitAppearance: 'none',
MozAppearance: 'none',
} as const,
buttonNeutral: {
fontFamily: THEME.fontFamily,
fontSize: 13,
fontWeight: 500,
height: 28,
lineHeight: '26px',
padding: '0 10px',
border: `1px solid ${THEME.borderMedium}`,
borderRadius: THEME.borderRadius,
background: THEME.bgPrimary,
color: THEME.fontPrimary,
cursor: 'pointer',
outline: 'none',
} as const,
body: {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
gap: 12,
} as const,
chartCard: {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
border: `1px solid ${THEME.borderLight}`,
borderRadius: THEME.borderRadius,
padding: 8,
background: THEME.bgPrimary,
boxSizing: 'border-box',
} as const,
chartHeader: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: WIDGET_HEADER_HEIGHT,
padding: '0 4px',
flexShrink: 0,
} as const,
chartTitle: {
fontSize: 13,
fontWeight: 500,
color: THEME.fontPrimary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
} as const,
chartTotal: {
fontSize: 13,
fontWeight: 500,
color: THEME.fontTertiary,
fontVariantNumeric: 'tabular-nums',
flexShrink: 0,
} as const,
chartContainer: {
flex: '1 1 0%',
minHeight: 90,
width: '100%',
position: 'relative',
marginTop: 4,
} as const,
centered: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
color: THEME.fontTertiary,
fontSize: 13,
textAlign: 'center',
padding: 16,
} as const,
searchWrapper: {
display: 'flex',
flexDirection: 'column',
gap: 8,
flex: 1,
minHeight: 0,
} as const,
searchInput: {
fontFamily: THEME.fontFamily,
fontSize: 13,
height: 28,
padding: '0 10px',
border: `1px solid ${THEME.borderMedium}`,
borderRadius: THEME.borderRadius,
outline: 'none',
background: THEME.bgPrimary,
color: THEME.fontPrimary,
} as const,
searchList: {
display: 'flex',
flexDirection: 'column',
flex: 1,
minHeight: 0,
overflowY: 'auto',
border: `1px solid ${THEME.borderLight}`,
borderRadius: THEME.borderRadius,
background: THEME.bgPrimary,
} as const,
searchItem: {
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 10px',
cursor: 'pointer',
borderBottom: `1px solid ${THEME.borderLight}`,
fontSize: 13,
color: THEME.fontPrimary,
} as const,
truncatedHint: {
fontSize: 11,
color: THEME.fontTertiary,
marginTop: 4,
paddingLeft: 4,
} as const,
};
type BarChartProps = {
buckets: Bucket[];
valueKey: 'prAuthored' | 'prMerged' | 'prReviewed';
color: string;
};
const CHART_PADDING = { top: 6, right: 4, bottom: 16, left: 22 };
const BarChart = ({ buckets, valueKey, color }: BarChartProps) => {
const yMax = useMemo(() => {
const m = buckets.reduce((acc, b) => Math.max(acc, b[valueKey]), 0);
if (m === 0) return 1;
return m <= 5 ? m : Math.ceil(m / 5) * 5;
}, [buckets, valueKey]);
const yTicks = useMemo(() => [0, Math.round(yMax / 2), yMax], [yMax]);
const n = buckets.length;
const labelEvery = (() => {
if (n <= 6) return 1;
if (n <= 14) return 2;
return Math.ceil(n / 6);
})();
return (
<div style={styles.chartContainer}>
<div
style={{
position: 'absolute',
left: 0,
top: CHART_PADDING.top,
bottom: CHART_PADDING.bottom,
width: CHART_PADDING.left,
}}
>
{yTicks.map((t) => {
const bottomPct = (t / yMax) * 100;
return (
<div
key={t}
style={{
position: 'absolute',
right: 4,
bottom: `${bottomPct}%`,
transform: 'translateY(50%)',
fontSize: 10,
color: THEME.fontTertiary,
lineHeight: 1,
fontVariantNumeric: 'tabular-nums',
}}
>
{t}
</div>
);
})}
</div>
<div
style={{
position: 'absolute',
left: CHART_PADDING.left,
right: CHART_PADDING.right,
top: CHART_PADDING.top,
bottom: CHART_PADDING.bottom,
}}
>
{yTicks.map((t, i) => {
const bottomPct = (t / yMax) * 100;
return (
<div
key={t}
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: `${bottomPct}%`,
borderTop: `1px ${i === 0 ? 'solid' : 'dotted'} ${THEME.borderLight}`,
pointerEvents: 'none',
}}
/>
);
})}
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'flex-end',
gap: 2,
}}
>
{buckets.map((b, i) => {
const v = b[valueKey];
const heightPct = (v / yMax) * 100;
const showLabel = i % labelEvery === 0 || i === n - 1;
return (
<div
key={b.key}
title={`${b.label}: ${v}`}
style={{
flex: '1 1 0',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-end',
position: 'relative',
minWidth: 0,
}}
>
<div
style={{
width: '70%',
maxWidth: 22,
height: `${heightPct}%`,
minHeight: v > 0 ? 2 : 0,
background: color,
borderRadius: '2px 2px 0 0',
}}
/>
{showLabel && (
<div
style={{
position: 'absolute',
top: '100%',
marginTop: 4,
fontSize: 10,
color: THEME.fontTertiary,
whiteSpace: 'nowrap',
lineHeight: 1,
pointerEvents: 'none',
fontVariantNumeric: 'tabular-nums',
}}
>
{b.label}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
};
const ContributorStats = () => {
const recordId = useRecordId();
const [period, setPeriod] = useState<Period>('month');
const [selectedId, setSelectedId] = useState<string | null>(recordId);
const [stats, setStats] = useState<StatsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResponse['contributors']>([]);
const [searchLoading, setSearchLoading] = useState(false);
useEffect(() => {
setSelectedId(recordId);
}, [recordId]);
useEffect(() => {
if (selectedId) return;
let cancelled = false;
setSearchLoading(true);
const handle = setTimeout(async () => {
try {
const res = (await callAppRoute('/contributors/search', {
query: searchQuery,
limit: 20,
})) as SearchResponse;
if (!cancelled) setSearchResults(res.contributors ?? []);
} catch (err) {
if (!cancelled) {
enqueueSnackbar({
message:
err instanceof Error ? err.message : 'Failed to search contributors',
variant: 'error',
});
}
} finally {
if (!cancelled) setSearchLoading(false);
}
}, 200);
return () => {
cancelled = true;
clearTimeout(handle);
};
}, [selectedId, searchQuery]);
useEffect(() => {
if (!selectedId) {
setStats(null);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
(async () => {
try {
const res = (await callAppRoute('/contributors/stats', {
contributorId: selectedId,
period,
})) as StatsResponse;
if (cancelled) return;
if (res.error) {
setError(res.error);
setStats(null);
} else {
setStats(res);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load stats');
setStats(null);
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [selectedId, period]);
const renderHeader = (contributor: ContributorInfo | null) => (
<div style={styles.header}>
<div style={styles.contributor}>
{contributor?.avatarUrl ? (
<img src={contributor.avatarUrl} alt="" style={styles.avatar} />
) : (
<div style={styles.avatar} />
)}
<div style={styles.nameCol}>
<span style={styles.name}>
{contributor?.name ?? contributor?.ghLogin ?? 'Contributor stats'}
</span>
{contributor?.ghLogin &&
contributor.ghLogin !== contributor.name && (
<span style={styles.login}>@{contributor.ghLogin}</span>
)}
</div>
</div>
<div style={styles.controls}>
{selectedId && !recordId && (
<button
type="button"
onClick={() => setSelectedId(null)}
style={styles.buttonNeutral}
>
Change
</button>
)}
<select
value={period}
onChange={onValueChange((v) => setPeriod(v as Period))}
style={styles.select}
>
{PERIOD_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
);
if (!selectedId) {
return (
<div style={styles.root}>
{renderHeader(null)}
<div style={styles.searchWrapper}>
<input
value={searchQuery}
onInput={onValueChange(setSearchQuery)}
onChange={onValueChange(setSearchQuery)}
placeholder="Search a contributor by name or GitHub login..."
style={styles.searchInput}
/>
<div style={styles.searchList}>
{searchLoading && searchResults.length === 0 && (
<div style={{ ...styles.searchItem, color: THEME.fontTertiary }}>
Searching...
</div>
)}
{!searchLoading && searchResults.length === 0 && (
<div style={{ ...styles.searchItem, color: THEME.fontTertiary }}>
No contributors found.
</div>
)}
{searchResults.map((eng) => (
<div
key={eng.id}
role="button"
tabIndex={0}
onClick={() => setSelectedId(eng.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') setSelectedId(eng.id);
}}
style={styles.searchItem}
>
{eng.avatarUrl ? (
<img src={eng.avatarUrl} alt="" style={styles.smallAvatar} />
) : (
<div style={styles.smallAvatar} />
)}
<div style={styles.nameCol}>
<span style={styles.name}>
{eng.name ?? eng.ghLogin ?? 'Unknown'}
</span>
{eng.ghLogin && eng.ghLogin !== eng.name && (
<span style={styles.login}>@{eng.ghLogin}</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
return (
<div style={styles.root}>
{renderHeader(stats?.contributor ?? null)}
<div style={styles.body}>
{loading && !stats && (
<div style={styles.centered}>Loading contributor stats...</div>
)}
{error && <div style={styles.centered}>{error}</div>}
{stats && (
<>
<div style={styles.chartCard}>
<div style={styles.chartHeader}>
<span style={styles.chartTitle}>PRs authored (merged)</span>
<span style={styles.chartTotal}>{stats.totals.prAuthored}</span>
</div>
<BarChart
buckets={stats.buckets}
valueKey="prAuthored"
color={THEME.chartAuthored}
/>
{stats.truncated.prAuthored && (
<span style={styles.truncatedHint}>
Showing partial data (results truncated).
</span>
)}
</div>
<div style={styles.chartCard}>
<div style={styles.chartHeader}>
<span style={styles.chartTitle}>PRs merged</span>
<span style={styles.chartTotal}>{stats.totals.prMerged}</span>
</div>
<BarChart
buckets={stats.buckets}
valueKey="prMerged"
color={THEME.chartMerged}
/>
{stats.truncated.prMerged && (
<span style={styles.truncatedHint}>
Showing partial data (results truncated).
</span>
)}
</div>
<div style={styles.chartCard}>
<div style={styles.chartHeader}>
<span style={styles.chartTitle}>PRs reviewed</span>
<span style={styles.chartTotal}>{stats.totals.prReviewed}</span>
</div>
<BarChart
buckets={stats.buckets}
valueKey="prReviewed"
color={THEME.chartReviewed}
/>
{stats.truncated.prReviewed && (
<span style={styles.truncatedHint}>
Showing partial data (results truncated).
</span>
)}
</div>
</>
)}
</div>
</div>
);
};
export default defineFrontComponent({
universalIdentifier: '6c2f1a8d-4b9e-4f7a-9c5d-1e8b2a3d4c5f',
name: 'Contributor Stats',
description:
'Displays time-bucketed charts of PRs authored (merged only), merged and reviewed by a contributor over the last week, month, 3 months or year.',
component: ContributorStats,
command: {
universalIdentifier: '7d3f2b9e-5c0a-4e8b-ad6e-2f9c3b4d5e6a',
label: 'Contributor Stats',
icon: 'IconChartBar',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'contributor',
},
});
@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
objectMetadataItem,
unmountFrontComponent,
updateProgress,
} from 'twenty-sdk/front-component';
import { callAppRoute } from 'src/modules/shared/call-app-route';
type CountResponse = {
totalPages: number;
repos: Array<{ owner: string; repo: string; totalCount: number; pages: number }>;
};
type FetchPageResponse = {
contributorCount: number;
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
type SyncStatus = 'syncing' | 'done' | 'error';
const FetchContributors = () => {
const [status, setStatus] = useState<SyncStatus>('syncing');
useEffect(() => {
const run = async () => {
try {
const counts = (await callAppRoute(
'/github/count-contributors',
{},
)) as CountResponse;
if (counts.repos.length === 0) {
throw new Error(
'No repos resolved. Set GITHUB_REPOS in the application variables.',
);
}
const totalPages = Math.max(counts.totalPages, 1);
let pagesProcessed = 0;
let totalSynced = 0;
for (const { owner, repo } of counts.repos) {
let cursor: string | null = null;
let hasMore = true;
while (hasMore) {
const data = (await callAppRoute('/github/fetch-contributors', {
owner,
repo,
cursor,
})) as FetchPageResponse;
totalSynced += data.contributorCount;
hasMore = data.hasMore && data.contributorCount > 0;
cursor = data.endCursor;
pagesProcessed++;
updateProgress(
Math.min(Math.round((pagesProcessed / totalPages) * 100), 99),
);
}
}
updateProgress(100);
enqueueSnackbar({
message: `Synced ${totalSynced} contributors`,
variant: 'success',
});
setStatus('done');
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to fetch contributors';
enqueueSnackbar({ message, variant: 'error' });
setStatus('error');
} finally {
unmountFrontComponent();
}
};
run();
}, []);
if (status === 'syncing') return <div>Fetching contributors...</div>;
if (status === 'error') return <div>Failed to fetch contributors.</div>;
return <div>Done</div>;
};
export default defineFrontComponent({
universalIdentifier: '08f40f82-24ed-4f3e-8c99-695151e90e38',
name: 'Fetch Contributors',
description:
'Fetches contributors from every configured GitHub repo (GITHUB_REPOS) and upserts them as Contributor records.',
isHeadless: true,
component: FetchContributors,
command: {
universalIdentifier: '4640992f-c2c9-4bba-b5df-9c8f05dc9e80',
label: 'Fetch Contributors',
icon: 'IconUsers',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'contributor',
},
});
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import {
Leaderboard,
type LeaderboardEntry,
} from 'src/modules/github/contributor/components/leaderboard';
import { callAppRoute } from 'src/modules/shared/call-app-route';
type Response = {
topAuthors: LeaderboardEntry[];
};
const TopPRAuthors = () => {
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
(async () => {
try {
const res = (await callAppRoute('/contributors/top', {
days: 90,
limit: 20,
kind: 'authors',
})) as Response;
if (!cancelled) setEntries(res.topAuthors ?? []);
} catch (err) {
if (!cancelled) {
const message =
err instanceof Error ? err.message : 'Failed to load top authors';
setError(message);
enqueueSnackbar({ message, variant: 'error' });
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<Leaderboard
contributorColumnLabel="Contributor"
countColumnLabel="PRs"
entries={entries}
loading={loading}
error={error}
/>
);
};
export const TOP_PR_AUTHORS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'a1d4f7e2-9b3c-4e8a-bf21-5d6c8a9b2e3f';
export default defineFrontComponent({
universalIdentifier: TOP_PR_AUTHORS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Top PR Authors',
description: 'Leaderboard of the top 20 pull-request authors over the last 90 days.',
component: TopPRAuthors,
});
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { enqueueSnackbar } from 'twenty-sdk/front-component';
import {
Leaderboard,
type LeaderboardEntry,
} from 'src/modules/github/contributor/components/leaderboard';
import { callAppRoute } from 'src/modules/shared/call-app-route';
type Response = {
topReviewers: LeaderboardEntry[];
};
const TopReviewers = () => {
const [entries, setEntries] = useState<LeaderboardEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
(async () => {
try {
const res = (await callAppRoute('/contributors/top', {
days: 90,
limit: 20,
kind: 'reviewers',
})) as Response;
if (!cancelled) setEntries(res.topReviewers ?? []);
} catch (err) {
if (!cancelled) {
const message =
err instanceof Error ? err.message : 'Failed to load top reviewers';
setError(message);
enqueueSnackbar({ message, variant: 'error' });
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<Leaderboard
contributorColumnLabel="Contributor"
countColumnLabel="Reviews"
entries={entries}
loading={loading}
error={error}
/>
);
};
export const TOP_REVIEWERS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER =
'b2e5f8a3-ac4d-4f9b-8032-6e7d9bac3f4a';
export default defineFrontComponent({
universalIdentifier: TOP_REVIEWERS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
name: 'Top Reviewers',
description: 'Leaderboard of the top 20 pull-request reviewers over the last 90 days.',
component: TopReviewers,
});
@@ -0,0 +1,20 @@
import { githubGraphql } from 'src/modules/github/connector/github-client';
const QUERY = `
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
mentionableUsers { totalCount }
}
}`;
type Response = {
repository: { mentionableUsers: { totalCount: number } } | null;
};
export async function countContributors(
owner: string,
name: string,
): Promise<number> {
const data = await githubGraphql<Response>(QUERY, { owner, name });
return data.repository?.mentionableUsers.totalCount ?? 0;
}
@@ -0,0 +1,52 @@
import {
EMPTY_PAGE,
type GithubPage,
githubGraphql,
} from 'src/modules/github/connector/github-client';
const QUERY = `
query($owner: String!, $name: String!, $cursor: String) {
repository(owner: $owner, name: $name) {
mentionableUsers(first: 100, after: $cursor) {
totalCount
pageInfo { hasNextPage endCursor }
nodes {
login
databaseId
avatarUrl
}
}
}
}`;
export type GqlContributor = {
login: string;
databaseId: number;
avatarUrl: string;
};
type Response = {
repository: { mentionableUsers: GithubPage<GqlContributor> } | null;
};
export async function fetchContributors(
owner: string,
name: string,
cursor: string | null = null,
): Promise<{
contributors: GqlContributor[];
totalCount: number;
hasMore: boolean;
endCursor: string | null;
}> {
const data = await githubGraphql<Response>(QUERY, { owner, name, cursor });
const conn = data.repository?.mentionableUsers;
if (!conn) return { contributors: [], ...EMPTY_PAGE };
return {
contributors: conn.nodes,
totalCount: conn.totalCount,
hasMore: conn.pageInfo.hasNextPage,
endCursor: conn.pageInfo.endCursor,
};
}
@@ -0,0 +1,21 @@
import type { ContributorRow } from 'src/modules/github/contributor/types/contributor-row';
import { chunkedBatchCreate } from 'src/modules/shared/twenty-client';
export async function batchUpsertContributors(
items: Array<{
ghLogin: string;
name: string;
githubId: number;
avatarUrl?: { primaryLinkLabel: string; primaryLinkUrl: string; secondaryLinks: null } | null;
contributions?: number;
}>,
): Promise<ContributorRow[]> {
return chunkedBatchCreate('createContributors', items, {
id: true,
ghLogin: true,
name: true,
githubId: true,
avatarUrl: true,
contributions: true,
}) as Promise<ContributorRow[]>;
}
@@ -0,0 +1,27 @@
import type { ContributorRow } from 'src/modules/github/contributor/types/contributor-row';
import { getClient } from 'src/modules/shared/twenty-client';
export async function findContributorByGhLogin(
ghLogin: string,
): Promise<ContributorRow | null> {
const client = getClient();
const res = await client.query({
contributors: {
__args: {
filter: { ghLogin: { eq: ghLogin } },
first: 1,
},
edges: {
node: {
id: true,
ghLogin: true,
name: true,
githubId: true,
},
},
},
});
const edges = res.contributors?.edges;
return (edges?.[0]?.node as ContributorRow | undefined) ?? null;
}
@@ -0,0 +1,52 @@
import { getClient } from 'src/modules/shared/twenty-client';
export type ContributorSearchResult = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: string | null;
};
export async function searchContributors(
query: string,
limit: number,
): Promise<ContributorSearchResult[]> {
const client = getClient();
const filter =
query.length === 0
? undefined
: {
or: [
{ name: { ilike: `%${query}%` } },
{ ghLogin: { ilike: `%${query}%` } },
],
};
const res = await client.query({
contributors: {
__args: {
...(filter ? { filter } : {}),
orderBy: [{ name: 'AscNullsLast' }],
first: limit,
},
edges: {
node: {
id: true,
name: true,
ghLogin: true,
avatarUrl: { primaryLinkUrl: true },
},
},
},
});
const edges = res.contributors?.edges ?? [];
return edges.map((e) => ({
id: e.node.id,
name: e.node.name ?? null,
ghLogin: e.node.ghLogin ?? null,
avatarUrl: e.node.avatarUrl?.primaryLinkUrl ?? null,
}));
}
@@ -0,0 +1,376 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { getClient } from 'src/modules/shared/twenty-client';
export type StatsPeriod = 'week' | 'month' | '3months' | 'year';
type Granularity = 'day' | 'week' | 'month';
type Bucket = {
key: string;
label: string;
start: string;
end: string;
prAuthored: number;
prMerged: number;
prReviewed: number;
};
type Totals = {
prAuthored: number;
prMerged: number;
prReviewed: number;
};
type ContributorInfo = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: string | null;
};
type ContributorStatsPayload = {
contributorId?: string;
period?: StatsPeriod;
};
type ContributorStatsResponse =
| {
contributor: ContributorInfo;
period: StatsPeriod;
granularity: Granularity;
buckets: Bucket[];
totals: Totals;
truncated: {
prAuthored: boolean;
prMerged: boolean;
prReviewed: boolean;
};
}
| { error: string };
const PAGE_SIZE = 100;
const MAX_PAGES = 30;
const PERIOD_CONFIG: Record<
StatsPeriod,
{ granularity: Granularity; rangeMs: number; bucketCount: number }
> = {
week: { granularity: 'day', rangeMs: 7 * 24 * 3600 * 1000, bucketCount: 7 },
month: { granularity: 'day', rangeMs: 30 * 24 * 3600 * 1000, bucketCount: 30 },
'3months': {
granularity: 'week',
rangeMs: 13 * 7 * 24 * 3600 * 1000,
bucketCount: 13,
},
year: {
granularity: 'month',
rangeMs: 365 * 24 * 3600 * 1000,
bucketCount: 12,
},
};
const startOfUtcDay = (d: Date): Date =>
new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
const startOfUtcWeek = (d: Date): Date => {
const day = startOfUtcDay(d);
const dow = day.getUTCDay();
const diff = (dow + 6) % 7;
day.setUTCDate(day.getUTCDate() - diff);
return day;
};
const startOfUtcMonth = (d: Date): Date =>
new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
const addBucket = (start: Date, granularity: Granularity, n: number): Date => {
const out = new Date(start);
if (granularity === 'day') out.setUTCDate(out.getUTCDate() + n);
else if (granularity === 'week') out.setUTCDate(out.getUTCDate() + 7 * n);
else out.setUTCMonth(out.getUTCMonth() + n);
return out;
};
const bucketStartFor = (d: Date, granularity: Granularity): Date => {
if (granularity === 'day') return startOfUtcDay(d);
if (granularity === 'week') return startOfUtcWeek(d);
return startOfUtcMonth(d);
};
const formatBucketLabel = (start: Date, granularity: Granularity): string => {
const month = start.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
if (granularity === 'month') {
return `${month} ${String(start.getUTCFullYear()).slice(2)}`;
}
return `${month} ${start.getUTCDate()}`;
};
const buildBuckets = (
now: Date,
period: StatsPeriod,
): { buckets: Bucket[]; rangeStart: Date; granularity: Granularity } => {
const { granularity, bucketCount } = PERIOD_CONFIG[period];
const lastBucketStart = bucketStartFor(now, granularity);
const firstBucketStart = addBucket(
lastBucketStart,
granularity,
-(bucketCount - 1),
);
const buckets: Bucket[] = [];
for (let i = 0; i < bucketCount; i++) {
const start = addBucket(firstBucketStart, granularity, i);
const end = addBucket(start, granularity, 1);
buckets.push({
key: start.toISOString(),
label: formatBucketLabel(start, granularity),
start: start.toISOString(),
end: end.toISOString(),
prAuthored: 0,
prMerged: 0,
prReviewed: 0,
});
}
return { buckets, rangeStart: firstBucketStart, granularity };
};
type Edge<T> = { node: T };
type Connection<T> = {
edges: Edge<T>[];
pageInfo: { hasNextPage: boolean; endCursor: string | null };
};
async function paginateUntil<T>(
fetchPage: (cursor: string | null) => Promise<Connection<T>>,
isOlderThanRange: (item: T) => boolean,
): Promise<{ items: T[]; truncated: boolean }> {
const items: T[] = [];
let cursor: string | null = null;
for (let page = 0; page < MAX_PAGES; page++) {
const conn = await fetchPage(cursor);
let stop = false;
for (const edge of conn.edges) {
if (isOlderThanRange(edge.node)) {
stop = true;
break;
}
items.push(edge.node);
}
if (stop) return { items, truncated: false };
if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor) {
return { items, truncated: false };
}
cursor = conn.pageInfo.endCursor;
}
return { items, truncated: true };
}
type MergedPrNode = { mergedAt: string | null };
type ReviewNode = { firstSubmittedAt: string | null };
const fetchContributorInfo = async (
contributorId: string,
): Promise<ContributorInfo | null> => {
const client = getClient();
const res = await client.query({
contributors: {
__args: { filter: { id: { eq: contributorId } }, first: 1 },
edges: {
node: {
id: true,
name: true,
ghLogin: true,
avatarUrl: { primaryLinkUrl: true },
},
},
},
});
const node = res.contributors?.edges?.[0]?.node;
if (!node) return null;
return {
id: node.id,
name: node.name ?? null,
ghLogin: node.ghLogin ?? null,
avatarUrl: node.avatarUrl?.primaryLinkUrl ?? null,
};
};
const handler = async (
event: RoutePayload<ContributorStatsPayload>,
): Promise<ContributorStatsResponse> => {
const contributorId = event.body?.contributorId;
const period: StatsPeriod = event.body?.period ?? 'month';
if (!contributorId) {
return { error: 'contributorId is required' };
}
if (!(period in PERIOD_CONFIG)) {
return { error: `Unsupported period: ${period}` };
}
const contributor = await fetchContributorInfo(contributorId);
if (!contributor) {
return { error: 'Contributor not found' };
}
const now = new Date();
const { buckets, rangeStart, granularity } = buildBuckets(now, period);
const rangeStartMs = rangeStart.getTime();
const bucketIndex = new Map<string, number>();
buckets.forEach((b, i) => bucketIndex.set(b.key, i));
const client = getClient();
const mergedResult = await paginateUntil<MergedPrNode>(
async (cursor) => {
const res = await client.query({
pullRequests: {
__args: {
filter: {
and: [
{ mergerId: { eq: contributorId } },
{ state: { eq: 'MERGED' } },
],
},
orderBy: [{ mergedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: { node: { mergedAt: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequests as Connection<MergedPrNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.mergedAt) return false;
return new Date(n.mergedAt).getTime() < rangeStartMs;
},
);
const authoredResult = await paginateUntil<MergedPrNode>(
async (cursor) => {
const res = await client.query({
pullRequests: {
__args: {
filter: {
and: [
{ authorId: { eq: contributorId } },
{ state: { eq: 'MERGED' } },
],
},
orderBy: [{ mergedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: { node: { mergedAt: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequests as Connection<MergedPrNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.mergedAt) return false;
return new Date(n.mergedAt).getTime() < rangeStartMs;
},
);
const reviewedResult = await paginateUntil<ReviewNode>(
async (cursor) => {
const res = await client.query({
pullRequestReviews: {
__args: {
filter: { reviewerId: { eq: contributorId } },
orderBy: [{ firstSubmittedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: { node: { firstSubmittedAt: true } },
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequestReviews as Connection<ReviewNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.firstSubmittedAt) return false;
return new Date(n.firstSubmittedAt).getTime() < rangeStartMs;
},
);
const totals: Totals = { prAuthored: 0, prMerged: 0, prReviewed: 0 };
for (const pr of authoredResult.items) {
if (!pr.mergedAt) continue;
const d = new Date(pr.mergedAt);
if (d.getTime() < rangeStartMs) continue;
const key = bucketStartFor(d, granularity).toISOString();
const idx = bucketIndex.get(key);
if (idx === undefined) continue;
buckets[idx].prAuthored++;
totals.prAuthored++;
}
for (const pr of mergedResult.items) {
if (!pr.mergedAt) continue;
const d = new Date(pr.mergedAt);
if (d.getTime() < rangeStartMs) continue;
const key = bucketStartFor(d, granularity).toISOString();
const idx = bucketIndex.get(key);
if (idx === undefined) continue;
buckets[idx].prMerged++;
totals.prMerged++;
}
for (const r of reviewedResult.items) {
if (!r.firstSubmittedAt) continue;
const d = new Date(r.firstSubmittedAt);
if (d.getTime() < rangeStartMs) continue;
const key = bucketStartFor(d, granularity).toISOString();
const idx = bucketIndex.get(key);
if (idx === undefined) continue;
buckets[idx].prReviewed++;
totals.prReviewed++;
}
return {
contributor,
period,
granularity,
buckets,
totals,
truncated: {
prAuthored: authoredResult.truncated,
prMerged: mergedResult.truncated,
prReviewed: reviewedResult.truncated,
},
};
};
export default defineLogicFunction({
universalIdentifier: 'a3c9e1b6-2f47-4d8a-9b0f-7e6d1a2c3b4f',
name: 'contributor-stats',
description:
'Returns time-bucketed counts of PRs authored (merged only), merged and reviewed by a contributor over the selected period.',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/contributors/stats',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,28 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { countAcrossRepos } from 'src/modules/github/connector/count-across-repos';
import { countContributors } from 'src/modules/github/contributor/graphql/github/count-contributors';
type CountContributorsPayload = {
repos?: string[];
};
const handler = async (event: RoutePayload<CountContributorsPayload>) =>
countAcrossRepos(
event.body?.repos,
countContributors,
'count-contributors',
);
export default defineLogicFunction({
universalIdentifier: 'fe0a6f00-0d63-4cb9-9b3c-1d8186181830',
name: 'count-contributors',
description:
'Counts contributors across configured repos and returns the per-repo page split.',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/github/count-contributors',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,70 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import {
fetchContributors,
type GqlContributor,
} from 'src/modules/github/contributor/graphql/github/fetch-contributors';
import { batchUpsertContributors } from 'src/modules/github/contributor/graphql/mutations/batch-upsert';
import { isFixtureAllowed } from 'src/modules/shared/fixtures';
export type FetchContributorsFixturePage = {
contributors: GqlContributor[];
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
type FetchContributorsPayload = {
owner: string;
repo: string;
cursor?: string | null;
fixturePage?: FetchContributorsFixturePage;
};
const handler = async (event: RoutePayload<FetchContributorsPayload>) => {
const { owner, repo, cursor = null, fixturePage } = event.body ?? {};
if (!owner || !repo) {
return { error: 'owner and repo are required' };
}
const result =
fixturePage && isFixtureAllowed()
? fixturePage
: await fetchContributors(owner, repo, cursor);
const { contributors, totalCount, hasMore, endCursor } = result;
if (contributors.length === 0) {
return { contributorCount: 0, totalCount, hasMore: false, endCursor: null };
}
const contributorData = contributors.map((c: GqlContributor) => ({
ghLogin: c.login,
name: c.login,
githubId: c.databaseId ?? 0,
avatarUrl: c.avatarUrl
? { primaryLinkLabel: c.login, primaryLinkUrl: c.avatarUrl, secondaryLinks: null }
: null,
}));
await batchUpsertContributors(contributorData);
return {
contributorCount: contributors.length,
totalCount,
hasMore,
endCursor,
};
};
export default defineLogicFunction({
universalIdentifier: 'ed9ba981-4172-4924-a18f-51fc6dcbcb23',
name: 'fetch-contributors',
description:
'Fetches one page of contributors via GitHub GraphQL API and batch upserts them into the workspace.',
timeoutSeconds: 300,
handler,
httpRouteTriggerSettings: {
path: '/github/fetch-contributors',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,43 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import {
searchContributors,
type ContributorSearchResult,
} from 'src/modules/github/contributor/graphql/queries/search-contributors';
type SearchContributorsPayload = {
query?: string;
limit?: number;
};
const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 50;
const handler = async (
event: RoutePayload<SearchContributorsPayload>,
): Promise<{ contributors: ContributorSearchResult[] }> => {
const queryInput = event.body?.query;
const rawQuery = typeof queryInput === 'string' ? queryInput.trim() : '';
const limitInput = event.body?.limit;
const limitNumber =
typeof limitInput === 'number' && Number.isFinite(limitInput)
? limitInput
: DEFAULT_LIMIT;
const limit = Math.min(Math.max(Math.floor(limitNumber), 1), MAX_LIMIT);
const contributors = await searchContributors(rawQuery, limit);
return { contributors };
};
export default defineLogicFunction({
universalIdentifier: 'b4d8f2a7-3e58-4f9b-ac1d-8f7e2b3c4d5e',
name: 'search-contributors',
description:
'Searches contributors by name or GitHub login for use in front-end pickers.',
timeoutSeconds: 15,
handler,
httpRouteTriggerSettings: {
path: '/contributors/search',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,257 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { isBotLogin } from 'src/modules/github/contributor/utils/is-bot-login';
import { getClient } from 'src/modules/shared/twenty-client';
type ContributorRef = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: string | null;
};
type LeaderboardEntry = ContributorRef & {
count: number;
};
type TopContributorsKind = 'authors' | 'reviewers' | 'both';
type TopContributorsPayload = {
days?: number;
limit?: number;
kind?: TopContributorsKind;
};
type TopContributorsResponse = {
days: number;
limit: number;
topAuthors: LeaderboardEntry[];
topReviewers: LeaderboardEntry[];
truncated: { authors: boolean; reviewers: boolean };
};
const PAGE_SIZE = 100;
const MAX_PAGES = 50;
const DEFAULT_DAYS = 90;
const DEFAULT_LIMIT = 10;
const MAX_LIMIT = 50;
const MAX_DAYS = 365;
type Edge<T> = { node: T };
type Connection<T> = {
edges: Edge<T>[];
pageInfo: { hasNextPage: boolean; endCursor: string | null };
};
async function paginateUntil<T>(
fetchPage: (cursor: string | null) => Promise<Connection<T>>,
isOlderThanRange: (item: T) => boolean,
): Promise<{ items: T[]; truncated: boolean }> {
const items: T[] = [];
let cursor: string | null = null;
for (let page = 0; page < MAX_PAGES; page++) {
const conn = await fetchPage(cursor);
let stop = false;
for (const edge of conn.edges) {
if (isOlderThanRange(edge.node)) {
stop = true;
break;
}
items.push(edge.node);
}
if (stop) return { items, truncated: false };
if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor) {
return { items, truncated: false };
}
cursor = conn.pageInfo.endCursor;
}
return { items, truncated: true };
}
type AuthorInfo = {
id: string;
name: string | null;
ghLogin: string | null;
avatarUrl: { primaryLinkUrl: string | null } | null;
};
type PrNode = {
githubCreatedAt: string | null;
author: AuthorInfo | null;
};
type ReviewNode = {
firstSubmittedAt: string | null;
reviewer: AuthorInfo | null;
};
const tally = (
items: { contributor: AuthorInfo | null }[],
limit: number,
): LeaderboardEntry[] => {
const counts = new Map<string, LeaderboardEntry>();
for (const item of items) {
const c = item.contributor;
if (!c) continue;
if (isBotLogin(c.ghLogin)) continue;
const existing = counts.get(c.id);
if (existing) {
existing.count += 1;
} else {
counts.set(c.id, {
id: c.id,
name: c.name ?? null,
ghLogin: c.ghLogin ?? null,
avatarUrl: c.avatarUrl?.primaryLinkUrl ?? null,
count: 1,
});
}
}
return Array.from(counts.values())
.sort((a, b) => b.count - a.count || (a.ghLogin ?? '').localeCompare(b.ghLogin ?? ''))
.slice(0, limit);
};
const handler = async (
event: RoutePayload<TopContributorsPayload>,
): Promise<TopContributorsResponse> => {
const daysInput = event.body?.days;
const limitInput = event.body?.limit;
const days = Math.min(
Math.max(
Math.floor(
typeof daysInput === 'number' && Number.isFinite(daysInput)
? daysInput
: DEFAULT_DAYS,
),
1,
),
MAX_DAYS,
);
const limit = Math.min(
Math.max(
Math.floor(
typeof limitInput === 'number' && Number.isFinite(limitInput)
? limitInput
: DEFAULT_LIMIT,
),
1,
),
MAX_LIMIT,
);
const kindInput = event.body?.kind;
const kind: TopContributorsKind =
kindInput === 'authors' || kindInput === 'reviewers' ? kindInput : 'both';
const sinceMs = Date.now() - days * 24 * 3600 * 1000;
const client = getClient();
const authoredResult: { items: PrNode[]; truncated: boolean } =
kind === 'reviewers' ? { items: [], truncated: false } : await paginateUntil<PrNode>(
async (cursor) => {
const res = await client.query({
pullRequests: {
__args: {
orderBy: [{ githubCreatedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: {
node: {
githubCreatedAt: true,
author: {
id: true,
name: true,
ghLogin: true,
avatarUrl: { primaryLinkUrl: true },
},
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequests as Connection<PrNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.githubCreatedAt) return false;
return new Date(n.githubCreatedAt).getTime() < sinceMs;
},
);
const reviewedResult: { items: ReviewNode[]; truncated: boolean } =
kind === 'authors' ? { items: [], truncated: false } : await paginateUntil<ReviewNode>(
async (cursor) => {
const res = await client.query({
pullRequestReviews: {
__args: {
orderBy: [{ firstSubmittedAt: 'DescNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: {
node: {
firstSubmittedAt: true,
reviewer: {
id: true,
name: true,
ghLogin: true,
avatarUrl: { primaryLinkUrl: true },
},
},
},
pageInfo: { hasNextPage: true, endCursor: true },
},
});
return (
(res.pullRequestReviews as Connection<ReviewNode>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
}
);
},
(n) => {
if (!n.firstSubmittedAt) return false;
return new Date(n.firstSubmittedAt).getTime() < sinceMs;
},
);
const topAuthors = tally(
authoredResult.items.map((pr) => ({ contributor: pr.author })),
limit,
);
const topReviewers = tally(
reviewedResult.items.map((r) => ({ contributor: r.reviewer })),
limit,
);
return {
days,
limit,
topAuthors,
topReviewers,
truncated: {
authors: authoredResult.truncated,
reviewers: reviewedResult.truncated,
},
};
};
export default defineLogicFunction({
universalIdentifier: 'd5b9c4a2-7e3f-4a1b-9c8d-2f4e6a7b8c9d',
name: 'top-contributors',
description:
'Returns the top contributors by pull-request authorship and review counts over a configurable time window.',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/contributors/top',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,47 @@
import { type LinksFieldValue, toLinksField } from 'src/modules/shared/types';
export type GhUser = {
login: string;
id?: number;
databaseId?: number;
avatarUrl?: string | null;
avatar_url?: string | null;
};
export type ContributorCanonical = {
ghLogin: string;
name: string;
githubId: number;
avatarUrl: LinksFieldValue | null;
};
export function contributorFromGhUser(user: GhUser): ContributorCanonical {
const avatarUrl = user.avatarUrl ?? user.avatar_url ?? null;
return {
ghLogin: user.login,
name: user.login,
githubId: user.id ?? user.databaseId ?? 0,
avatarUrl: avatarUrl ? toLinksField(avatarUrl, user.login) : null,
};
}
export function dedupeContributors(
users: Array<GhUser | null | undefined>,
): ContributorCanonical[] {
const seen = new Map<string, ContributorCanonical>();
for (const u of users) {
if (!u || !u.login) continue;
const existing = seen.get(u.login);
if (!existing) {
seen.set(u.login, contributorFromGhUser(u));
continue;
}
if (!existing.avatarUrl) {
const avatarUrl = u.avatarUrl ?? u.avatar_url ?? null;
if (avatarUrl) {
existing.avatarUrl = toLinksField(avatarUrl, u.login);
}
}
}
return [...seen.values()];
}
@@ -0,0 +1,72 @@
import { defineObject, FieldType } from 'twenty-sdk/define';
export const CONTRIBUTOR_UNIVERSAL_IDENTIFIER =
'8e9464f0-fdc9-487a-9963-c7accac0d4bb';
export const CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'e2e77e0e-5300-44ce-ab1c-9ad0593b5d1a';
export const CONTRIBUTOR_GH_LOGIN_FIELD_UNIVERSAL_IDENTIFIER =
'a1d3c7b2-4e5f-4a8b-9c6d-2e1f0b3a4c5d';
export const CONTRIBUTOR_GITHUB_ID_FIELD_UNIVERSAL_IDENTIFIER =
'f7a8b9c0-1d2e-4f3a-8b5c-6d7e8f9a0b1c';
export const CONTRIBUTOR_AVATAR_URL_FIELD_UNIVERSAL_IDENTIFIER =
'450a612c-f7a3-4bba-aa36-e22339eb0720';
export const CONTRIBUTOR_CONTRIBUTIONS_FIELD_UNIVERSAL_IDENTIFIER =
'66f73e49-6edb-48fa-812b-c3e684ef6340';
export const CONTRIBUTOR_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'bb8d2a87-b41c-578b-98de-2e37dba12a14';
export default defineObject({
universalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
nameSingular: 'contributor',
namePlural: 'contributors',
labelSingular: 'Contributor',
labelPlural: 'Contributors',
icon: 'IconUsers',
labelIdentifierFieldMetadataUniversalIdentifier:
CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER,
name: 'name',
type: FieldType.TEXT,
label: 'Name',
icon: 'IconUser',
},
{
universalIdentifier: CONTRIBUTOR_GH_LOGIN_FIELD_UNIVERSAL_IDENTIFIER,
name: 'ghLogin',
type: FieldType.TEXT,
label: 'GitHub Login',
icon: 'IconBrandGithub',
isUnique: true,
},
{
universalIdentifier: CONTRIBUTOR_GITHUB_ID_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubId',
type: FieldType.NUMBER,
label: 'GitHub ID',
icon: 'IconHash',
},
{
universalIdentifier: CONTRIBUTOR_AVATAR_URL_FIELD_UNIVERSAL_IDENTIFIER,
name: 'avatarUrl',
type: FieldType.LINKS,
label: 'Avatar',
icon: 'IconPhoto',
},
{
universalIdentifier: CONTRIBUTOR_CONTRIBUTIONS_FIELD_UNIVERSAL_IDENTIFIER,
name: 'contributions',
type: FieldType.NUMBER,
label: 'Contributions',
icon: 'IconTrendingUp',
defaultValue: 0,
},
],
});
@@ -0,0 +1,10 @@
import type { LinksFieldValue } from 'src/modules/shared/types';
export type ContributorRow = {
id: string;
ghLogin?: string | null;
name?: string | null;
githubId?: number | null;
avatarUrl?: LinksFieldValue | null;
contributions?: number | null;
};
@@ -0,0 +1,30 @@
const BOT_LOGIN_SUFFIX = '[bot]';
const KNOWN_BOT_LOGINS = new Set<string>([
'github-actions',
'dependabot',
'dependabot-preview',
'renovate',
'renovate-bot',
'cubic-dev-ai',
'greptile-apps',
'sentry',
'sentry-io',
'codecov',
'codecov-commenter',
'snyk-bot',
'mergify',
'allcontributors',
'imgbot',
'pre-commit-ci',
'semantic-release-bot',
'stale',
'web-flow',
]);
export function isBotLogin(login: string | null | undefined): boolean {
if (!login) return false;
const lower = login.toLowerCase();
if (lower.endsWith(BOT_LOGIN_SUFFIX)) return true;
return KNOWN_BOT_LOGINS.has(lower);
}
@@ -0,0 +1,51 @@
import {
defineView,
ViewKey,
ViewSortDirection,
ViewType,
} from 'twenty-sdk/define';
import {
CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER,
CONTRIBUTOR_GH_LOGIN_FIELD_UNIVERSAL_IDENTIFIER,
CONTRIBUTOR_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/contributor/objects/contributor.object';
export const ALL_CONTRIBUTORS_VIEW_UNIVERSAL_IDENTIFIER =
'e2b51f8e-97ea-49e0-b38d-a69d2191236f';
export default defineView({
universalIdentifier: ALL_CONTRIBUTORS_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All Contributors',
objectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconUsers',
key: ViewKey.INDEX,
position: 0,
fields: [
{
universalIdentifier: 'b53cb9dd-0e2e-415e-81af-80bedbed89bc',
fieldMetadataUniversalIdentifier:
CONTRIBUTOR_NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 200,
},
{
universalIdentifier: '604a2f1d-ef20-4821-8742-fe3b5b83ffcf',
fieldMetadataUniversalIdentifier:
CONTRIBUTOR_GH_LOGIN_FIELD_UNIVERSAL_IDENTIFIER,
position: 1,
isVisible: true,
size: 150,
},
],
sorts: [
{
universalIdentifier: 'c0c54264-8de9-418b-b91a-cc4a2b94c539',
fieldMetadataUniversalIdentifier:
CONTRIBUTOR_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
direction: ViewSortDirection.DESC,
},
],
});
@@ -0,0 +1,25 @@
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';
import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/fields/authored-issues-on-contributor.field';
export const ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER =
'5ff7ab7f-bcfa-4e7a-9a33-0860ec692736';
export default defineField({
universalIdentifier: ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'author',
label: 'Author',
icon: 'IconUser',
relationTargetObjectMetadataUniversalIdentifier:
CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
AUTHORED_ISSUES_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'authorId',
},
});
@@ -0,0 +1,23 @@
import { defineField, FieldType, RelationType } from 'twenty-sdk/define';
import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object';
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
import { LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/linked-issue-on-project-item.field';
export const PROJECT_ITEMS_ON_ISSUE_FIELD_UNIVERSAL_IDENTIFIER =
'8b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e';
export default defineField({
universalIdentifier: PROJECT_ITEMS_ON_ISSUE_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'projectItems',
label: 'Project Items',
icon: 'IconLayoutKanban',
relationTargetObjectMetadataUniversalIdentifier:
PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
},
});
@@ -0,0 +1,109 @@
import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
objectMetadataItem,
unmountFrontComponent,
updateProgress,
} from 'twenty-sdk/front-component';
import { callAppRoute } from 'src/modules/shared/call-app-route';
import { retry } from 'src/modules/shared/retry';
type CountResponse = {
totalPages: number;
repos: Array<{ owner: string; repo: string; totalCount: number; pages: number }>;
};
type FetchPageResponse = {
issueCount: number;
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
type SyncStatus = 'syncing' | 'done' | 'error';
const FetchIssues = () => {
const [status, setStatus] = useState<SyncStatus>('syncing');
useEffect(() => {
const run = async () => {
try {
const counts = (await callAppRoute(
'/github/count-issues',
{},
)) as CountResponse;
if (counts.repos.length === 0) {
throw new Error(
'No repos resolved. Set GITHUB_REPOS in the application variables.',
);
}
const totalPages = Math.max(counts.totalPages, 1);
let pagesProcessed = 0;
let totalIssues = 0;
for (const { owner, repo } of counts.repos) {
let cursor: string | null = null;
let hasMore = true;
while (hasMore) {
const cursorTag = cursor ? `@${cursor.slice(0, 8)}` : '';
const data = (await retry(
`fetch-issues ${owner}/${repo}${cursorTag}`,
() =>
callAppRoute('/github/fetch-issues', {
owner,
repo,
cursor,
}),
)) as FetchPageResponse;
totalIssues += data.issueCount;
hasMore = data.hasMore && data.issueCount > 0;
cursor = data.endCursor;
pagesProcessed++;
updateProgress(Math.min(Math.round((pagesProcessed / totalPages) * 100), 99));
}
}
updateProgress(100);
enqueueSnackbar({
message: `Fetched ${totalIssues} issues`,
variant: 'success',
});
setStatus('done');
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to fetch issues';
enqueueSnackbar({ message, variant: 'error' });
setStatus('error');
} finally {
unmountFrontComponent();
}
};
run();
}, []);
if (status === 'syncing') return <div>Fetching issues...</div>;
if (status === 'error') return <div>Failed to fetch issues.</div>;
return <div>Done</div>;
};
export default defineFrontComponent({
universalIdentifier: '9430e4fc-9ecb-428d-9bde-2babeb1f452f',
name: 'Fetch Issues',
description: 'Fetches issues from GitHub repos',
isHeadless: true,
component: FetchIssues,
command: {
universalIdentifier: 'c34f56aa-ff65-43a4-9db2-774945dbcc53',
label: 'Fetch Issues',
icon: 'IconBug',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'issue',
},
});
@@ -0,0 +1,26 @@
import { githubGraphql } from 'src/modules/github/connector/github-client';
const QUERY = `
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
issues(states: [OPEN, CLOSED]) {
totalCount
}
}
}`;
type Response = {
repository: { issues: { totalCount: number } } | null;
};
export async function countIssues(
owner: string,
name: string,
): Promise<number> {
try {
const data = await githubGraphql<Response>(QUERY, { owner, name });
return data.repository?.issues.totalCount ?? 0;
} catch {
return 0;
}
}
@@ -0,0 +1,66 @@
import {
EMPTY_PAGE,
type GithubPage,
githubGraphql,
} from 'src/modules/github/connector/github-client';
const QUERY = `
query($owner: String!, $name: String!, $cursor: String) {
repository(owner: $owner, name: $name) {
issues(first: 100, states: [OPEN, CLOSED], orderBy: {field: CREATED_AT, direction: DESC}, after: $cursor) {
totalCount
pageInfo { hasNextPage endCursor }
nodes {
number
title
url
state
createdAt
closedAt
author { login avatarUrl ... on User { databaseId } }
labels(first: 50) { nodes { name } }
}
}
}
}`;
export type GqlIssue = {
number: number;
title: string;
url: string;
state: 'OPEN' | 'CLOSED';
createdAt: string;
closedAt: string | null;
author: { login: string; avatarUrl?: string | null; databaseId?: number } | null;
labels: { nodes: Array<{ name: string }> };
};
type Response = {
repository: { issues: GithubPage<GqlIssue> } | null;
};
export async function fetchIssues(
owner: string,
name: string,
cursor: string | null = null,
): Promise<{
issues: GqlIssue[];
totalCount: number;
hasMore: boolean;
endCursor: string | null;
}> {
try {
const data = await githubGraphql<Response>(QUERY, { owner, name, cursor });
const conn = data.repository?.issues;
if (!conn) return { issues: [], ...EMPTY_PAGE };
return {
issues: conn.nodes,
totalCount: conn.totalCount,
hasMore: conn.pageInfo.hasNextPage,
endCursor: conn.pageInfo.endCursor,
};
} catch {
return { issues: [], ...EMPTY_PAGE };
}
}
@@ -0,0 +1,27 @@
import { chunkedBatchCreate } from 'src/modules/shared/twenty-client';
import type { LinksFieldValue } from 'src/modules/shared/types';
import type { IssueRow } from 'src/modules/github/issue/types/issue-row';
export async function batchUpsertIssues(
items: Array<{
title: string;
githubNumber: number;
uniqueIdentifier: string;
githubUrl: LinksFieldValue;
state: string;
labels: string[];
githubCreatedAt: string | null;
closedAt: string | null;
repo: string;
authorId: string | null;
}>,
): Promise<IssueRow[]> {
return chunkedBatchCreate('createIssues', items, {
id: true,
githubNumber: true,
uniqueIdentifier: true,
title: true,
state: true,
repo: true,
}) as Promise<IssueRow[]>;
}
@@ -0,0 +1,40 @@
import { getClient } from 'src/modules/shared/twenty-client';
import type { IssueRow } from 'src/modules/github/issue/types/issue-row';
export async function findIssueByNumberAndRepo(
githubNumber: number,
repo: string,
): Promise<IssueRow | null> {
const client = getClient();
const res = await client.query({
issues: {
__args: {
filter: {
and: [
{ githubNumber: { eq: githubNumber } },
{ repo: { eq: repo } },
],
},
first: 1,
},
edges: {
node: {
id: true,
title: true,
githubNumber: true,
uniqueIdentifier: true,
githubUrl: { primaryLinkLabel: true, primaryLinkUrl: true },
state: true,
labels: true,
githubCreatedAt: true,
closedAt: true,
repo: true,
authorId: true,
},
},
},
});
const node = res.issues?.edges?.[0]?.node;
return (node as IssueRow | undefined) ?? null;
}
@@ -0,0 +1,24 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { countAcrossRepos } from 'src/modules/github/connector/count-across-repos';
import { countIssues } from 'src/modules/github/issue/graphql/github/count-issues';
type CountIssuesPayload = {
repos?: string[];
};
const handler = async (event: RoutePayload<CountIssuesPayload>) =>
countAcrossRepos(event.body?.repos, countIssues, 'count-issues');
export default defineLogicFunction({
universalIdentifier: 'd8cc32bf-6be9-44fc-920a-8bba510f045f',
name: 'count-issues',
description:
'Counts total issue pages across configured repos using GraphQL totalCount',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/github/count-issues',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,93 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import {
fetchIssues,
type GqlIssue,
} from 'src/modules/github/issue/graphql/github/fetch-issues';
import { batchUpsertContributors } from 'src/modules/github/contributor/graphql/mutations/batch-upsert';
import { batchUpsertIssues } from 'src/modules/github/issue/graphql/mutations/batch-upsert';
import { dedupeContributors } from 'src/modules/github/contributor/normalizers';
import { issueFromGraphql } from 'src/modules/github/issue/normalizers';
import { timed } from 'src/modules/shared/timing';
import { isFixtureAllowed } from 'src/modules/shared/fixtures';
export type FetchIssuesFixturePage = {
issues: GqlIssue[];
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
type FetchIssuesPayload = {
owner: string;
repo: string;
cursor?: string | null;
fixturePage?: FetchIssuesFixturePage;
};
const handler = async (event: RoutePayload<FetchIssuesPayload>) => {
const handlerStart = Date.now();
const { owner, repo, cursor = null, fixturePage } = event.body ?? {};
if (!owner || !repo) {
return { error: 'owner and repo are required' };
}
const tag = `${owner}/${repo}${cursor ? `@${cursor.slice(0, 8)}` : ''}`;
console.log(`[fetch-issues] start ${tag}${fixturePage ? ' (fixture)' : ''}`);
const result =
fixturePage && isFixtureAllowed()
? fixturePage
: await timed(`fetch-issues:github ${tag}`, () =>
fetchIssues(owner, repo, cursor),
);
const { issues, totalCount, hasMore, endCursor } = result;
if (issues.length === 0) {
console.log(`[fetch-issues] empty page for ${tag}`);
return { issueCount: 0, totalCount, hasMore: false, endCursor: null };
}
const fullRepo = `${owner}/${repo}`;
const contributorInputs = dedupeContributors(issues.map((i) => i.author));
const contributors = await timed(
`fetch-issues:upsertContributors ${tag} (${contributorInputs.length})`,
() => batchUpsertContributors(contributorInputs),
);
const idByLogin = new Map<string, string>();
for (const c of contributors) {
if (c.ghLogin) idByLogin.set(c.ghLogin, c.id);
}
const issueData = issues.map((issue) => ({
...issueFromGraphql(issue, fullRepo),
authorId: issue.author ? (idByLogin.get(issue.author.login) ?? null) : null,
}));
await timed(
`fetch-issues:upsertIssues ${tag} (${issueData.length})`,
() => batchUpsertIssues(issueData),
);
const totalMs = Date.now() - handlerStart;
console.log(
`[fetch-issues] done ${tag} in ${totalMs}ms (issues=${issues.length}, hasMore=${hasMore})`,
);
return { issueCount: issues.length, totalCount, hasMore, endCursor };
};
export default defineLogicFunction({
universalIdentifier: 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e',
name: 'fetch-issues',
description:
'Fetches one page of issues via GitHub GraphQL API and batch upserts them',
timeoutSeconds: 300,
handler,
httpRouteTriggerSettings: {
path: '/github/fetch-issues',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,11 @@
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object';
import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: '7c4f8e1a-3b9d-4f2e-8a6c-1d5b7e9f3a2c',
position: 1,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,63 @@
import type { LinksFieldValue } from 'src/modules/shared/types';
import { toLinksField } from 'src/modules/shared/types';
import type { GitHubIssue } from 'src/modules/github/issue/types/github-issue';
import type { GqlIssue } from 'src/modules/github/issue/graphql/github/fetch-issues';
export type IssueState = 'OPEN' | 'CLOSED';
export type IssueCanonical = {
title: string;
githubNumber: number;
uniqueIdentifier: string;
githubUrl: LinksFieldValue;
state: IssueState;
labels: string[];
githubCreatedAt: string | null;
closedAt: string | null;
repo: string;
};
export function deriveIssueState(state: string): IssueState {
return state.toUpperCase() === 'CLOSED' ? 'CLOSED' : 'OPEN';
}
export function buildIssueUniqueIdentifier(
repoFullName: string,
number: number,
): string {
return `${repoFullName}#${number}`;
}
export function issueFromWebhook(
issue: GitHubIssue,
repoFullName: string,
): IssueCanonical {
return {
title: issue.title,
githubNumber: issue.number,
uniqueIdentifier: buildIssueUniqueIdentifier(repoFullName, issue.number),
githubUrl: toLinksField(issue.html_url, issue.title),
state: deriveIssueState(issue.state),
labels: issue.labels.map((l) => l.name),
githubCreatedAt: issue.created_at,
closedAt: issue.closed_at,
repo: repoFullName,
};
}
export function issueFromGraphql(
issue: GqlIssue,
repoFullName: string,
): IssueCanonical {
return {
title: issue.title,
githubNumber: issue.number,
uniqueIdentifier: buildIssueUniqueIdentifier(repoFullName, issue.number),
githubUrl: toLinksField(issue.url, issue.title),
state: deriveIssueState(issue.state),
labels: issue.labels.nodes.map((l) => l.name),
githubCreatedAt: issue.createdAt,
closedAt: issue.closedAt,
repo: repoFullName,
};
}
@@ -0,0 +1,134 @@
import { defineObject, FieldType } from 'twenty-sdk/define';
export const ISSUE_UNIVERSAL_IDENTIFIER =
'3a4b5c6d-7e8f-4a9b-8c0d-1e2f3a4b5c6d';
export const ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER =
'4b5c6d7e-8f9a-4b0c-9d1e-2f3a4b5c6d7e';
export const ISSUE_GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER =
'5c6d7e8f-9a0b-4c1d-ae2f-3a4b5c6d7e8f';
export const ISSUE_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER =
'6d7e8f9a-0b1c-4d2e-bf3a-4b5c6d7e8f9a';
export const ISSUE_LABELS_FIELD_UNIVERSAL_IDENTIFIER =
'dfd35d92-f4a3-44c5-b758-494c0882fcc8';
export const ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'76364df9-6f5c-477d-bd99-97b6738df3bf';
export const ISSUE_CLOSED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'48ac555b-73b7-4ce9-a264-251c8e06b4e9';
export const ISSUE_REPO_FIELD_UNIVERSAL_IDENTIFIER =
'79c41cdc-912a-4e4d-b9de-3f2421bcb2fb';
export const ISSUE_UNIQUE_IDENTIFIER_FIELD_UNIVERSAL_IDENTIFIER =
'0ca04207-4d74-4298-8238-59cdf685862b';
enum IssueState {
OPEN = 'OPEN',
CLOSED = 'CLOSED',
}
export const ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER =
'1c2d3e4f-5a6b-4c7d-a48e-9f0a1b2c3d4e';
export const ISSUE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'440e9ba8-20d1-5843-91ef-3b7b699a59f4';
export default defineObject({
universalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
nameSingular: 'issue',
namePlural: 'issues',
labelSingular: 'Issue',
labelPlural: 'Issues',
icon: 'IconBug',
labelIdentifierFieldMetadataUniversalIdentifier:
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
name: 'title',
type: FieldType.TEXT,
label: 'Title',
icon: 'IconTextCaption',
},
{
universalIdentifier: ISSUE_GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubNumber',
type: FieldType.NUMBER,
label: 'Issue Number',
icon: 'IconHash',
},
{
universalIdentifier: ISSUE_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubUrl',
type: FieldType.LINKS,
label: 'GitHub URL',
icon: 'IconLink',
},
{
universalIdentifier: ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER,
name: 'state',
type: FieldType.SELECT,
label: 'State',
icon: 'IconCircleDot',
options: [
{
value: IssueState.OPEN,
label: 'Open',
position: 0,
color: 'green',
},
{
value: IssueState.CLOSED,
label: 'Closed',
position: 1,
color: 'red',
},
],
},
{
universalIdentifier: ISSUE_LABELS_FIELD_UNIVERSAL_IDENTIFIER,
name: 'labels',
type: FieldType.ARRAY,
label: 'Labels',
icon: 'IconTag',
},
{
universalIdentifier: ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubCreatedAt',
type: FieldType.DATE_TIME,
label: 'GitHub Created At',
icon: 'IconCalendarPlus',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: ISSUE_CLOSED_AT_FIELD_UNIVERSAL_IDENTIFIER,
name: 'closedAt',
type: FieldType.DATE_TIME,
label: 'Closed At',
icon: 'IconCalendarOff',
isNullable: true,
defaultValue: null,
},
{
universalIdentifier: ISSUE_REPO_FIELD_UNIVERSAL_IDENTIFIER,
name: 'repo',
type: FieldType.TEXT,
label: 'Repository',
icon: 'IconFolder',
},
{
universalIdentifier: ISSUE_UNIQUE_IDENTIFIER_FIELD_UNIVERSAL_IDENTIFIER,
name: 'uniqueIdentifier',
type: FieldType.TEXT,
label: 'Unique Identifier',
icon: 'IconFingerprint',
isUnique: true,
},
],
});
@@ -0,0 +1,13 @@
import type { GitHubUser } from 'src/modules/github/connector/github-user';
export type GitHubIssue = {
number: number;
title: string;
html_url: string;
state: 'open' | 'closed';
labels: Array<{ name: string }>;
created_at: string;
closed_at: string | null;
user: GitHubUser;
pull_request?: unknown;
};
@@ -0,0 +1,15 @@
import type { LinksFieldValue } from 'src/modules/shared/types';
export type IssueRow = {
id: string;
title?: string | null;
githubNumber?: number | null;
uniqueIdentifier?: string | null;
githubUrl?: LinksFieldValue | null;
state?: string | null;
labels?: string[] | null;
githubCreatedAt?: string | null;
closedAt?: string | null;
repo?: string | null;
authorId?: string | null;
};
@@ -0,0 +1,87 @@
import {
defineView,
ViewKey,
ViewSortDirection,
ViewType,
} from 'twenty-sdk/define';
import {
ISSUE_UNIVERSAL_IDENTIFIER,
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_LABELS_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_REPO_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/issue/objects/issue.object';
import { ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/fields/author-on-issue.field';
export const ALL_ISSUES_VIEW_UNIVERSAL_IDENTIFIER =
'b2c3d4e5-1111-4f6a-b7c8-d9e0f1a2b3c4';
export default defineView({
universalIdentifier: ALL_ISSUES_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All Issues',
objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconBug',
key: ViewKey.INDEX,
position: 0,
fields: [
{
universalIdentifier: '5b115bd1-489c-44e5-ac1b-7d508e06f165',
fieldMetadataUniversalIdentifier:
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 300,
},
{
universalIdentifier: '77ac1882-3a13-43e3-822e-c9f5f7d83a67',
fieldMetadataUniversalIdentifier:
ISSUE_GITHUB_NUMBER_FIELD_UNIVERSAL_IDENTIFIER,
position: 1,
isVisible: true,
size: 100,
},
{
universalIdentifier: '4f0d17f2-c3a6-4431-a928-3812273592e2',
fieldMetadataUniversalIdentifier:
ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER,
position: 2,
isVisible: true,
size: 100,
},
{
universalIdentifier: '01673163-ccfa-4370-a1c3-48c33b6c4aa3',
fieldMetadataUniversalIdentifier:
ISSUE_LABELS_FIELD_UNIVERSAL_IDENTIFIER,
position: 3,
isVisible: true,
size: 200,
},
{
universalIdentifier: '6d932c97-8f92-4d93-968f-29ca34b27ac0',
fieldMetadataUniversalIdentifier:
ISSUE_REPO_FIELD_UNIVERSAL_IDENTIFIER,
position: 4,
isVisible: true,
size: 180,
},
{
universalIdentifier: 'dc293fd6-747c-4291-9426-5d7b53234936',
fieldMetadataUniversalIdentifier:
ISSUE_AUTHOR_FIELD_UNIVERSAL_IDENTIFIER,
position: 5,
isVisible: true,
size: 150,
},
],
sorts: [
{
universalIdentifier: '8e972333-b919-4c1b-ad7b-4fab8f018253',
fieldMetadataUniversalIdentifier:
ISSUE_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
direction: ViewSortDirection.DESC,
},
],
});
@@ -0,0 +1,260 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import type { GitHubWebhookPayload } from 'src/modules/github/connector/webhook-payload';
import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item';
import { fetchProjectItemByNodeId } from 'src/modules/github/project-item/graphql/github/fetch-project-item-by-node-id';
import {
getRawBodyForSignature,
verifyGitHubSignature,
} from 'src/modules/github/connector/webhook-signature';
import { batchUpsertContributors } from 'src/modules/github/contributor/graphql/mutations/batch-upsert';
import { batchUpsertPullRequests } from 'src/modules/github/pull-request/graphql/mutations/batch-upsert';
import { batchUpsertReviewEvents } from 'src/modules/github/pull-request-review-event/graphql/mutations/batch-upsert';
import { batchUpsertConsolidatedReviews } from 'src/modules/github/pull-request-review/graphql/mutations/batch-upsert';
import { findReviewEventsForPair } from 'src/modules/github/pull-request-review-event/graphql/queries/find-events-for-pair';
import { buildConsolidatedRow } from 'src/modules/github/pull-request-review/utils/build-consolidated-row';
import type { ReviewEventState } from 'src/modules/github/pull-request-review/utils/consolidate-reviews';
import { batchUpsertIssues } from 'src/modules/github/issue/graphql/mutations/batch-upsert';
import { batchUpsertProjectItems } from 'src/modules/github/project-item/graphql/mutations/batch-upsert';
import { dedupeContributors } from 'src/modules/github/contributor/normalizers';
import { pullRequestFromWebhook } from 'src/modules/github/pull-request/normalizers';
import { reviewEventFromWebhook } from 'src/modules/github/pull-request-review-event/normalizers';
import { issueFromWebhook } from 'src/modules/github/issue/normalizers';
import { projectItemFromGraphql } from 'src/modules/github/project-item/normalizers';
async function upsertContributorsByLogin(
users: Array<{ login: string; id?: number } | null | undefined>,
): Promise<Map<string, string>> {
const inputs = dedupeContributors(users);
if (inputs.length === 0) return new Map();
const rows = await batchUpsertContributors(inputs);
const map = new Map<string, string>();
for (const r of rows) {
if (r.ghLogin) map.set(r.ghLogin, r.id);
}
return map;
}
async function handlePullRequestEvent(payload: GitHubWebhookPayload) {
const pr = payload.pull_request;
const repository = payload.repository;
if (!pr || !repository) {
return { skipped: true, reason: 'missing pull_request or repository' };
}
const idByLogin = await upsertContributorsByLogin([pr.user, pr.merged_by]);
const authorId = idByLogin.get(pr.user.login) ?? null;
const mergerId = pr.merged_by ? (idByLogin.get(pr.merged_by.login) ?? null) : null;
const canonical = pullRequestFromWebhook(pr, repository.full_name);
const [record] = await batchUpsertPullRequests([
{ ...canonical, authorId, mergerId },
]);
return {
processed: true,
pullRequestId: record?.id,
action: payload.action,
state: canonical.state,
};
}
async function handlePullRequestReviewEvent(payload: GitHubWebhookPayload) {
const review = payload.review;
const pr = payload.pull_request;
const repository = payload.repository;
if (!review || !pr || !repository) {
return {
skipped: true,
reason: 'missing review, pull_request, or repository',
};
}
const idByLogin = await upsertContributorsByLogin([
pr.user,
pr.merged_by,
review.user,
]);
const prCanonical = pullRequestFromWebhook(pr, repository.full_name);
const [prRecord] = await batchUpsertPullRequests([
{
...prCanonical,
authorId: idByLogin.get(pr.user.login) ?? null,
mergerId: pr.merged_by ? (idByLogin.get(pr.merged_by.login) ?? null) : null,
},
]);
const reviewCanonical = reviewEventFromWebhook(review);
const reviewerId = idByLogin.get(review.user.login) ?? null;
const pullRequestId = prRecord?.id ?? '';
const [eventRecord] = await batchUpsertReviewEvents([
{
...reviewCanonical,
reviewerId,
pullRequestId,
},
]);
let consolidatedId: string | undefined;
if (pullRequestId) {
const events = await findReviewEventsForPair(pullRequestId, reviewerId);
if (events.length > 0) {
const consolidatedRow = buildConsolidatedRow({
pullRequestId,
reviewerId,
prNumber: pr.number ?? null,
reviewerLogin: review.user.login,
events: events.map((e) => ({
state: e.state as ReviewEventState,
submittedAt: e.submittedAt,
})),
});
const [consolidatedRecord] = await batchUpsertConsolidatedReviews([
consolidatedRow,
]);
consolidatedId = consolidatedRecord?.id;
if (consolidatedId && eventRecord?.id) {
await batchUpsertReviewEvents([
{
...reviewCanonical,
reviewerId,
pullRequestId,
reviewId: consolidatedId,
},
]);
}
}
}
return {
processed: true,
reviewEventId: eventRecord?.id,
reviewId: consolidatedId,
pullRequestId: prRecord?.id,
action: payload.action,
};
}
async function handleIssueEvent(payload: GitHubWebhookPayload) {
const issue = payload.issue;
const repository = payload.repository;
if (!issue || !repository) {
return { skipped: true, reason: 'missing issue or repository' };
}
const idByLogin = await upsertContributorsByLogin([issue.user]);
const canonical = issueFromWebhook(issue, repository.full_name);
const [record] = await batchUpsertIssues([
{ ...canonical, authorId: idByLogin.get(issue.user.login) ?? null },
]);
return {
processed: true,
issueId: record?.id,
action: payload.action,
};
}
async function handleProjectV2ItemEvent(
payload: GitHubWebhookPayload,
testProjectItem?: ProjectV2Item,
) {
const item = payload.projects_v2_item;
if (!item?.node_id) {
return { skipped: true, reason: 'missing projects_v2_item.node_id' };
}
if (payload.action === 'deleted') {
console.log(
`[handle-webhook] projects_v2_item delete skipped (nodeId=${item.node_id})`,
);
return { skipped: true, reason: 'delete not implemented' };
}
const node = testProjectItem ?? (await fetchProjectItemByNodeId(item.node_id));
if (!node) {
return { skipped: true, reason: 'project item not found on GitHub' };
}
const canonical = await projectItemFromGraphql(node);
const [record] = await batchUpsertProjectItems([canonical]);
return {
processed: true,
projectItemId: record?.id,
action: payload.action,
};
}
const handler = async (
event: RoutePayload<
GitHubWebhookPayload & { __test_projectItem?: ProjectV2Item }
>,
) => {
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (secret) {
const signatureHeader =
event.headers?.['x-hub-signature-256'] ??
event.headers?.['X-Hub-Signature-256'];
const rawBody = getRawBodyForSignature(event);
const verification = verifyGitHubSignature({
rawBody,
signatureHeader,
secret,
});
if (!verification.ok) {
const delivery =
event.headers?.['x-github-delivery'] ??
event.headers?.['X-GitHub-Delivery'];
console.warn(
`[handle-webhook] signature verification failed (${verification.reason}) delivery=${delivery ?? 'unknown'}`,
);
return { error: 'invalid signature', reason: verification.reason };
}
} else {
console.warn(
'[handle-webhook] GITHUB_WEBHOOK_SECRET is not set; skipping signature verification (insecure)',
);
}
const payload = event.body;
if (!payload) return { error: 'empty body' };
if (payload.pull_request && payload.review) {
return handlePullRequestReviewEvent(payload);
}
if (payload.pull_request) {
return handlePullRequestEvent(payload);
}
if (payload.issue) {
return handleIssueEvent(payload);
}
if (payload.projects_v2_item) {
return handleProjectV2ItemEvent(payload, payload.__test_projectItem);
}
return { skipped: true, reason: 'unhandled event' };
};
export default defineLogicFunction({
universalIdentifier: '22b199b3-2851-4a4f-99fd-4e79c188fe7d',
name: 'handle-github-webhook',
description:
'Receives GitHub webhook events for PRs, reviews, issues, and project items; idempotent upserts via shared batch helpers',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/github/webhook',
httpMethod: 'POST',
isAuthRequired: false,
forwardedRequestHeaders: [
'x-hub-signature-256',
'x-github-event',
'x-github-delivery',
],
},
});
@@ -0,0 +1,14 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: 'd1c2b3a4-9e8f-4d3c-b2a1-0f9e8d7c6b5a',
position: 3,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,17 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import { GITHUB_DASHBOARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/page-layouts/github-dashboard.page-layout';
import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: 'e8f9a1b2-c3d4-4567-89ab-cdef01234567',
name: 'GitHub Dashboard',
icon: 'IconBrandGithub',
position: 6,
type: NavigationMenuItemType.PAGE_LAYOUT,
pageLayoutUniversalIdentifier:
GITHUB_DASHBOARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,13 @@
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
export const GITHUB_FOLDER_UNIVERSAL_IDENTIFIER =
'a4f7d8b1-2c93-4e6f-8b1a-9d3e5c7f2a48';
export default defineNavigationMenuItem({
universalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER,
name: 'GitHub',
icon: 'IconBrandGithub',
color: 'gray',
position: 0,
type: NavigationMenuItemType.FOLDER,
});
@@ -0,0 +1,281 @@
import {
AggregateOperations,
definePageLayout,
ObjectRecordGroupByDateGranularity,
PageLayoutTabLayoutMode,
} from 'twenty-sdk/define';
import { TOP_PR_AUTHORS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/front-components/top-pr-authors.front-component';
import { TOP_REVIEWERS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/front-components/top-reviewers.front-component';
import {
ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
ISSUE_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/issue/objects/issue.object';
import {
MERGED_AT_FIELD_UNIVERSAL_IDENTIFIER,
PR_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
PR_STATE_FIELD_UNIVERSAL_IDENTIFIER,
PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
PULL_REQUEST_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/pull-request/objects/pull-request.object';
import {
PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/pull-request-review/objects/pull-request-review.object';
export const GITHUB_DASHBOARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
'a8d2f1c4-7b69-4e3a-8c5d-9f6b1a3e7c2d';
const THIS_WEEK_RELATIVE = 'THIS_1_WEEK;;UTC;;SUNDAY;;';
const COMMON = {
timezone: 'UTC',
firstDayOfTheWeek: 0,
};
const COL_1 = { column: 0, columnSpan: 3 } as const;
const COL_2 = { column: 3, columnSpan: 5 } as const;
const COL_3 = { column: 8, columnSpan: 4 } as const;
export default definePageLayout({
universalIdentifier: GITHUB_DASHBOARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
name: 'GitHub Dashboard',
type: 'STANDALONE_PAGE',
tabs: [
{
universalIdentifier: 'b3e9c2a1-4d68-4f7b-9c25-1e3a5b7c9d11',
title: 'GitHub Dashboard',
position: 0,
icon: 'IconBrandGithub',
layoutMode: PageLayoutTabLayoutMode.GRID,
widgets: [
{
universalIdentifier: 'f1a2b3c4-d5e6-4789-a0b1-c2d3e4f5a6b1',
title: 'PRs Merged This Week',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 0, ...COL_1, rowSpan: 3 },
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
label: 'PRs Merged',
color: 'green',
displayDataLabel: false,
...COMMON,
filter: {
recordFilters: [
{
fieldMetadataUniversalIdentifier:
MERGED_AT_FIELD_UNIVERSAL_IDENTIFIER,
operand: 'IS_RELATIVE',
value: THIS_WEEK_RELATIVE,
type: 'DATE_TIME',
},
],
},
},
},
{
universalIdentifier: 'f1a2b3c4-d5e6-4789-a0b1-c2d3e4f5a6b2',
title: 'PR Reviews This Week',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 3, ...COL_1, rowSpan: 3 },
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
label: 'Reviews',
color: 'blue',
displayDataLabel: false,
...COMMON,
filter: {
recordFilters: [
{
fieldMetadataUniversalIdentifier:
REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
operand: 'IS_RELATIVE',
value: THIS_WEEK_RELATIVE,
type: 'DATE_TIME',
},
],
},
},
},
{
universalIdentifier: '8d4e9d09-29b0-4925-8fcc-aedf5bcecb37',
title: 'PRs Opened This Week',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 6, ...COL_1, rowSpan: 3 },
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
label: 'PRs Opened',
color: 'purple',
displayDataLabel: false,
...COMMON,
filter: {
recordFilters: [
{
fieldMetadataUniversalIdentifier:
PR_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
operand: 'IS_RELATIVE',
value: THIS_WEEK_RELATIVE,
type: 'DATE_TIME',
},
],
},
},
},
{
universalIdentifier: '3c321867-0227-4535-81db-c64a6f673d86',
title: 'Issues Opened This Week',
type: 'GRAPH',
objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 9, ...COL_1, rowSpan: 3 },
configuration: {
configurationType: 'AGGREGATE_CHART',
aggregateFieldMetadataUniversalIdentifier:
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
label: 'Issues Opened',
color: 'orange',
displayDataLabel: false,
...COMMON,
filter: {
recordFilters: [
{
fieldMetadataUniversalIdentifier:
ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
operand: 'IS_RELATIVE',
value: THIS_WEEK_RELATIVE,
type: 'DATE_TIME',
},
],
},
},
},
{
universalIdentifier: '704209be-9174-43a8-ae51-6b11fff20e22',
title: 'PRs by State',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 12, ...COL_1, rowSpan: 6 },
configuration: {
configurationType: 'PIE_CHART',
aggregateFieldMetadataUniversalIdentifier:
PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
groupByFieldMetadataUniversalIdentifier:
PR_STATE_FIELD_UNIVERSAL_IDENTIFIER,
displayLegend: true,
showCenterMetric: true,
hideEmptyCategory: true,
...COMMON,
},
},
{
universalIdentifier: 'c1f2a3b4-d5e6-4789-a0b1-c2d3e4f5a6b7',
title: 'PRs Merged per Week',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 0, ...COL_2, rowSpan: 6 },
configuration: {
configurationType: 'BAR_CHART',
aggregateFieldMetadataUniversalIdentifier:
PULL_REQUEST_NAME_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
MERGED_AT_FIELD_UNIVERSAL_IDENTIFIER,
primaryAxisDateGranularity:
ObjectRecordGroupByDateGranularity.WEEK,
displayDataLabel: false,
displayLegend: false,
color: 'green',
layout: 'VERTICAL',
omitNullValues: true,
...COMMON,
},
},
{
universalIdentifier: 'd2e3f4a5-b6c7-4890-a1b2-c3d4e5f6a7b8',
title: 'PR Reviews per Week',
type: 'GRAPH',
objectUniversalIdentifier: PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 6, ...COL_2, rowSpan: 6 },
configuration: {
configurationType: 'BAR_CHART',
aggregateFieldMetadataUniversalIdentifier:
REVIEW_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
REVIEW_FIRST_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
primaryAxisDateGranularity:
ObjectRecordGroupByDateGranularity.WEEK,
displayDataLabel: false,
displayLegend: false,
color: 'blue',
layout: 'VERTICAL',
omitNullValues: true,
...COMMON,
},
},
{
universalIdentifier: '6d03cbd1-ee03-4bf4-a3ac-143d03fa7fd9',
title: 'Issues per Week',
type: 'GRAPH',
objectUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
gridPosition: { row: 12, ...COL_2, rowSpan: 6 },
configuration: {
configurationType: 'BAR_CHART',
aggregateFieldMetadataUniversalIdentifier:
ISSUE_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
aggregateOperation: AggregateOperations.COUNT,
primaryAxisGroupByFieldMetadataUniversalIdentifier:
ISSUE_GITHUB_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
primaryAxisDateGranularity:
ObjectRecordGroupByDateGranularity.WEEK,
secondaryAxisGroupByFieldMetadataUniversalIdentifier:
ISSUE_STATE_FIELD_UNIVERSAL_IDENTIFIER,
displayDataLabel: false,
displayLegend: true,
color: 'orange',
layout: 'VERTICAL',
omitNullValues: true,
...COMMON,
},
},
{
universalIdentifier: '7b3e9c4a-1d52-4f8b-ac76-3e5b8d2f1a9c',
title: 'Top PR Authors',
type: 'FRONT_COMPONENT',
gridPosition: { row: 0, ...COL_3, rowSpan: 9 },
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
TOP_PR_AUTHORS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
{
universalIdentifier: '5e4a8c1d-7f93-4b2e-9d6c-3a8f1b5e7d4c',
title: 'Top Reviewers',
type: 'FRONT_COMPONENT',
gridPosition: { row: 9, ...COL_3, rowSpan: 9 },
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
TOP_REVIEWERS_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
},
},
],
},
],
});
@@ -0,0 +1,24 @@
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
import { ISSUE_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/objects/issue.object';
import { PROJECT_ITEMS_ON_ISSUE_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/issue/fields/project-items-on-issue.field';
export const LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER =
'7a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d';
export default defineField({
universalIdentifier: LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'linkedIssue',
label: 'Linked Issue',
icon: 'IconBug',
relationTargetObjectMetadataUniversalIdentifier: ISSUE_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
PROJECT_ITEMS_ON_ISSUE_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'linkedIssueId',
},
});
@@ -0,0 +1,25 @@
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object';
import { PROJECT_ITEMS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/project-items-on-pull-request.field';
export const LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER =
'9c4d5e6f-7a8b-4c9d-ae0f-1a2b3c4d5e6f';
export default defineField({
universalIdentifier: LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'linkedPullRequest',
label: 'Linked PR',
icon: 'IconGitPullRequest',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
PROJECT_ITEMS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'linkedPullRequestId',
},
});
@@ -0,0 +1,25 @@
import { defineField, FieldType, RelationType, OnDeleteAction } from 'twenty-sdk/define';
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/fields/assigned-project-items-on-contributor.field';
export const MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER =
'5bc9d7b3-ca5a-4006-bd74-0420d1f3df85';
export default defineField({
universalIdentifier: MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'mainAssignee',
label: 'Main Assignee',
icon: 'IconUser',
relationTargetObjectMetadataUniversalIdentifier:
CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
ASSIGNED_PROJECT_ITEMS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'mainAssigneeId',
},
});
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
objectMetadataItem,
unmountFrontComponent,
updateProgress,
} from 'twenty-sdk/front-component';
import { callAppRoute } from 'src/modules/shared/call-app-route';
type CountResponse = {
totalPages: number;
projects: Array<{
owner: string;
number: number;
totalCount: number;
pages: number;
}>;
};
type FetchPageResponse = {
itemCount: number;
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
type SyncStatus = 'syncing' | 'done' | 'error';
const FetchProjectItems = () => {
const [status, setStatus] = useState<SyncStatus>('syncing');
useEffect(() => {
const run = async () => {
try {
const counts = (await callAppRoute(
'/github/count-project-items',
{},
)) as CountResponse;
if (counts.projects.length === 0) {
throw new Error(
'No projects resolved. Set GITHUB_PROJECTS in the application variables (e.g. `twentyhq/24`).',
);
}
const totalPages = Math.max(counts.totalPages, 1);
let pagesProcessed = 0;
let totalItems = 0;
for (const { owner, number } of counts.projects) {
let cursor: string | null = null;
let hasMore = true;
while (hasMore) {
const data = (await callAppRoute('/github/fetch-project-items', {
owner,
number,
cursor,
})) as FetchPageResponse;
totalItems += data.itemCount;
hasMore = data.hasMore && data.itemCount > 0;
cursor = data.endCursor;
pagesProcessed++;
updateProgress(Math.min(Math.round((pagesProcessed / totalPages) * 100), 99));
}
}
updateProgress(100);
enqueueSnackbar({
message: `Fetched ${totalItems} project items`,
variant: 'success',
});
setStatus('done');
} catch (err) {
const message =
err instanceof Error ? err.message : 'Failed to fetch project items';
enqueueSnackbar({ message, variant: 'error' });
setStatus('error');
} finally {
unmountFrontComponent();
}
};
run();
}, []);
if (status === 'syncing') return <div>Fetching project items...</div>;
if (status === 'error') return <div>Failed to fetch project items.</div>;
return <div>Done</div>;
};
export default defineFrontComponent({
universalIdentifier: '7c397b0c-8b19-4fac-924a-8f6aa1dece78',
name: 'Fetch Project Items',
description: 'Fetches project items from GitHub Projects V2',
isHeadless: true,
component: FetchProjectItems,
command: {
universalIdentifier: '719cfe1c-d570-4c8c-89e6-88671c6ba1ea',
label: 'Fetch Project Items',
icon: 'IconLayoutKanban',
isPinned: false,
conditionalAvailabilityExpression:
objectMetadataItem.nameSingular === 'projectItem',
},
});
@@ -0,0 +1,41 @@
import { githubGraphqlOptional } from 'src/modules/github/connector/github-client';
const ORG_QUERY = `
query($owner: String!, $number: Int!) {
organization(login: $owner) {
projectV2(number: $number) {
items { totalCount }
}
}
}`;
const USER_QUERY = ORG_QUERY.replace(
'organization(login: $owner)',
'user(login: $owner)',
);
type OrgResponse = {
organization: { projectV2: { items: { totalCount: number } } | null } | null;
};
type UserResponse = {
user: { projectV2: { items: { totalCount: number } } | null } | null;
};
export async function countProjectItems(
owner: string,
projectNumber: number,
): Promise<number> {
const orgData = await githubGraphqlOptional<OrgResponse>(ORG_QUERY, {
owner,
number: projectNumber,
});
const orgCount = orgData?.organization?.projectV2?.items.totalCount;
if (typeof orgCount === 'number') return orgCount;
const userData = await githubGraphqlOptional<UserResponse>(USER_QUERY, {
owner,
number: projectNumber,
});
return userData?.user?.projectV2?.items.totalCount ?? 0;
}
@@ -0,0 +1,31 @@
import { githubGraphql } from 'src/modules/github/connector/github-client';
import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item';
import { PROJECT_V2_ITEM_FRAGMENT } from 'src/modules/github/project-item/graphql/github/fragments';
const QUERY = `
query($id: ID!) {
node(id: $id) {
... on ProjectV2Item {
${PROJECT_V2_ITEM_FRAGMENT}
}
}
}`;
type Response = {
node: ProjectV2Item | null;
};
export async function fetchProjectItemByNodeId(
nodeId: string,
): Promise<ProjectV2Item | null> {
try {
const data = await githubGraphql<Response>(QUERY, { id: nodeId });
return data.node;
} catch (err) {
const msg = err instanceof Error ? err.message : '';
if (msg.includes('Could not resolve') || msg.includes('global id')) {
return null;
}
throw err;
}
}
@@ -0,0 +1,78 @@
import {
EMPTY_PAGE,
type GithubPage,
githubGraphqlOptional,
} from 'src/modules/github/connector/github-client';
import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item';
import { PROJECT_V2_ITEM_FRAGMENT } from 'src/modules/github/project-item/graphql/github/fragments';
const ORG_QUERY = `
query($owner: String!, $number: Int!, $cursor: String) {
organization(login: $owner) {
projectV2(number: $number) {
items(first: 100, after: $cursor) {
totalCount
pageInfo { hasNextPage endCursor }
nodes { ${PROJECT_V2_ITEM_FRAGMENT} }
}
}
}
}`;
const USER_QUERY = ORG_QUERY.replace(
'organization(login: $owner)',
'user(login: $owner)',
);
type ItemsConnection = GithubPage<ProjectV2Item>;
type OrgResponse = {
organization: { projectV2: { items: ItemsConnection } | null } | null;
};
type UserResponse = {
user: { projectV2: { items: ItemsConnection } | null } | null;
};
export async function fetchProjectItems(
owner: string,
projectNumber: number,
cursor: string | null = null,
): Promise<{
items: ProjectV2Item[];
totalCount: number;
hasMore: boolean;
endCursor: string | null;
}> {
const orgData = await githubGraphqlOptional<OrgResponse>(ORG_QUERY, {
owner,
number: projectNumber,
cursor,
});
let conn: ItemsConnection | undefined =
orgData?.organization?.projectV2?.items;
if (!conn) {
const userData = await githubGraphqlOptional<UserResponse>(USER_QUERY, {
owner,
number: projectNumber,
cursor,
});
conn = userData?.user?.projectV2?.items;
}
if (!conn) {
console.warn(
`[github-gql] project ${owner}/${projectNumber} returned no items (project does not exist or the fine-grained PAT lacks Organization → Projects: Read for "${owner}", and may also need to be approved by an org admin).`,
);
return { items: [], ...EMPTY_PAGE };
}
return {
items: conn.nodes,
totalCount: conn.totalCount,
hasMore: conn.pageInfo.hasNextPage,
endCursor: conn.pageInfo.endCursor,
};
}
@@ -0,0 +1,46 @@
export const PROJECT_V2_ITEM_FRAGMENT = `
id
content {
__typename
... on Issue {
title
number
url
repository { nameWithOwner }
}
... on PullRequest {
title
number
url
repository { nameWithOwner }
}
... on DraftIssue {
title
}
}
fieldValues(first: 20) {
nodes {
__typename
... on ProjectV2ItemFieldSingleSelectValue {
name
field { ... on ProjectV2SingleSelectField { name } }
}
... on ProjectV2ItemFieldIterationValue {
title
field { ... on ProjectV2IterationField { name } }
}
... on ProjectV2ItemFieldTextValue {
text
field { ... on ProjectV2Field { name } }
}
... on ProjectV2ItemFieldNumberValue {
number
field { ... on ProjectV2Field { name } }
}
... on ProjectV2ItemFieldUserValue {
users(first: 10) { nodes { login } }
field { ... on ProjectV2Field { name } }
}
}
}
`;
@@ -0,0 +1,29 @@
import { chunkedBatchCreate } from 'src/modules/shared/twenty-client';
import type { ProjectItemRow } from 'src/modules/github/project-item/types/project-item-row';
export async function batchUpsertProjectItems(
items: Array<{
name: string;
githubProjectItemId: string;
status: string;
sprint: string;
assignees: string;
priority: string | null;
mainAssigneeId: string | null;
linkedIssueId: string | null;
linkedPullRequestId: string | null;
githubUrl: { primaryLinkLabel: string; primaryLinkUrl: string; secondaryLinks: null } | null;
repo: string;
}>,
): Promise<ProjectItemRow[]> {
return chunkedBatchCreate('createProjectItems', items, {
id: true,
githubProjectItemId: true,
name: true,
status: true,
mainAssigneeId: true,
linkedIssueId: true,
linkedPullRequestId: true,
githubUrl: { primaryLinkLabel: true, primaryLinkUrl: true },
}) as Promise<ProjectItemRow[]>;
}
@@ -0,0 +1,47 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import {
getGithubProjects,
type GithubProject,
} from 'src/modules/github/connector/config';
import { countProjectItems } from 'src/modules/github/project-item/graphql/github/count-project-items';
const PAGE_SIZE = 100;
type CountProjectItemsPayload = {
projects?: GithubProject[];
};
const handler = async (event: RoutePayload<CountProjectItemsPayload>) => {
const bodyProjects = event.body?.projects;
const projects =
bodyProjects && bodyProjects.length > 0
? bodyProjects
: getGithubProjects();
const results: Array<GithubProject & { totalCount: number; pages: number }> =
[];
let totalPages = 0;
for (const { owner, number } of projects) {
const totalCount = await countProjectItems(owner, number);
const pages = Math.max(Math.ceil(totalCount / PAGE_SIZE), 1);
results.push({ owner, number, totalCount, pages });
totalPages += pages;
}
return { totalPages, projects: results };
};
export default defineLogicFunction({
universalIdentifier: 'f7a3e1b2-5c4d-4e6f-8a9b-0d1c2e3f4a5b',
name: 'count-project-items',
description:
'Counts total project item pages across configured projects using GraphQL totalCount',
timeoutSeconds: 30,
handler,
httpRouteTriggerSettings: {
path: '/github/count-project-items',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,57 @@
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
import { fetchProjectItems } from 'src/modules/github/project-item/graphql/github/fetch-project-items';
import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item';
import { batchUpsertProjectItems } from 'src/modules/github/project-item/graphql/mutations/batch-upsert';
import { projectItemFromGraphql } from 'src/modules/github/project-item/normalizers';
import { isFixtureAllowed } from 'src/modules/shared/fixtures';
export type FetchProjectItemsFixturePage = {
items: ProjectV2Item[];
totalCount: number;
hasMore: boolean;
endCursor: string | null;
};
type FetchProjectItemsPayload = {
owner: string;
number: number;
cursor?: string | null;
fixturePage?: FetchProjectItemsFixturePage;
};
const handler = async (event: RoutePayload<FetchProjectItemsPayload>) => {
const { owner, number, cursor = null, fixturePage } = event.body ?? {};
if (!owner || !number) {
return { error: 'owner and number are required' };
}
const result =
fixturePage && isFixtureAllowed()
? fixturePage
: await fetchProjectItems(owner, number, cursor);
const { items, totalCount, hasMore, endCursor } = result;
if (items.length === 0) {
return { itemCount: 0, totalCount, hasMore: false, endCursor: null };
}
const itemData = await Promise.all(items.map(projectItemFromGraphql));
await batchUpsertProjectItems(itemData);
return { itemCount: items.length, totalCount, hasMore, endCursor };
};
export default defineLogicFunction({
universalIdentifier: 'acb300d4-d4ec-491c-b314-3d4db90a49c5',
name: 'fetch-project-items',
description:
'Fetches one page of project items from GitHub Projects V2 GraphQL API and batch upserts them',
timeoutSeconds: 300,
handler,
httpRouteTriggerSettings: {
path: '/github/fetch-project-items',
httpMethod: 'POST',
isAuthRequired: true,
},
});
@@ -0,0 +1,11 @@
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
import { PROJECT_ITEM_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/objects/project-item.object';
import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item';
export default defineNavigationMenuItem({
universalIdentifier: '25d3a916-9a70-478f-8d83-ef336e582fbc',
position: 2,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,106 @@
import type { LinksFieldValue } from 'src/modules/shared/types';
import { toLinksField } from 'src/modules/shared/types';
import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item';
import {
extractFieldValue,
extractAssigneeLogins,
} from 'src/modules/github/project-item/utils/extract-field-value';
import { findIssueByNumberAndRepo } from 'src/modules/github/issue/graphql/queries/find-by-number-and-repo';
import {
findPullRequestByGithubNumber,
findPullRequestByRepoAndNumber,
} from 'src/modules/github/pull-request/graphql/queries/find-by-github-number';
import { findContributorByGhLogin } from 'src/modules/github/contributor/graphql/queries/find-by-gh-login';
const STATUS_MAP: Record<string, string> = {
'No Status': 'NO_STATUS',
Backlog: 'BACKLOG',
Todo: 'TODO',
'In Progress': 'IN_PROGRESS',
'In Review': 'IN_REVIEW',
Done: 'DONE',
};
const PRIORITY_MAP: Record<string, string> = {
Low: 'LOW',
Medium: 'MEDIUM',
High: 'HIGH',
Critical: 'CRITICAL',
};
export type ProjectItemUpsertInput = {
name: string;
githubProjectItemId: string;
status: string;
sprint: string;
assignees: string;
priority: string | null;
mainAssigneeId: string | null;
linkedIssueId: string | null;
linkedPullRequestId: string | null;
githubUrl: LinksFieldValue | null;
repo: string;
};
export async function projectItemFromGraphql(
item: ProjectV2Item,
): Promise<ProjectItemUpsertInput> {
const title =
item.content?.title ??
(extractFieldValue(item, 'Title') || 'Untitled');
const rawStatus = extractFieldValue(item, 'Status');
const status = STATUS_MAP[rawStatus] ?? 'NO_STATUS';
const sprint =
extractFieldValue(item, 'Sprint') ||
extractFieldValue(item, 'Iteration');
const assigneeLogins = extractAssigneeLogins(item);
const assignees = assigneeLogins.join(', ');
const rawPriority = extractFieldValue(item, 'Priority');
const priority = PRIORITY_MAP[rawPriority] ?? null;
let mainAssigneeId: string | null = null;
if (assigneeLogins.length > 0) {
const contributor = await findContributorByGhLogin(assigneeLogins[0]);
mainAssigneeId = contributor?.id ?? null;
}
let linkedIssueId: string | null = null;
let linkedPullRequestId: string | null = null;
let repo = '';
let githubUrl: LinksFieldValue | null = null;
if (item.content) {
const contentType = item.content.__typename;
repo = item.content.repository?.nameWithOwner ?? '';
if (contentType === 'Issue' && item.content.number) {
const issue = await findIssueByNumberAndRepo(item.content.number, repo);
linkedIssueId = issue?.id ?? null;
if (item.content.url) {
githubUrl = toLinksField(item.content.url, `#${item.content.number}`);
}
} else if (contentType === 'PullRequest' && item.content.number) {
const pr = repo
? await findPullRequestByRepoAndNumber(repo, item.content.number)
: await findPullRequestByGithubNumber(item.content.number);
linkedPullRequestId = pr?.id ?? null;
if (item.content.url) {
githubUrl = toLinksField(item.content.url, `#${item.content.number}`);
}
}
}
return {
name: title,
githubProjectItemId: item.id,
status,
sprint,
assignees,
priority,
mainAssigneeId,
linkedIssueId,
linkedPullRequestId,
githubUrl,
repo,
};
}
@@ -0,0 +1,181 @@
import { defineObject, FieldType } from 'twenty-sdk/define';
export const PROJECT_ITEM_UNIVERSAL_IDENTIFIER =
'1c4c36a5-586c-4ead-9f3e-5e9718ba6231';
export const PROJECT_ITEM_NAME_FIELD_UNIVERSAL_IDENTIFIER =
'215cdbbd-8606-4c13-9025-3ebe912eaa32';
export const PROJECT_ITEM_GITHUB_ID_FIELD_UNIVERSAL_IDENTIFIER =
'2d3e4f5a-6b7c-4d8e-af9b-0c1d2e3f4a5b';
export const PROJECT_ITEM_SPRINT_FIELD_UNIVERSAL_IDENTIFIER =
'e3708df4-6b6b-45f6-83fb-9d47c5bc5a25';
export const PROJECT_ITEM_ASSIGNEES_FIELD_UNIVERSAL_IDENTIFIER =
'3e4f5a6b-7c8d-4e9f-b0ac-1d2e3f4a5b6c';
export const PROJECT_ITEM_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER =
'2db040a6-f2af-4e7d-9b70-d453f3cd99b7';
export const PROJECT_ITEM_REPO_FIELD_UNIVERSAL_IDENTIFIER =
'589d6aeb-8384-4d52-9d71-d63b7fd94ec7';
enum ProjectItemStatus {
NO_STATUS = 'NO_STATUS',
BACKLOG = 'BACKLOG',
TODO = 'TODO',
IN_PROGRESS = 'IN_PROGRESS',
IN_REVIEW = 'IN_REVIEW',
DONE = 'DONE',
}
export const PROJECT_ITEM_STATUS_FIELD_UNIVERSAL_IDENTIFIER =
'54034cfc-83d9-478d-849c-98c91a819342';
enum ProjectItemPriority {
LOW = 'LOW',
MEDIUM = 'MEDIUM',
HIGH = 'HIGH',
CRITICAL = 'CRITICAL',
}
export const PROJECT_ITEM_PRIORITY_FIELD_UNIVERSAL_IDENTIFIER =
'952a601c-b2da-4b84-9c7b-a9b42fcb7d95';
export const PROJECT_ITEM_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'597b4141-487f-5ad3-87c2-49f0a1679856';
export default defineObject({
universalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
nameSingular: 'projectItem',
namePlural: 'projectItems',
labelSingular: 'Project Item',
labelPlural: 'Project Items',
icon: 'IconLayoutKanban',
labelIdentifierFieldMetadataUniversalIdentifier:
PROJECT_ITEM_NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: PROJECT_ITEM_NAME_FIELD_UNIVERSAL_IDENTIFIER,
name: 'name',
type: FieldType.TEXT,
label: 'Name',
icon: 'IconTextCaption',
},
{
universalIdentifier: PROJECT_ITEM_GITHUB_ID_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubProjectItemId',
type: FieldType.TEXT,
label: 'GitHub Item ID',
icon: 'IconHash',
isUnique: true,
},
{
universalIdentifier: PROJECT_ITEM_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
name: 'status',
type: FieldType.SELECT,
label: 'Status',
icon: 'IconCircleDot',
options: [
{
value: ProjectItemStatus.NO_STATUS,
label: 'No Status',
position: 0,
color: 'gray',
},
{
value: ProjectItemStatus.BACKLOG,
label: 'Backlog',
position: 1,
color: 'gray',
},
{
value: ProjectItemStatus.TODO,
label: 'Todo',
position: 2,
color: 'blue',
},
{
value: ProjectItemStatus.IN_PROGRESS,
label: 'In Progress',
position: 3,
color: 'yellow',
},
{
value: ProjectItemStatus.IN_REVIEW,
label: 'In Review',
position: 4,
color: 'turquoise',
},
{
value: ProjectItemStatus.DONE,
label: 'Done',
position: 5,
color: 'green',
},
],
},
{
universalIdentifier: PROJECT_ITEM_SPRINT_FIELD_UNIVERSAL_IDENTIFIER,
name: 'sprint',
type: FieldType.TEXT,
label: 'Sprint',
icon: 'IconRun',
},
{
universalIdentifier: PROJECT_ITEM_ASSIGNEES_FIELD_UNIVERSAL_IDENTIFIER,
name: 'assignees',
type: FieldType.TEXT,
label: 'Assignees',
icon: 'IconUsers',
},
{
universalIdentifier: PROJECT_ITEM_PRIORITY_FIELD_UNIVERSAL_IDENTIFIER,
name: 'priority',
type: FieldType.SELECT,
label: 'Priority',
icon: 'IconFlag',
options: [
{
value: ProjectItemPriority.LOW,
label: 'Low',
position: 0,
color: 'green',
},
{
value: ProjectItemPriority.MEDIUM,
label: 'Medium',
position: 1,
color: 'turquoise',
},
{
value: ProjectItemPriority.HIGH,
label: 'High',
position: 2,
color: 'red',
},
{
value: ProjectItemPriority.CRITICAL,
label: 'Critical',
position: 3,
color: 'red',
},
],
},
{
universalIdentifier: PROJECT_ITEM_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubUrl',
type: FieldType.LINKS,
label: 'GitHub URL',
icon: 'IconLink',
},
{
universalIdentifier: PROJECT_ITEM_REPO_FIELD_UNIVERSAL_IDENTIFIER,
name: 'repo',
type: FieldType.TEXT,
label: 'Repository',
icon: 'IconFolder',
},
],
});
@@ -0,0 +1,7 @@
export type GitHubProjectV2Item = {
id: number;
node_id: string;
project_node_id?: string;
content_node_id?: string;
content_type?: 'Issue' | 'PullRequest' | 'DraftIssue';
};
@@ -0,0 +1,16 @@
import type { LinksFieldValue } from 'src/modules/shared/types';
export type ProjectItemRow = {
id: string;
name?: string | null;
githubProjectItemId?: string | null;
status?: string | null;
sprint?: string | null;
assignees?: string | null;
priority?: string | null;
mainAssigneeId?: string | null;
linkedIssueId?: string | null;
linkedPullRequestId?: string | null;
githubUrl?: LinksFieldValue | null;
repo?: string | null;
};
@@ -0,0 +1,42 @@
type FieldRef = { name: string };
export type ProjectV2FieldValue =
| {
__typename: 'ProjectV2ItemFieldSingleSelectValue';
name: string;
field: FieldRef;
}
| {
__typename: 'ProjectV2ItemFieldIterationValue';
title: string;
field: FieldRef;
}
| {
__typename: 'ProjectV2ItemFieldTextValue';
text: string;
field: FieldRef;
}
| {
__typename: 'ProjectV2ItemFieldNumberValue';
number: number;
field: FieldRef;
}
| {
__typename: 'ProjectV2ItemFieldUserValue';
users: { nodes: Array<{ login: string }> };
field: FieldRef;
};
export type ProjectV2Item = {
id: string;
content: {
__typename: string;
title?: string;
number?: number;
url?: string;
repository?: { nameWithOwner: string };
} | null;
fieldValues: {
nodes: Array<ProjectV2FieldValue>;
};
};
@@ -0,0 +1,34 @@
import type { ProjectV2Item } from 'src/modules/github/project-item/types/project-v2-item';
export function extractAssigneeLogins(item: ProjectV2Item): string[] {
for (const fv of item.fieldValues.nodes) {
if (fv.field?.name !== 'Assignees') continue;
if (fv.__typename === 'ProjectV2ItemFieldUserValue') {
return fv.users.nodes.map((u) => u.login);
}
}
return [];
}
export function extractFieldValue(
item: ProjectV2Item,
fieldName: string,
): string {
for (const fv of item.fieldValues.nodes) {
if (fv.field?.name !== fieldName) continue;
switch (fv.__typename) {
case 'ProjectV2ItemFieldSingleSelectValue':
return fv.name;
case 'ProjectV2ItemFieldIterationValue':
return fv.title;
case 'ProjectV2ItemFieldTextValue':
return fv.text;
case 'ProjectV2ItemFieldNumberValue':
return String(fv.number);
case 'ProjectV2ItemFieldUserValue':
return fv.users.nodes.map((u) => u.login).join(', ');
}
}
return '';
}
@@ -0,0 +1,123 @@
import {
defineView,
ViewKey,
ViewSortDirection,
ViewType,
} from 'twenty-sdk/define';
import {
PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
PROJECT_ITEM_NAME_FIELD_UNIVERSAL_IDENTIFIER,
PROJECT_ITEM_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
PROJECT_ITEM_SPRINT_FIELD_UNIVERSAL_IDENTIFIER,
PROJECT_ITEM_ASSIGNEES_FIELD_UNIVERSAL_IDENTIFIER,
PROJECT_ITEM_PRIORITY_FIELD_UNIVERSAL_IDENTIFIER,
PROJECT_ITEM_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER,
PROJECT_ITEM_REPO_FIELD_UNIVERSAL_IDENTIFIER,
PROJECT_ITEM_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
} from 'src/modules/github/project-item/objects/project-item.object';
import { MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/main-assignee-on-project-item.field';
import { LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/linked-issue-on-project-item.field';
import { LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/project-item/fields/linked-pr-on-project-item.field';
export const ALL_PROJECT_ITEMS_VIEW_UNIVERSAL_IDENTIFIER =
'9bb6d69e-9411-4195-9042-8df2e4b72a11';
export default defineView({
universalIdentifier: ALL_PROJECT_ITEMS_VIEW_UNIVERSAL_IDENTIFIER,
name: 'All Project Items',
objectUniversalIdentifier: PROJECT_ITEM_UNIVERSAL_IDENTIFIER,
type: ViewType.TABLE,
icon: 'IconLayoutKanban',
key: ViewKey.INDEX,
position: 0,
fields: [
{
universalIdentifier: '19064693-70e0-4653-b6f6-572c3adfcc78',
fieldMetadataUniversalIdentifier:
PROJECT_ITEM_NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
isVisible: true,
size: 300,
},
{
universalIdentifier: 'f7a1de3d-3989-4cb9-8e8e-0edd1b4d15e7',
fieldMetadataUniversalIdentifier:
PROJECT_ITEM_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
position: 1,
isVisible: true,
size: 130,
},
{
universalIdentifier: 'f4fae1f9-8480-4cfd-b493-9dbac84c6643',
fieldMetadataUniversalIdentifier:
PROJECT_ITEM_PRIORITY_FIELD_UNIVERSAL_IDENTIFIER,
position: 2,
isVisible: true,
size: 130,
},
{
universalIdentifier: '5969adcf-1e89-4561-a052-0296058623f3',
fieldMetadataUniversalIdentifier:
MAIN_ASSIGNEE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
position: 3,
isVisible: true,
size: 180,
},
{
universalIdentifier: '21868cca-dae2-45e7-aa92-e667703e8cfe',
fieldMetadataUniversalIdentifier:
PROJECT_ITEM_SPRINT_FIELD_UNIVERSAL_IDENTIFIER,
position: 4,
isVisible: true,
size: 150,
},
{
universalIdentifier: 'bf877bd3-a788-4db5-89cd-db6fb74fe49d',
fieldMetadataUniversalIdentifier:
PROJECT_ITEM_ASSIGNEES_FIELD_UNIVERSAL_IDENTIFIER,
position: 5,
isVisible: true,
size: 200,
},
{
universalIdentifier: '4c063477-ffe8-4799-bc50-bb9b2483cbab',
fieldMetadataUniversalIdentifier:
LINKED_ISSUE_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
position: 6,
isVisible: true,
size: 180,
},
{
universalIdentifier: 'acba5c9a-fd97-42b6-b0f5-e8e3fb7d49c5',
fieldMetadataUniversalIdentifier:
LINKED_PR_ON_PROJECT_ITEM_FIELD_UNIVERSAL_IDENTIFIER,
position: 7,
isVisible: true,
size: 180,
},
{
universalIdentifier: '931fac89-e99d-46dd-b316-85ff04356811',
fieldMetadataUniversalIdentifier:
PROJECT_ITEM_GITHUB_URL_FIELD_UNIVERSAL_IDENTIFIER,
position: 8,
isVisible: true,
size: 200,
},
{
universalIdentifier: '11c446af-470a-40d4-96e8-9e91879accf4',
fieldMetadataUniversalIdentifier:
PROJECT_ITEM_REPO_FIELD_UNIVERSAL_IDENTIFIER,
position: 9,
isVisible: true,
size: 180,
},
],
sorts: [
{
universalIdentifier: 'a7c181f1-2961-45b0-a700-dd9489b3420c',
fieldMetadataUniversalIdentifier:
PROJECT_ITEM_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER,
direction: ViewSortDirection.DESC,
},
],
});
@@ -0,0 +1,30 @@
import {
defineField,
FieldType,
RelationType,
OnDeleteAction,
} from 'twenty-sdk/define';
import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object';
import { PULL_REQUEST_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/objects/pull-request.object';
import { REVIEW_EVENTS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request/fields/review-events-on-pull-request.field';
export const PULL_REQUEST_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'8bd96f9d-fa6b-431a-9264-000097b580f8';
export default defineField({
universalIdentifier: PULL_REQUEST_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'pullRequest',
label: 'Pull Request',
icon: 'IconGitPullRequest',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
REVIEW_EVENTS_ON_PR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.CASCADE,
joinColumnName: 'pullRequestId',
},
});
@@ -0,0 +1,30 @@
import {
defineField,
FieldType,
RelationType,
OnDeleteAction,
} from 'twenty-sdk/define';
import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object';
import { PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/objects/pull-request-review.object';
import { EVENTS_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review/fields/events-on-review.field';
export const REVIEW_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'1bc4170d-e998-4aac-bfea-4a6145d7d707';
export default defineField({
universalIdentifier: REVIEW_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'review',
label: 'Review',
icon: 'IconEye',
relationTargetObjectMetadataUniversalIdentifier:
PULL_REQUEST_REVIEW_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
EVENTS_ON_REVIEW_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'reviewId',
},
});
@@ -0,0 +1,30 @@
import {
defineField,
FieldType,
RelationType,
OnDeleteAction,
} from 'twenty-sdk/define';
import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object';
import { CONTRIBUTOR_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/objects/contributor.object';
import { REVIEW_EVENTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER } from 'src/modules/github/contributor/fields/review-events-on-contributor.field';
export const REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER =
'f5a6b7c8-9d0e-4f1a-b2c3-d4e5f6a7b8c9';
export default defineField({
universalIdentifier: REVIEWER_ON_REVIEW_EVENT_FIELD_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'reviewer',
label: 'Reviewer',
icon: 'IconUser',
relationTargetObjectMetadataUniversalIdentifier:
CONTRIBUTOR_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
REVIEW_EVENTS_ON_CONTRIBUTOR_FIELD_UNIVERSAL_IDENTIFIER,
universalSettings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: OnDeleteAction.SET_NULL,
joinColumnName: 'reviewerId',
},
});
@@ -0,0 +1,25 @@
import { chunkedBatchCreate } from 'src/modules/shared/twenty-client';
import type { PullRequestReviewEventRow } from 'src/modules/github/pull-request-review-event/types/pull-request-review-event-row';
export async function batchUpsertReviewEvents(
items: Array<{
githubReviewId: number;
title: string;
state: string;
submittedAt: string | null;
reviewerId: string | null;
pullRequestId: string;
reviewId?: string | null;
}>,
): Promise<PullRequestReviewEventRow[]> {
return chunkedBatchCreate('createPullRequestReviewEvents', items, {
id: true,
githubReviewId: true,
title: true,
state: true,
submittedAt: true,
reviewerId: true,
pullRequestId: true,
reviewId: true,
}) as Promise<PullRequestReviewEventRow[]>;
}
@@ -0,0 +1,61 @@
import { getClient } from 'src/modules/shared/twenty-client';
export type ReviewEventForPair = {
id: string;
state: string;
submittedAt: string | null;
};
type Edge<T> = { node: T };
type Connection<T> = {
edges: Edge<T>[];
pageInfo: { hasNextPage: boolean; endCursor: string | null };
};
const PAGE_SIZE = 100;
export async function findReviewEventsForPair(
pullRequestId: string,
reviewerId: string | null,
): Promise<ReviewEventForPair[]> {
const client = getClient();
const out: ReviewEventForPair[] = [];
let cursor: string | null = null;
const reviewerFilter =
reviewerId === null
? { reviewerId: { is: 'NULL' } }
: { reviewerId: { eq: reviewerId } };
for (;;) {
const res = await client.query({
pullRequestReviewEvents: {
__args: {
filter: {
and: [{ pullRequestId: { eq: pullRequestId } }, reviewerFilter],
},
orderBy: [{ submittedAt: 'AscNullsLast' }],
first: PAGE_SIZE,
after: cursor,
},
edges: {
node: { id: true, state: true, submittedAt: true },
},
pageInfo: { hasNextPage: true, endCursor: true },
},
});
const conn =
(res.pullRequestReviewEvents as Connection<ReviewEventForPair>) ?? {
edges: [],
pageInfo: { hasNextPage: false, endCursor: null },
};
for (const edge of conn.edges) out.push(edge.node);
if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor) break;
cursor = conn.pageInfo.endCursor;
}
return out;
}
@@ -0,0 +1,15 @@
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
import { GITHUB_FOLDER_UNIVERSAL_IDENTIFIER } from 'src/modules/github/navigation-menu-items/github-folder.navigation-menu-item';
import { PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER } from 'src/modules/github/pull-request-review-event/objects/pull-request-review-event.object';
export default defineNavigationMenuItem({
universalIdentifier: '2a3b4c5d-6e7f-4890-9abc-def012345679',
position: 5,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier:
PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER,
folderUniversalIdentifier: GITHUB_FOLDER_UNIVERSAL_IDENTIFIER,
});
@@ -0,0 +1,48 @@
import type { GitHubReview } from 'src/modules/github/pull-request-review-event/types/github-review';
export type ReviewEventState =
| 'APPROVED'
| 'CHANGES_REQUESTED'
| 'COMMENTED'
| 'DISMISSED';
export type ReviewEventCanonical = {
githubReviewId: number;
title: string;
state: ReviewEventState;
submittedAt: string | null;
};
export type GqlReviewLike = {
databaseId: number;
state: ReviewEventState;
submittedAt: string | null;
author: { login: string } | null;
};
export function buildReviewEventTitle(login: string, state: string): string {
return `${login} \u2014 ${state.toLowerCase()}`;
}
export function reviewEventFromWebhook(
review: GitHubReview,
): ReviewEventCanonical {
return {
githubReviewId: review.id,
title: buildReviewEventTitle(review.user.login, review.state),
state: review.state,
submittedAt: review.submitted_at,
};
}
export function reviewEventFromGraphql(
review: GqlReviewLike,
): ReviewEventCanonical {
const login = review.author?.login ?? 'unknown';
return {
githubReviewId: review.databaseId,
title: buildReviewEventTitle(login, review.state),
state: review.state,
submittedAt: review.submittedAt,
};
}
@@ -0,0 +1,97 @@
import { defineObject, FieldType } from 'twenty-sdk/define';
export const PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER =
'7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d';
export const REVIEW_EVENT_TITLE_FIELD_UNIVERSAL_IDENTIFIER =
'8b9c0d1e-2f3a-4b4c-9d5e-6f7a8b9c0d1e';
export const REVIEW_EVENT_GITHUB_REVIEW_ID_FIELD_UNIVERSAL_IDENTIFIER =
'9c0d1e2f-3a4b-4c5d-ae6f-7a8b9c0d1e2f';
export const REVIEW_EVENT_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'0d1e2f3a-4b5c-4d6e-bf7a-8b9c0d1e2f3a';
enum ReviewEventState {
APPROVED = 'APPROVED',
CHANGES_REQUESTED = 'CHANGES_REQUESTED',
COMMENTED = 'COMMENTED',
DISMISSED = 'DISMISSED',
}
export const REVIEW_EVENT_STATE_FIELD_UNIVERSAL_IDENTIFIER =
'e5eeeaab-4ea9-4f9f-873d-9c1c1473ddb3';
export const PULL_REQUEST_REVIEW_EVENT_CREATED_AT_FIELD_UNIVERSAL_IDENTIFIER =
'fc577b22-c927-5b0e-9d6d-532dd41cf6d2';
export default defineObject({
universalIdentifier: PULL_REQUEST_REVIEW_EVENT_UNIVERSAL_IDENTIFIER,
nameSingular: 'pullRequestReviewEvent',
namePlural: 'pullRequestReviewEvents',
labelSingular: 'Pull Request Review Event',
labelPlural: 'Pull Request Review Events',
icon: 'IconEye',
labelIdentifierFieldMetadataUniversalIdentifier:
REVIEW_EVENT_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: REVIEW_EVENT_TITLE_FIELD_UNIVERSAL_IDENTIFIER,
name: 'title',
type: FieldType.TEXT,
label: 'Title',
icon: 'IconTextCaption',
},
{
universalIdentifier:
REVIEW_EVENT_GITHUB_REVIEW_ID_FIELD_UNIVERSAL_IDENTIFIER,
name: 'githubReviewId',
type: FieldType.NUMBER,
label: 'GitHub Review ID',
icon: 'IconHash',
isUnique: true,
},
{
universalIdentifier: REVIEW_EVENT_STATE_FIELD_UNIVERSAL_IDENTIFIER,
name: 'state',
type: FieldType.SELECT,
label: 'State',
icon: 'IconCircleDot',
options: [
{
value: ReviewEventState.APPROVED,
label: 'Approved',
position: 0,
color: 'green',
},
{
value: ReviewEventState.CHANGES_REQUESTED,
label: 'Changes Requested',
position: 1,
color: 'red',
},
{
value: ReviewEventState.COMMENTED,
label: 'Commented',
position: 2,
color: 'blue',
},
{
value: ReviewEventState.DISMISSED,
label: 'Dismissed',
position: 3,
color: 'gray',
},
],
},
{
universalIdentifier: REVIEW_EVENT_SUBMITTED_AT_FIELD_UNIVERSAL_IDENTIFIER,
name: 'submittedAt',
type: FieldType.DATE_TIME,
label: 'Submitted At',
icon: 'IconCalendar',
isNullable: true,
defaultValue: null,
},
],
});
@@ -0,0 +1,9 @@
import type { GitHubUser } from 'src/modules/github/connector/github-user';
export type GitHubReview = {
id: number;
user: GitHubUser;
state: 'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'DISMISSED';
submitted_at: string;
body: string | null;
};

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