Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,8 @@
},
{
"path": "integrator-default-profile/samples/github-issue-to-google-chat"
},
{
"path": "integrator-default-profile/samples/hubspot-contacts-to-google-sheets"
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"wso2": {
"type": "object",
"properties": {
"hubspot_contacts_to_google_sheet": {
"type": "object",
"properties": {
"hubspotAccessToken": {
"type": "string",
"description": "Legacy app access token used to authenticate with the HubSpot CRM API."
},
"googleClientId": {
"type": "string",
"description": "OAuth 2.0 client ID for authenticating with the Google Sheets API."
},
"googleClientSecret": {
"type": "string",
"description": "OAuth 2.0 client secret for authenticating with the Google Sheets API."
},
"googleRefreshToken": {
"type": "string",
"description": "OAuth 2.0 refresh token used to obtain new access tokens for the Google Sheets API."
},
"googleRefreshUrl": {
"type": "string",
"description": "The token endpoint URL used to refresh Google OAuth 2.0 access tokens."
},
"spreadsheetId": {
"type": "string",
"description": "The unique identifier of the Google Spreadsheet where HubSpot contacts will be synced."
},
"subscriberSheetName": {
"type": "string",
"description": "Name of the worksheet for contacts with lifecycle stage 'Subscriber'."
},
"leadSheetName": {
"type": "string",
"description": "Name of the worksheet for contacts with lifecycle stage 'Lead'."
},
"marketingqualifiedleadSheetName": {
"type": "string",
"description": "Name of the worksheet for contacts with lifecycle stage 'Marketing Qualified Lead'."
},
"salesqualifiedleadSheetName": {
"type": "string",
"description": "Name of the worksheet for contacts with lifecycle stage 'Sales Qualified Lead'."
},
"opportunitySheetName": {
"type": "string",
"description": "Name of the worksheet for contacts with lifecycle stage 'Opportunity'."
},
"customerSheetName": {
"type": "string",
"description": "Name of the worksheet for contacts with lifecycle stage 'Customer'."
},
"evangelistSheetName": {
"type": "string",
"description": "Name of the worksheet for contacts with lifecycle stage 'Evangelist'."
},
"otherSheetName": {
"type": "string",
"description": "Name of the worksheet for contacts with lifecycle stage 'Other'."
},
"defaultSheetName": {
"type": "string",
"description": "Name of the fallback worksheet for contacts whose lifecycle stage does not match any known value."
},
"fields": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of HubSpot contact property names to include as columns in the Google Sheet (e.g. ['email', 'firstname', 'lastname', 'phone'])."
},
"lastSyncTimestamp": {
"type": "string",
"description": "ISO 8601 timestamp used as the starting checkpoint for incremental sync. Leave empty to perform a full sync on the first run."
},
"contactFilterProperty": {
"type": "string",
"description": "HubSpot contact property name to filter synced contacts by (e.g. 'hs_lead_status'). Leave empty to sync all contacts."
},
"contactFilterValue": {
"type": "string",
"description": "Expected value of the filter property. Only contacts matching this value will be synced."
},
"maxRows": {
"type": "integer",
"description": "Maximum number of contacts to process per incremental sync run. Set to 0 for no limit."
},
"syncMode": {
"type": "string",
"description": "Sync strategy: 'upsert' (default) updates existing rows and inserts new ones; 'append' always inserts; 'replace' clears the sheet then inserts all contacts."
}
Comment on lines +90 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Tighten validation for maxRows and syncMode.

Right now negative maxRows values are accepted and behave like “unlimited” at runtime, and any unknown syncMode silently falls back to the upsert branch. Adding schema constraints here makes those misconfigurations fail fast instead of changing sync behavior unexpectedly.

Suggested schema update
             "maxRows": {
               "type": "integer",
+              "minimum": 0,
               "description": "Maximum number of contacts to process per incremental sync run. Set to 0 for no limit."
             },
             "syncMode": {
               "type": "string",
+              "enum": ["upsert", "append", "replace"],
               "description": "Sync strategy: 'upsert' (default) updates existing rows and inserts new ones; 'append' always inserts; 'replace' clears the sheet then inserts all contacts."
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"maxRows": {
"type": "integer",
"description": "Maximum number of contacts to process per incremental sync run. Set to 0 for no limit."
},
"syncMode": {
"type": "string",
"description": "Sync strategy: 'upsert' (default) updates existing rows and inserts new ones; 'append' always inserts; 'replace' clears the sheet then inserts all contacts."
}
"maxRows": {
"type": "integer",
"minimum": 0,
"description": "Maximum number of contacts to process per incremental sync run. Set to 0 for no limit."
},
"syncMode": {
"type": "string",
"enum": ["upsert", "append", "replace"],
"description": "Sync strategy: 'upsert' (default) updates existing rows and inserts new ones; 'append' always inserts; 'replace' clears the sheet then inserts all contacts."
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@integrator-default-profile/samples/hubspot-contacts-to-google-sheets/.choreo/config-schema.json`
around lines 90 - 97, Add validation rules to the JSON schema so maxRows and
syncMode fail-fast on invalid input: update the maxRows property to require an
integer minimum of 0 (and optionally set a sensible default) to disallow
negatives, and constrain syncMode to an enum of allowed strings
["upsert","append","replace"] (and optionally set "upsert" as the default) so
unknown modes cannot silently fall back at runtime; modify the properties named
maxRows and syncMode in the schema accordingly.

},
"additionalProperties": false,
"required": [
"hubspotAccessToken",
"googleClientId",
"googleClientSecret",
"googleRefreshToken",
"spreadsheetId"
]
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
"requiredLevel": 3
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
```mermaid
flowchart TD
A([Start]):::startNode
B[Load Last Sync Timestamp]:::processNode
C[Fetch HubSpot Contacts]:::processNode
D{Contact Filter Enabled?}:::decisionNode
E[Filter Contacts]:::processNode
F[Determine Lifecycle Stage]:::processNode
G[Select Target Google Sheet]:::processNode
SM{Sync Mode?}:::decisionNode
REP[Clear Sheet Data]:::processNode
H[Check Existing Row by Email]:::processNode
I[Write Contact Row]:::processNode
J[Update Last Sync Timestamp]:::processNode
K([End]):::endNode

A --> B
B --> C
C --> D
D -->|Yes| E
D -->|No| F
E --> F
F --> G
G --> SM
SM -->|replace| REP
REP --> I
SM -->|append| I
SM -->|upsert| H
H --> I
I --> J
J --> K

classDef startNode fill:#4CAF50,color:#fff
classDef endNode fill:#F44336,color:#fff
classDef processNode fill:#2196F3,color:#fff
classDef decisionNode fill:#FF9800,color:#fff
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# HubSpot Contacts to Google Sheets

## What It Does

- Fetches contacts from HubSpot CRM Contacts API
- Routes contacts to sheet tabs based on lifecycle stage
- Upserts rows using email as the unique key
- Supports incremental sync and optional filtering
- Runs once per execution (scheduling is handled externally)

<details>
<summary>HubSpot Setup</summary>

1. Sign in to HubSpot.
2. Go to Settings > Integrations > Legacy Apps.
3. Create a Legacy app and enable the scope `crm.objects.contacts.read`.
4. Obtain the generated access token.
Comment on lines +14 to +17

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Align the HubSpot setup steps with the auth flow this sample is meant to support.

Lines 15-17 still direct users to Legacy Apps, but the sample is described as using a Private App token. Please update these steps—and the matching token description in .choreo/config-schema.json—so the setup path is consistent for deployers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@integrator-default-profile/samples/hubspot-contacts-to-google-sheets/.choreo/instructions.md`
around lines 14 - 17, Update the HubSpot setup steps that currently reference
"Legacy Apps" and the generated access token so they describe creating a Private
App instead: replace steps that say "Go to Settings > Integrations > Legacy
Apps" and "Create a Legacy app and enable the scope `crm.objects.contacts.read`"
with instructions to create a Private App (Settings > Integrations > Private
Apps), add the CRM Contacts scope (crm.objects.contacts.read), and copy the
Private App access token; also update the matching token description in
.choreo/config-schema.json to mention "Private App access token" (and the
required scope `crm.objects.contacts.read`) so the README and config-schema.json
are consistent.


</details>

<details>
<summary>Google Sheets Setup Guide</summary>

1. A Google Cloud project with Google Sheets API enabled
2. OAuth2 credentials:
- Client ID
- Client Secret
- Refresh Token
3. Scopes Required
- `https://www.googleapis.com/auth/drive`
- `https://www.googleapis.com/auth/spreadsheets`
This integration uses refresh token flow for auth. [Learn how to Develop on Google Workspace](https://developers.google.com/workspace/guides/get-started).

</details>

<details>
<summary>Spreadsheet Setup</summary>

1. Create a Google Spreadsheet.
2. Copy the spreadsheet ID from `https://docs.google.com/spreadsheets/d/<spreadsheetId>/edit`.
3. Use that value as `spreadsheetId`.
4. The integration creates missing lifecycle-stage tabs automatically.

Default sheet mapping:

- Subscriber -> `Subscribers`
- Lead -> `Leads`
- Marketing Qualified Lead -> `MQLs`
- Sales Qualified Lead -> `SQLs`
- Opportunity -> `Opportunities`
- Customer -> `Customers`
- Evangelist -> `Evangelists`
- Other -> `Others`
- Unrecognized/empty -> `Sheet1`

</details>

<details>
<summary>Additional Configuration</summary>

- `fields`: HubSpot properties exported as columns
- `syncMode`: `upsert` (default), `append`, or `replace`
- `maxRows`: max contacts per run (`0` means unlimited)
- `lastSyncTimestamp`: optional initial checkpoint
- `contactFilterProperty` / `contactFilterValue`: optional HubSpot filter

</details>
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Ballerina generates this directory during the compilation of a package.
# It contains compiler-generated artifacts and the final executable if this is an application package.
target/

# Ballerina maintains the compiler-generated source code here.
# Remove this if you want to commit generated sources.
generated/

# Contains configuration values used during development time.
# See https://ballerina.io/learn/provide-values-to-configurable-variables/ for more details.
Config.toml

# Dependency definitions
Dependencies.toml

last_sync_timestamp.txt

.DS_Store

.vscode
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[package]
org = "wso2"
name = "hubspot_contacts_to_google_sheet"
version = "0.1.0"
title = "hubspot-contacts-to-google-sheet"
distribution = "2201.13.1"
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# HubSpot API
hubspotAccessToken = "<your-hubspot-legacy-app-token>"

# Google OAuth2
googleClientId = "<your-google-client-id>"
googleClientSecret = "<your-google-client-secret>"
googleRefreshToken = "<your-google-refresh-token>"

# Google Spreadsheet target
spreadsheetId = "<your-google-spreadsheet-id>"

# Lifecycle routing sheets
# Each HubSpot lifecycle stage routes to its own sheet by default.
# To merge multiple stages into one sheet, set them to the same name.
# To send all contacts to a single sheet, set all names to the same value.
subscriberSheetName = "Subscribers"
leadSheetName = "Leads"
marketingqualifiedleadSheetName = "MQLs"
salesqualifiedleadSheetName = "SQLs"
opportunitySheetName = "Opportunities"
customerSheetName = "Customers"
evangelistSheetName = "Evangelists"
otherSheetName = "Others"
defaultSheetName = "Sheet1"

# Limits
maxRows = 0

# Sync mode
# "upsert" - Update existing row if email matches, insert if not (default)
# "append" - Always insert a new row, never check for duplicates
# "replace" - Clear the sheet first, then write all contacts fresh
syncMode = "upsert"

# Optional contact filter (leave empty to export all)
contactFilterProperty = ""
contactFilterValue = ""

# Example filter:
# contactFilterProperty = "lifecyclestage"
# contactFilterValue = "customer"
Loading