Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 30 additions & 20 deletions .agents/skills/adding-inbox-sources/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,19 +49,24 @@ A new credential-based source needs **zero form code** — just route its
`sourceType` and the `schemas` to sync.

- Endpoint: `GET /api/environments/{projectId}/external_data_sources/wizard/?source_type=<Type>` → `Record<string, SourceConfig>`. Client method: `PostHogAPIClient.getExternalDataSourceConfigs`. Hook: `useSourceConfig(sourceType)`.
- `SourceConfig.fields` is a union: `input` (text/email/password/url/number/…), `select`, `switch-group`, `oauth`, `ssh-tunnel`, `file-upload`. `DynamicSourceSetup` renders input/select/switch-group and builds the `createExternalDataSource` payload from field `name`s. The backend is the single source of truth for field names/labels/required/secret, so forms never drift.
- `SourceConfig.fields` is a union: `input` (text/email/password/url/number/…), `select`, `switch-group`, `oauth`, `oauth-account-select`, `ssh-tunnel`, `file-upload`. `DynamicSourceSetup` renders input/select/switch-group **and `oauth`/`oauth-account-select`** generically, and builds the `createExternalDataSource` payload from field `name`s. The backend is the single source of truth for field names/labels/required/secret, so forms never drift.
- The field `name`s become the `payload` keys — so you no longer hand-maintain them. (Jira → `subdomain`, `email`, `api_token`; all `secret:false` except the token.)

Three cases still need bespoke handling (the generic renderer flags `oauth`/`ssh-tunnel`/`file-upload` as unsupported and disables submit):

| Case | When | Existing example |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| **Generic dynamic form** | Credential inputs only (Jira, Zendesk, Freshdesk, Front, Gorgias, Sentry, GitLab). | `DynamicSourceSetup` (route the switch case to it) |
| **OAuth + integration polling** | Source authenticates via OAuth grant (Intercom `kind=intercom`); poll `getIntegrationsForProject` for the `kind`, pass `<source>_integration_id`. | `LinearSetup` |
| **Deep-link OAuth + resource picker** | User must pick a specific resource (repo/board) during setup. | `GitHubSetup` |

`ZendeskSetup`/`PgAnalyzeSetup` are the _old_ hardcoded forms — leave them or
migrate them to `DynamicSourceSetup` opportunistically; don't add new ones.
**OAuth sources need NO bespoke form.** `DynamicSourceSetup` renders the `oauth` field (a
connect button that starts the flow by `kind` and polls `getIntegrationsForProject` for the new
integration, writing its id into `payload[<name>]`) and the `oauth-account-select` field (a
server-side-searched picker over the integration's resources) generically. The connect flow is
started by the generic, `kind`-parameterized `integration` tRPC router
(`packages/host-router/src/routers/integration.router.ts` → `IntegrationService`), so any
provider in `OauthIntegration.supported_kinds` works with no per-kind service or router. So an
OAuth source (Intercom, HubSpot, Salesforce, …) is the same one-registry-entry change as a
credential source — route its `DataSourceSetup` case to `DynamicSourceSetup`.

Only two field types still lack a generic renderer (disable submit): `ssh-tunnel` and
`file-upload`. Route those to a bespoke form. Resource pickers that must run _after_ OAuth
(GitHub's repo picker) are handled by `oauth-account-select`; the old `GitHubSetup`/`ZendeskSetup`
/`PgAnalyzeSetup` hardcoded forms remain only for historical reasons — leave them or migrate to
`DynamicSourceSetup` opportunistically; don't add new ones.

Supported OAuth `kind` values (posthog `OauthIntegration.supported_kinds`,
`posthog/models/integration.py`): `slack, salesforce, hubspot, google-ads,
Expand All @@ -86,7 +91,7 @@ Verify exact `source_type` + `payload` key names against the posthog
| Freshdesk | `Freshdesk` | `tickets` | API key | `DynamicSourceSetup` | `subdomain`, `api_key` |
| Front | `Front` | `conversations` | API token | `DynamicSourceSetup` | `api_token` |
| Gorgias | `Gorgias` | `tickets` | API key | `DynamicSourceSetup` | `gorgias_domain`, `email`, `api_key` |
| Intercom | `Intercom` | `conversations` | OAuth (`kind=intercom`) | Linear | `intercom_integration_id` |
| Intercom | `Intercom` | `conversations` | OAuth (`kind=intercom`) | `DynamicSourceSetup` | `intercom_integration_id` |

(Zendesk `tickets`, GitHub `issues`, Linear `issues`, pganalyze `issues`+`servers`,
and Jira `issues` are already shipped — copy them, don't re-add. The Jira row above
Expand Down Expand Up @@ -119,18 +124,23 @@ source-list-relevant is a place you must add the new product. The canonical list
8. `packages/core/src/inbox/signalSourceService.ts` — mirror `SOURCE_TYPE_MAP`, `DATA_WAREHOUSE_SOURCES`, `ALL_SOURCE_PRODUCTS`, `computeSourceValues` init, plus `WarehouseSourceProduct`/`SignalSourceValues`.
9. `packages/core/src/inbox/dataSourceService.ts` — `DataSourceType`, `REQUIRED_SCHEMAS`, a `createXDataSource` method.

### OAuth plumbing — **only** for OAuth sources (Intercom); API-key sources skip this
### OAuth plumbing — NOT needed per source anymore

10. `packages/core/src/integrations/<source>.ts` — `XIntegrationService.startFlow(region, projectId)` (clone `linear.ts`).
11. `packages/core/src/integrations/identifiers.ts` — new `X_INTEGRATION_SERVICE` symbol.
12. `packages/core/src/integrations/integrations.module.ts` — bind it.
13. `packages/host-router/src/routers/<source>-integration.router.ts` — clone `linear-integration.router.ts`.
14. `packages/host-router/src/router.ts` — import + register the router in `appRouter`.
There is now a **generic, `kind`-parameterized** integration flow: `IntegrationService`
(`packages/core/src/integrations/integration.ts`) + the `integration` tRPC router
(`packages/host-router/src/routers/integration.router.ts`). `DynamicSourceSetup` starts any
OAuth flow through it via the field's `kind`. So a new OAuth source needs **no** per-kind
service, symbol, or router — do not clone `linear.ts`/`linear-integration.router.ts` per source.
(The old per-kind linear/slack/github routers still exist for other callers; leave them.)

### Setup-form specifics

- **Credential source:** route the `DataSourceSetup` switch case to `DynamicSourceSetup` (above). Nothing else — the fields come from the wizard endpoint.
- **OAuth form:** clone `LinearSetup`. Change the `kind` matched in the poll loop and the `<source>_integration_id` payload key; swap `trpc.linearIntegration.startFlow` for the new router.
- **Credential source:** route the `DataSourceSetup` switch case to `DynamicSourceSetup`. Nothing else — the fields come from the wizard endpoint.
- **OAuth source:** also just route to `DynamicSourceSetup`. Its connect-form schema carries the
`oauth` field (and, if the provider needs a resource picked, an `oauth-account-select` field),
which `DynamicSourceSetup` renders generically — connect button + integration polling + account
picker, all by `kind`. No bespoke form. The provider must be in
`OauthIntegration.supported_kinds`.
- Issues sources (`github`/`linear`/`jira`) force `issues` to `full_refresh` in `ensureRequiredTableSyncing` (`useSignalSourceToggles.ts`) — add the new product to that condition if it syncs an `issues` table (issues get edited/closed, so incremental append would miss updates). Ticket/conversation sources only force `should_sync=true`.

### Verify
Expand Down
35 changes: 34 additions & 1 deletion products/signals/backend/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,35 @@ class JudgemeReviewsReviewSignalInput(SignalInputBase):
extra: JudgemeReviewsReviewSignalExtra


# ── OAuth-connected support sources ───────────────────────────────────────────────


class IntercomTicketSignalExtra(SignalExtraBase):
state: str | None
priority: str | None
admin_assignee_id: str | None
created_at: str | None


class IntercomTicketSignalInput(SignalInputBase):
source_type: Literal[SignalSourceType.TICKET]
source_product: Literal[SignalSourceProduct.INTERCOM]
extra: IntercomTicketSignalExtra


class HubspotTicketSignalExtra(SignalExtraBase):
hs_ticket_priority: str | None
hs_pipeline_stage: str | None
hs_ticket_category: str | None
createdate: str | None


class HubspotTicketSignalInput(SignalInputBase):
source_type: Literal[SignalSourceType.TICKET]
source_product: Literal[SignalSourceProduct.HUBSPOT]
extra: HubspotTicketSignalExtra


# ── Union over all signal variants ──────────────────────────────────────────────
# Discrimination is by the composite (source_product, source_type) pair, resolved via
# SIGNAL_VARIANT_LOOKUP below — a single-field pydantic discriminator can't express it
Expand Down Expand Up @@ -896,7 +925,9 @@ class JudgemeReviewsReviewSignalInput(SignalInputBase):
| RetentlyFeedbackSignalInput
| AppfiguresReviewSignalInput
| AppfollowReviewSignalInput
| JudgemeReviewsReviewSignalInput,
| JudgemeReviewsReviewSignalInput
| IntercomTicketSignalInput
| HubspotTicketSignalInput,
Field(union_mode="left_to_right"),
]

Expand Down Expand Up @@ -948,6 +979,8 @@ class JudgemeReviewsReviewSignalInput(SignalInputBase):
AppfiguresReviewSignalInput,
AppfollowReviewSignalInput,
JudgemeReviewsReviewSignalInput,
IntercomTicketSignalInput,
HubspotTicketSignalInput,
)


Expand Down
44 changes: 44 additions & 0 deletions products/signals/backend/emission/hubspot_tickets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""Signal emitter for hubspot `tickets` (record kind: ticket).

HubSpot is an OAuth-connected source, but its `tickets` table is flat, so the shared factory
applies. `hs_object_id` is the record id, `subject`/`content` the text, `createdate` an ISO
string. Only the ticket properties the user selected during setup are synced; the defaults
include subject/content/createdate.
"""

from products.signals.backend.emission._common import make_flat_emitter
from products.signals.backend.emission._prompts import TICKET_ACTIONABILITY_PROMPT, TICKET_SUMMARIZATION_PROMPT
from products.signals.backend.emission.fetchers.data_warehouse import data_warehouse_record_fetcher
from products.signals.backend.emission.registry import SignalSourceTableConfig

HUBSPOT_FIELDS = (
"hs_object_id",
"subject",
"content",
"hs_ticket_priority",
"hs_pipeline_stage",
"hs_ticket_category",
"createdate",
)

HUBSPOT_CONFIG = SignalSourceTableConfig(
source_product="hubspot",
source_type="ticket",
emitter=make_flat_emitter(
source_product="hubspot",
source_type="ticket",
id_field="hs_object_id",
title_field="subject",
body_field="content",
extra_fields=("hs_ticket_priority", "hs_pipeline_stage", "hs_ticket_category", "createdate"),
),
record_fetcher=data_warehouse_record_fetcher,
partition_field="createdate",
partition_field_is_datetime_string=True,
fields=HUBSPOT_FIELDS,
max_records=200,
first_sync_lookback_days=1,
actionability_prompt=TICKET_ACTIONABILITY_PROMPT,
summarization_prompt=TICKET_SUMMARIZATION_PROMPT,
description_summarization_threshold_chars=2000,
)
103 changes: 103 additions & 0 deletions products/signals/backend/emission/intercom_conversations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Signal emitter for intercom `conversations` (record kind: ticket).

Intercom is OAuth-connected and its `conversations` table isn't flat, so this is a bespoke
emitter (like Jira's):
- The opening message isn't a top-level column — it lives in the `source` JSON blob
(`source.body`, HTML). `title` is often null, so we fall back to the stripped source body.
The rest of the thread is in `conversation_parts` (a separate table we don't sync).
- `created_at` is a Unix epoch (seconds), so the partition cursor wraps it in
`fromUnixTimestamp(...)`. Verify the stored type on the first real sync.
"""

import re
from typing import Any

from structlog import get_logger

from products.signals.backend.emission._prompts import TICKET_ACTIONABILITY_PROMPT, TICKET_SUMMARIZATION_PROMPT
from products.signals.backend.emission.fetchers.data_warehouse import data_warehouse_record_fetcher
from products.signals.backend.emission.registry import SignalEmitterOutput, SignalSourceTableConfig

logger = get_logger(__name__)

INTERCOM_IGNORED_STATES = ("closed",)

FIELDS = (
"id",
"title",
"state",
"priority",
"admin_assignee_id",
"created_at",
"JSONExtractString(source, 'body') AS source_body",
)

_TAG_RE = re.compile(r"<[^>]+>")


def _strip_html(raw: Any) -> str:
if not raw or not isinstance(raw, str):
return ""
return _TAG_RE.sub(" ", raw).replace("&nbsp;", " ").strip()


def intercom_conversation_emitter(team_id: int, record: dict[str, Any]) -> SignalEmitterOutput | None:
try:
conversation_id = record["id"]
except KeyError as e:
msg = f"Intercom conversation record missing required field {e}"
logger.exception(msg, record=record, team_id=team_id, signals_type="data-import-signals")
raise ValueError(msg) from e
if not conversation_id:
msg = f"Intercom conversation record has empty id: {conversation_id!r}"
logger.exception(msg, record=record, team_id=team_id, signals_type="data-import-signals")
raise ValueError(msg)

title = record.get("title")
body = _strip_html(record.get("source_body"))
# Prefer an explicit subject; otherwise use the opening message. Skip conversations with no
# readable text (auto-created/empty ones can't produce a useful signal).
description = title or body
if not description:
logger.info(
"Ignoring Intercom conversation without text",
team_id=team_id,
signals_type="data-import-signals",
)
return None
if title and body:
description = f"{title}\n{body}"

return SignalEmitterOutput(
source_product="intercom",
source_type="ticket",
source_id=str(conversation_id),
description=description,
weight=1.0,
extra={
"state": record.get("state") or None,
"priority": record.get("priority") or None,
"admin_assignee_id": (
str(record["admin_assignee_id"]) if record.get("admin_assignee_id") is not None else None
),
"created_at": str(record["created_at"]) if record.get("created_at") is not None else None,
},
)


INTERCOM_CONFIG = SignalSourceTableConfig(
source_product="intercom",
source_type="ticket",
emitter=intercom_conversation_emitter,
record_fetcher=data_warehouse_record_fetcher,
# created_at is a Unix epoch (seconds). Wrap so the cursor compares as a datetime; verify the
# stored column type on the first real sync (it may already be a DateTime).
partition_field="fromUnixTimestamp(toUInt32(created_at))",
fields=FIELDS,
where_clause=f"state NOT IN ({', '.join(repr(s) for s in INTERCOM_IGNORED_STATES)})",
max_records=200,
first_sync_lookback_days=1,
actionability_prompt=TICKET_ACTIONABILITY_PROMPT,
summarization_prompt=TICKET_SUMMARIZATION_PROMPT,
description_summarization_threshold_chars=2000,
)
5 changes: 5 additions & 0 deletions products/signals/backend/emission/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ def _register_all_emitters() -> None:
from products.signals.backend.emission.gitlab_issues import GITLAB_CONFIG
from products.signals.backend.emission.gorgias_tickets import GORGIAS_CONFIG
from products.signals.backend.emission.honeybadger_faults import HONEYBADGER_CONFIG
from products.signals.backend.emission.hubspot_tickets import HUBSPOT_CONFIG
from products.signals.backend.emission.intercom_conversations import INTERCOM_CONFIG
from products.signals.backend.emission.jira_issues import JIRA_ISSUES_CONFIG
from products.signals.backend.emission.judgeme_reviews_reviews import JUDGEME_REVIEWS_CONFIG
from products.signals.backend.emission.kustomer_conversations import KUSTOMER_CONFIG
Expand Down Expand Up @@ -199,6 +201,9 @@ def _register_all_emitters() -> None:
register_signal_source(ExternalDataSourceType.APPFIGURES, "reviews", APPFIGURES_CONFIG)
register_signal_source(ExternalDataSourceType.APPFOLLOW, "reviews", APPFOLLOW_CONFIG)
register_signal_source(ExternalDataSourceType.JUDGEMEREVIEWS, "reviews", JUDGEME_REVIEWS_CONFIG)
# OAuth-connected support sources (record kind: ticket)
register_signal_source(ExternalDataSourceType.INTERCOM, "conversations", INTERCOM_CONFIG)
Comment thread
Gilbert09 marked this conversation as resolved.
register_signal_source(ExternalDataSourceType.HUBSPOT, "tickets", HUBSPOT_CONFIG)


_register_all_emitters()
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
(ExternalDataSourceType.APPFIGURES, "reviews", "appfigures", "review"),
(ExternalDataSourceType.APPFOLLOW, "reviews", "appfollow", "review"),
(ExternalDataSourceType.JUDGEMEREVIEWS, "reviews", "judgeme_reviews", "review"),
# OAuth-connected support sources
(ExternalDataSourceType.INTERCOM, "conversations", "intercom", "ticket"),
(ExternalDataSourceType.HUBSPOT, "tickets", "hubspot", "ticket"),
]

IDS = [product for _, _, product, _ in TIER1_SOURCES]
Expand Down
5 changes: 5 additions & 0 deletions products/signals/backend/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ class SignalSourceProduct(StrEnum):
APPFIGURES = "appfigures"
APPFOLLOW = "appfollow"
JUDGEME_REVIEWS = "judgeme_reviews"
# OAuth-connected support sources
INTERCOM = "intercom"
HUBSPOT = "hubspot"


class SignalSourceType(StrEnum):
Expand Down Expand Up @@ -141,6 +144,8 @@ class SignalSourceType(StrEnum):
SignalSourceProduct.APPFIGURES: "Appfigures",
SignalSourceProduct.APPFOLLOW: "AppFollow",
SignalSourceProduct.JUDGEME_REVIEWS: "Judge.me",
SignalSourceProduct.INTERCOM: "Intercom",
SignalSourceProduct.HUBSPOT: "HubSpot",
}

# The Django model's `source_product` choices, frozen-equivalent to the prior nested TextChoices so
Expand Down
Loading
Loading