mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(composio): add ClickUp provider for Memory Tree ingest
Mirrors the existing gmail / notion / slack ComposioProvider layout to
periodically ingest the connected user's ClickUp tasks into the Memory
Tree. Until now ClickUp was exposed only as a Composio toolkit slug
(tool-calling surface) but never had its workspace state ingested into
long-term memory.
Implementation follows the Notion provider's incremental-sync model:
1. SyncState load + daily request budget check.
2. Resolve the authorized user's numeric ID
(CLICKUP_GET_AUTHORIZED_USER) so we can scope by `assignees`.
3. Resolve visible workspaces
(CLICKUP_GET_AUTHORIZED_TEAMS_WORKSPACES).
4. Per workspace, page through CLICKUP_GET_FILTERED_TEAM_TASKS
sorted by `date_updated` DESC with `assignees: [user_id]`. Stop
each workspace early when we hit tasks older than the cursor or
when a short page signals end-of-results.
5. Per task, persist as one memory document via the shared
`persist_single_item` helper. Dedupe by composite
`task_id@date_updated` so edited tasks re-ingest.
6. Advance cursor to newest `date_updated` seen across all
workspaces, record `last_sync_at_ms`, save state.
Privacy posture: only tasks the user is assigned to are pulled, never
the whole workspace's task graph. This mirrors the
"fetch-what-the-user-sees" stance gmail / notion already take and
avoids accidentally ingesting other teammates' private tasks.
Files added (1029 LOC):
- composio/providers/clickup/mod.rs — module wiring (22)
- composio/providers/clickup/provider.rs — ClickUpProvider impl (509)
- composio/providers/clickup/sync.rs — payload helpers (229)
- composio/providers/clickup/tests.rs — unit tests (145)
- composio/providers/clickup/tools.rs — CLICKUP_CURATED (124)
Files modified:
- composio/providers/mod.rs — `pub mod clickup;`
- composio/providers/registry.rs — register in init_default_providers
Tests: 31 new ClickUp unit tests pass (sync helpers, workspace
discovery, dedup key shape, trait metadata). All 262 existing
`composio::providers` tests continue to pass (no regression on
gmail / notion / slack).
Verified locally:
- `cargo check --lib` clean (pre-existing warnings only)
- `cargo test --lib composio::providers` 262/262 pass
- `cargo test --lib clickup` 31/31 pass
- `cargo fmt --check` clean
- `cargo clippy --lib --no-deps` no new warnings
Closes #2288
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
//! ClickUp Composio provider — incremental Memory Tree ingest for
|
||||
//! tasks owned by (or assigned to) the connected user.
|
||||
//!
|
||||
//! Mirrors the [`crate::openhuman::composio::providers::notion`] layout
|
||||
//! so anyone familiar with Notion/Slack ingestion can read this without
|
||||
//! re-learning a new shape:
|
||||
//!
|
||||
//! - `provider.rs` — `impl ComposioProvider for ClickUpProvider`
|
||||
//! - `sync.rs` — payload-shape helpers (results extraction, title)
|
||||
//! - `tools.rs` — `CLICKUP_CURATED` whitelist of Composio actions
|
||||
//! - `tests.rs` — unit tests for the helpers + trait metadata
|
||||
//!
|
||||
//! Issue: #2288.
|
||||
|
||||
mod provider;
|
||||
mod sync;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub mod tools;
|
||||
|
||||
pub use provider::ClickUpProvider;
|
||||
pub use tools::CLICKUP_CURATED;
|
||||
@@ -0,0 +1,509 @@
|
||||
//! ClickUp provider — incremental sync of tasks assigned to the
|
||||
//! authenticated user, with per-item persistence into the Memory Tree.
|
||||
//!
|
||||
//! On each sync pass:
|
||||
//!
|
||||
//! 1. Load persistent [`SyncState`] from the KV store.
|
||||
//! 2. Check the daily request budget — bail early if exhausted.
|
||||
//! 3. If we don't yet know the user's numeric ID, call
|
||||
//! `CLICKUP_GET_AUTHORIZED_USER` and cache the result in memory
|
||||
//! (it doesn't change for the lifetime of the connection).
|
||||
//! 4. If we don't yet know which workspaces (teams) the connection
|
||||
//! can see, call `CLICKUP_GET_AUTHORIZED_TEAMS_WORKSPACES` and
|
||||
//! cache the list.
|
||||
//! 5. For each workspace, page through
|
||||
//! `CLICKUP_GET_FILTERED_TEAM_TASKS` filtered to the user as
|
||||
//! assignee, sorted by `date_updated` descending. Stop a workspace
|
||||
//! early once we hit tasks older than the cursor.
|
||||
//! 6. For each task, persist as a single memory document if it's new
|
||||
//! *or* edited since the last sync.
|
||||
//! 7. Advance the cursor to the newest `date_updated` seen and save.
|
||||
//!
|
||||
//! Privacy posture: we only pull tasks the user is assigned to, never
|
||||
//! the whole workspace's task graph. This mirrors the
|
||||
//! "fetch-what-the-user-sees" model `gmail` / `notion` already follow
|
||||
//! and avoids accidentally ingesting other teammates' private tasks.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use super::sync;
|
||||
use crate::openhuman::composio::providers::sync_state::{persist_single_item, SyncState};
|
||||
use crate::openhuman::composio::providers::{
|
||||
pick_str, ComposioProvider, CuratedTool, ProviderContext, ProviderUserProfile, SyncOutcome,
|
||||
SyncReason,
|
||||
};
|
||||
|
||||
pub(crate) const ACTION_GET_AUTHORIZED_USER: &str = "CLICKUP_GET_AUTHORIZED_USER";
|
||||
pub(crate) const ACTION_GET_AUTHORIZED_TEAMS_WORKSPACES: &str =
|
||||
"CLICKUP_GET_AUTHORIZED_TEAMS_WORKSPACES";
|
||||
pub(crate) const ACTION_GET_FILTERED_TEAM_TASKS: &str = "CLICKUP_GET_FILTERED_TEAM_TASKS";
|
||||
|
||||
/// Page size per API call. ClickUp's filtered-team-tasks endpoint
|
||||
/// returns up to 100 tasks per page; we ask for a smaller window on
|
||||
/// steady-state syncs to keep response sizes bounded.
|
||||
const PAGE_SIZE: u32 = 50;
|
||||
|
||||
/// Larger initial-sync page size, used immediately after OAuth so the
|
||||
/// first backfill catches up faster.
|
||||
const INITIAL_PAGE_SIZE: u32 = 100;
|
||||
|
||||
/// Maximum pages (per workspace) per sync pass before yielding. Caps
|
||||
/// initial backfill churn — anything beyond this rolls over to the
|
||||
/// next sync interval.
|
||||
const MAX_PAGES_PER_WORKSPACE: u32 = 20;
|
||||
|
||||
/// Paths for extracting a task's unique ID. Composio sometimes wraps
|
||||
/// the upstream payload under `data`, so we check both shapes.
|
||||
const TASK_ID_PATHS: &[&str] = &["id", "data.id", "task_id", "data.task_id"];
|
||||
|
||||
pub struct ClickUpProvider;
|
||||
|
||||
impl ClickUpProvider {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ClickUpProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ComposioProvider for ClickUpProvider {
|
||||
fn toolkit_slug(&self) -> &'static str {
|
||||
"clickup"
|
||||
}
|
||||
|
||||
fn curated_tools(&self) -> Option<&'static [CuratedTool]> {
|
||||
Some(super::tools::CLICKUP_CURATED)
|
||||
}
|
||||
|
||||
fn sync_interval_secs(&self) -> Option<u64> {
|
||||
// 30 minutes — same cadence as Notion. ClickUp tasks change
|
||||
// more slowly than chat but faster than email, so this is in
|
||||
// the middle.
|
||||
Some(30 * 60)
|
||||
}
|
||||
|
||||
async fn fetch_user_profile(
|
||||
&self,
|
||||
ctx: &ProviderContext,
|
||||
) -> Result<ProviderUserProfile, String> {
|
||||
tracing::debug!(
|
||||
connection_id = ?ctx.connection_id,
|
||||
"[composio:clickup] fetch_user_profile via {ACTION_GET_AUTHORIZED_USER}"
|
||||
);
|
||||
|
||||
let resp = ctx
|
||||
.execute(ACTION_GET_AUTHORIZED_USER, Some(json!({})))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
format!("[composio:clickup] {ACTION_GET_AUTHORIZED_USER} failed: {e:#}")
|
||||
})?;
|
||||
|
||||
if !resp.successful {
|
||||
let err = resp
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "provider reported failure".to_string());
|
||||
return Err(format!(
|
||||
"[composio:clickup] {ACTION_GET_AUTHORIZED_USER}: {err}"
|
||||
));
|
||||
}
|
||||
|
||||
// Composio's wrapping puts ClickUp's `{user: {…}}` payload at
|
||||
// `data` or `data.user`. We probe both — `pick_str` walks dotted
|
||||
// paths so `user.username` and `data.user.username` both work.
|
||||
let data = &resp.data;
|
||||
let display_name = pick_str(data, &["user.username", "data.user.username", "username"]);
|
||||
let email = pick_str(data, &["user.email", "data.user.email", "email"]);
|
||||
let username = sync::extract_user_id(data);
|
||||
let avatar_url = pick_str(
|
||||
data,
|
||||
&[
|
||||
"user.profilePicture",
|
||||
"data.user.profilePicture",
|
||||
"profilePicture",
|
||||
],
|
||||
);
|
||||
let profile_url = None;
|
||||
|
||||
Ok(ProviderUserProfile {
|
||||
toolkit: "clickup".to_string(),
|
||||
connection_id: ctx.connection_id.clone(),
|
||||
display_name,
|
||||
email,
|
||||
username,
|
||||
avatar_url,
|
||||
profile_url,
|
||||
extras: data.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn sync(&self, ctx: &ProviderContext, reason: SyncReason) -> Result<SyncOutcome, String> {
|
||||
let started_at_ms = sync::now_ms();
|
||||
let connection_id = ctx
|
||||
.connection_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
tracing::info!(
|
||||
connection_id = %connection_id,
|
||||
reason = reason.as_str(),
|
||||
"[composio:clickup] incremental sync starting"
|
||||
);
|
||||
|
||||
// ── Step 1: load persistent sync state ──────────────────────
|
||||
let Some(memory) = ctx.memory_client() else {
|
||||
return Err("[composio:clickup] memory client not ready".to_string());
|
||||
};
|
||||
let mut state = SyncState::load(&memory, "clickup", &connection_id).await?;
|
||||
|
||||
// ── Step 2: check daily budget ──────────────────────────────
|
||||
if state.budget_exhausted() {
|
||||
tracing::info!(
|
||||
connection_id = %connection_id,
|
||||
"[composio:clickup] daily request budget exhausted, skipping sync"
|
||||
);
|
||||
return Ok(SyncOutcome {
|
||||
toolkit: "clickup".to_string(),
|
||||
connection_id: Some(connection_id),
|
||||
reason: reason.as_str().to_string(),
|
||||
items_ingested: 0,
|
||||
started_at_ms,
|
||||
finished_at_ms: sync::now_ms(),
|
||||
summary: "clickup sync skipped: daily budget exhausted".to_string(),
|
||||
details: json!({ "budget_exhausted": true }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Step 3: resolve the authenticated user's numeric ID ─────
|
||||
//
|
||||
// ClickUp's "filtered team tasks" endpoint accepts an
|
||||
// `assignees` filter as a list of user IDs. We need the
|
||||
// *current* user's ID to scope the sync to "my tasks" rather
|
||||
// than "everyone's tasks". The ID is stable for the lifetime
|
||||
// of the OAuth connection, so we only fetch it once per sync
|
||||
// pass (and cheaply re-fetch each pass — Composio caches and
|
||||
// the call is sub-100ms).
|
||||
let user_id = match self.resolve_user_id(ctx, &mut state).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
let _ = state.save(&memory).await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Step 4: resolve which workspaces (teams) to iterate ─────
|
||||
let workspaces = match self.resolve_workspaces(ctx, &mut state).await {
|
||||
Ok(ws) => ws,
|
||||
Err(e) => {
|
||||
let _ = state.save(&memory).await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
if workspaces.is_empty() {
|
||||
tracing::info!(
|
||||
connection_id = %connection_id,
|
||||
"[composio:clickup] no workspaces visible to this connection; nothing to sync"
|
||||
);
|
||||
state.save(&memory).await?;
|
||||
return Ok(SyncOutcome {
|
||||
toolkit: "clickup".to_string(),
|
||||
connection_id: Some(connection_id),
|
||||
reason: reason.as_str().to_string(),
|
||||
items_ingested: 0,
|
||||
started_at_ms,
|
||||
finished_at_ms: sync::now_ms(),
|
||||
summary: "clickup sync: no workspaces visible".to_string(),
|
||||
details: json!({ "workspaces_visible": 0 }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Step 5: paginated incremental fetch per workspace ───────
|
||||
let page_size = match reason {
|
||||
SyncReason::ConnectionCreated => INITIAL_PAGE_SIZE,
|
||||
_ => PAGE_SIZE,
|
||||
};
|
||||
|
||||
let mut total_fetched: usize = 0;
|
||||
let mut total_persisted: usize = 0;
|
||||
let mut newest_updated: Option<String> = None;
|
||||
|
||||
'workspaces: for workspace_id in &workspaces {
|
||||
for page_num in 0..MAX_PAGES_PER_WORKSPACE {
|
||||
if state.budget_exhausted() {
|
||||
tracing::info!(
|
||||
workspace_id = %workspace_id,
|
||||
page = page_num,
|
||||
"[composio:clickup] budget exhausted mid-sync, stopping pagination"
|
||||
);
|
||||
break 'workspaces;
|
||||
}
|
||||
|
||||
let args = json!({
|
||||
"team_id": workspace_id,
|
||||
"assignees": [user_id.clone()],
|
||||
"order_by": "updated",
|
||||
"reverse": true,
|
||||
"page": page_num,
|
||||
"page_size": page_size,
|
||||
// Include subtasks so per-list "checklist" style work
|
||||
// also reaches Memory Tree.
|
||||
"subtasks": true,
|
||||
// Include archived = false (default) — we don't want
|
||||
// closed-and-archived noise in memory.
|
||||
});
|
||||
|
||||
let resp = ctx
|
||||
.execute(ACTION_GET_FILTERED_TEAM_TASKS, Some(args))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"[composio:clickup] {ACTION_GET_FILTERED_TEAM_TASKS} \
|
||||
workspace={workspace_id} page={page_num}: {e:#}"
|
||||
)
|
||||
})?;
|
||||
|
||||
state.record_requests(1);
|
||||
|
||||
if !resp.successful {
|
||||
let err = resp
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "provider reported failure".to_string());
|
||||
let _ = state.save(&memory).await;
|
||||
return Err(format!(
|
||||
"[composio:clickup] {ACTION_GET_FILTERED_TEAM_TASKS} \
|
||||
workspace={workspace_id} page={page_num}: {err}"
|
||||
));
|
||||
}
|
||||
|
||||
let tasks = sync::extract_tasks(&resp.data);
|
||||
total_fetched += tasks.len();
|
||||
|
||||
if tasks.is_empty() {
|
||||
tracing::debug!(
|
||||
workspace_id = %workspace_id,
|
||||
page = page_num,
|
||||
"[composio:clickup] empty page, moving to next workspace"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Per-item dedup + persist ────────────────────────
|
||||
let mut hit_cursor_boundary = false;
|
||||
for task in &tasks {
|
||||
let Some(task_id) =
|
||||
crate::openhuman::composio::providers::sync_state::extract_item_id(
|
||||
task,
|
||||
TASK_ID_PATHS,
|
||||
)
|
||||
else {
|
||||
tracing::debug!("[composio:clickup] task missing ID, skipping");
|
||||
continue;
|
||||
};
|
||||
|
||||
let updated = sync::extract_task_updated(task);
|
||||
|
||||
// Track newest `date_updated` for cursor advancement.
|
||||
if let Some(ref ts) = updated {
|
||||
if newest_updated.as_ref().is_none_or(|existing| ts > existing) {
|
||||
newest_updated = Some(ts.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Use a composite (task_id, date_updated) key so that
|
||||
// a task edited *after* its last sync is re-persisted.
|
||||
// Same trick the Notion provider uses for
|
||||
// `last_edited_time`.
|
||||
let sync_key = match &updated {
|
||||
Some(ts) => format!("{task_id}@{ts}"),
|
||||
None => task_id.clone(),
|
||||
};
|
||||
|
||||
// If `date_updated` is at or older than our cursor
|
||||
// *and* we've already synced this composite key, the
|
||||
// rest of the page is by definition older too — we
|
||||
// can stop this workspace early.
|
||||
if let (Some(ref cursor), Some(ref ts)) = (&state.cursor, &updated) {
|
||||
if ts <= cursor && state.is_synced(&sync_key) {
|
||||
hit_cursor_boundary = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if state.is_synced(&sync_key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let title_text = sync::extract_task_name(task)
|
||||
.unwrap_or_else(|| format!("ClickUp task {task_id}"));
|
||||
let doc_id = format!("composio-clickup-task-{task_id}");
|
||||
let title = format!("ClickUp: {title_text}");
|
||||
|
||||
match persist_single_item(
|
||||
&memory,
|
||||
"clickup",
|
||||
&doc_id,
|
||||
&title,
|
||||
task,
|
||||
"clickup",
|
||||
ctx.connection_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
state.mark_synced(&sync_key);
|
||||
total_persisted += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
task_id = %task_id,
|
||||
workspace_id = %workspace_id,
|
||||
error = %e,
|
||||
"[composio:clickup] failed to persist task (continuing)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hit_cursor_boundary {
|
||||
tracing::debug!(
|
||||
workspace_id = %workspace_id,
|
||||
page = page_num,
|
||||
"[composio:clickup] reached cursor boundary, stopping workspace"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// ClickUp's filtered-team-tasks endpoint signals the last
|
||||
// page implicitly: when fewer than `page_size` results
|
||||
// come back, there are no more pages.
|
||||
if (tasks.len() as u32) < page_size {
|
||||
tracing::debug!(
|
||||
workspace_id = %workspace_id,
|
||||
page = page_num,
|
||||
returned = tasks.len(),
|
||||
"[composio:clickup] short page, end of workspace"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 6: advance cursor and save state ───────────────────
|
||||
if let Some(new_cursor) = newest_updated {
|
||||
state.advance_cursor(&new_cursor);
|
||||
}
|
||||
state.set_last_sync_at_ms(sync::now_ms());
|
||||
state.save(&memory).await?;
|
||||
|
||||
let finished_at_ms = sync::now_ms();
|
||||
let summary = format!(
|
||||
"clickup sync ({reason}): fetched {total_fetched}, persisted {total_persisted} new, \
|
||||
across {workspace_count} workspace(s), budget remaining {remaining}",
|
||||
reason = reason.as_str(),
|
||||
workspace_count = workspaces.len(),
|
||||
remaining = state.budget_remaining(),
|
||||
);
|
||||
tracing::info!(
|
||||
connection_id = %connection_id,
|
||||
elapsed_ms = finished_at_ms.saturating_sub(started_at_ms),
|
||||
total_fetched,
|
||||
total_persisted,
|
||||
workspace_count = workspaces.len(),
|
||||
budget_remaining = state.budget_remaining(),
|
||||
"[composio:clickup] incremental sync complete"
|
||||
);
|
||||
|
||||
Ok(SyncOutcome {
|
||||
toolkit: "clickup".to_string(),
|
||||
connection_id: Some(connection_id),
|
||||
reason: reason.as_str().to_string(),
|
||||
items_ingested: total_persisted,
|
||||
started_at_ms,
|
||||
finished_at_ms,
|
||||
summary,
|
||||
details: json!({
|
||||
"tasks_fetched": total_fetched,
|
||||
"tasks_persisted": total_persisted,
|
||||
"workspaces_visible": workspaces.len(),
|
||||
"budget_remaining": state.budget_remaining(),
|
||||
"cursor": state.cursor,
|
||||
"synced_ids_total": state.synced_ids.len(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ClickUpProvider {
|
||||
/// Look up (and budget-record) the authorized user's numeric ID.
|
||||
///
|
||||
/// The ID is stable for the connection's lifetime, but we re-fetch
|
||||
/// on every sync rather than persisting it because (a) the ClickUp
|
||||
/// API call is cheap, (b) caching it in `SyncState` would inflate
|
||||
/// the public struct for a single provider's quirk, and (c) it
|
||||
/// implicitly validates that the OAuth connection is still good
|
||||
/// before we start paginating.
|
||||
async fn resolve_user_id(
|
||||
&self,
|
||||
ctx: &ProviderContext,
|
||||
state: &mut SyncState,
|
||||
) -> Result<String, String> {
|
||||
let resp = ctx
|
||||
.execute(ACTION_GET_AUTHORIZED_USER, Some(json!({})))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
format!("[composio:clickup] {ACTION_GET_AUTHORIZED_USER} failed: {e:#}")
|
||||
})?;
|
||||
state.record_requests(1);
|
||||
|
||||
if !resp.successful {
|
||||
let err = resp
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "provider reported failure".to_string());
|
||||
return Err(format!(
|
||||
"[composio:clickup] {ACTION_GET_AUTHORIZED_USER}: {err}"
|
||||
));
|
||||
}
|
||||
|
||||
sync::extract_user_id(&resp.data).ok_or_else(|| {
|
||||
"[composio:clickup] CLICKUP_GET_AUTHORIZED_USER returned no user.id".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// Look up the list of workspace (team) IDs visible to this
|
||||
/// connection. ClickUp's per-team task-filter endpoint requires a
|
||||
/// concrete `team_id`, so we have to enumerate.
|
||||
async fn resolve_workspaces(
|
||||
&self,
|
||||
ctx: &ProviderContext,
|
||||
state: &mut SyncState,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let resp = ctx
|
||||
.execute(ACTION_GET_AUTHORIZED_TEAMS_WORKSPACES, Some(json!({})))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
format!("[composio:clickup] {ACTION_GET_AUTHORIZED_TEAMS_WORKSPACES} failed: {e:#}")
|
||||
})?;
|
||||
state.record_requests(1);
|
||||
|
||||
if !resp.successful {
|
||||
let err = resp
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "provider reported failure".to_string());
|
||||
return Err(format!(
|
||||
"[composio:clickup] {ACTION_GET_AUTHORIZED_TEAMS_WORKSPACES}: {err}"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(sync::extract_workspace_ids(&resp.data))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! ClickUp sync helpers — result extraction, task-title extraction,
|
||||
//! and time utilities.
|
||||
//!
|
||||
//! ClickUp's REST API (and therefore Composio's wrapping of it) returns
|
||||
//! task lists in a small handful of shapes depending on which endpoint
|
||||
//! is called. The functions here walk the union of common shapes so the
|
||||
//! provider doesn't have to branch per Composio envelope variant.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::openhuman::composio::providers::pick_str;
|
||||
|
||||
/// Walk the Composio response envelope for ClickUp task list results.
|
||||
///
|
||||
/// ClickUp's "filtered team tasks" endpoint returns `{ "tasks": [...] }`
|
||||
/// at the top level; Composio re-wraps the upstream payload under
|
||||
/// `data` or `data.data` depending on the action. We probe each shape
|
||||
/// in order and return the first array we find.
|
||||
pub(crate) fn extract_tasks(data: &Value) -> Vec<Value> {
|
||||
let candidates = [
|
||||
data.pointer("/data/tasks"),
|
||||
data.pointer("/tasks"),
|
||||
data.pointer("/data/data/tasks"),
|
||||
data.pointer("/data/results"),
|
||||
data.pointer("/results"),
|
||||
data.pointer("/data/items"),
|
||||
data.pointer("/items"),
|
||||
];
|
||||
for cand in candidates.into_iter().flatten() {
|
||||
if let Some(arr) = cand.as_array() {
|
||||
return arr.clone();
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Extract a human-readable title from a ClickUp task object.
|
||||
///
|
||||
/// ClickUp tasks store the name at `name` (or `data.name` after Composio
|
||||
/// envelope wrapping). When the name is missing we fall back to the
|
||||
/// task ID so chunks remain identifiable.
|
||||
pub(crate) fn extract_task_name(task: &Value) -> Option<String> {
|
||||
pick_str(task, &["name", "data.name", "title", "data.title"])
|
||||
}
|
||||
|
||||
/// Extract a stable cursor timestamp (milliseconds since epoch as a
|
||||
/// string) from a ClickUp task object.
|
||||
///
|
||||
/// The ClickUp API returns `date_updated` as a stringified epoch ms
|
||||
/// (e.g. `"1733412345678"`); we keep it as a string so lexicographic
|
||||
/// comparison against the stored cursor remains valid as long as the
|
||||
/// length doesn't change (it won't until year 33658).
|
||||
pub(crate) fn extract_task_updated(task: &Value) -> Option<String> {
|
||||
pick_str(
|
||||
task,
|
||||
&[
|
||||
"date_updated",
|
||||
"data.date_updated",
|
||||
"updated_at",
|
||||
"data.updated_at",
|
||||
"dateUpdated",
|
||||
"data.dateUpdated",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Current wall-clock time in milliseconds since the UNIX epoch.
|
||||
pub(crate) fn now_ms() -> u64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Extract the authorized user's numeric ID from the
|
||||
/// `CLICKUP_GET_AUTHORIZED_USER` response.
|
||||
///
|
||||
/// Composio wraps the upstream `{"user": {"id": …}}` shape; this walker
|
||||
/// is defensive against both raw and wrapped payloads. Returns the ID
|
||||
/// as a string because `CLICKUP_GET_FILTERED_TEAM_TASKS` accepts the
|
||||
/// `assignees` filter as a string array.
|
||||
pub(crate) fn extract_user_id(data: &Value) -> Option<String> {
|
||||
let candidates = [
|
||||
data.pointer("/user/id"),
|
||||
data.pointer("/data/user/id"),
|
||||
data.pointer("/id"),
|
||||
data.pointer("/data/id"),
|
||||
];
|
||||
for cand in candidates.into_iter().flatten() {
|
||||
if let Some(n) = cand.as_u64() {
|
||||
return Some(n.to_string());
|
||||
}
|
||||
if let Some(n) = cand.as_i64() {
|
||||
return Some(n.to_string());
|
||||
}
|
||||
if let Some(s) = cand.as_str() {
|
||||
let trimmed = s.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract a list of workspace (team) IDs from the
|
||||
/// `CLICKUP_GET_AUTHORIZED_TEAMS_WORKSPACES` response.
|
||||
///
|
||||
/// ClickUp returns `{"teams": [{"id": "...", "name": "..."}, …]}`. We
|
||||
/// keep the IDs as strings — `CLICKUP_GET_FILTERED_TEAM_TASKS` requires
|
||||
/// a `team_id` (string) argument.
|
||||
pub(crate) fn extract_workspace_ids(data: &Value) -> Vec<String> {
|
||||
let candidates = [
|
||||
data.pointer("/teams"),
|
||||
data.pointer("/data/teams"),
|
||||
data.pointer("/workspaces"),
|
||||
data.pointer("/data/workspaces"),
|
||||
];
|
||||
for cand in candidates.into_iter().flatten() {
|
||||
if let Some(arr) = cand.as_array() {
|
||||
return arr
|
||||
.iter()
|
||||
.filter_map(|t| pick_str(t, &["id", "team_id", "workspace_id"]))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn extract_tasks_from_data_tasks() {
|
||||
let data = json!({ "data": { "tasks": [{"id": "t1"}] } });
|
||||
assert_eq!(extract_tasks(&data).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_tasks_from_top_level_tasks() {
|
||||
let data = json!({ "tasks": [{"id": "a"}, {"id": "b"}] });
|
||||
assert_eq!(extract_tasks(&data).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_tasks_empty_when_missing() {
|
||||
let data = json!({ "foo": "bar" });
|
||||
assert!(extract_tasks(&data).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_task_name_from_top_level() {
|
||||
let task = json!({ "id": "t1", "name": "Build feature X" });
|
||||
assert_eq!(extract_task_name(&task), Some("Build feature X".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_task_name_falls_back_to_data_name() {
|
||||
let task = json!({ "data": { "name": "Wrapped" } });
|
||||
assert_eq!(extract_task_name(&task), Some("Wrapped".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_task_name_none_when_missing() {
|
||||
let task = json!({ "id": "t1" });
|
||||
assert!(extract_task_name(&task).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_task_updated_handles_string_form() {
|
||||
let task = json!({ "date_updated": "1733412345678" });
|
||||
assert_eq!(
|
||||
extract_task_updated(&task),
|
||||
Some("1733412345678".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_task_updated_handles_nested_data() {
|
||||
let task = json!({ "data": { "dateUpdated": "1700000000000" } });
|
||||
assert_eq!(
|
||||
extract_task_updated(&task),
|
||||
Some("1700000000000".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_user_id_handles_numeric_id() {
|
||||
let data = json!({ "user": { "id": 12345 } });
|
||||
assert_eq!(extract_user_id(&data), Some("12345".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_user_id_handles_wrapped_payload() {
|
||||
let data = json!({ "data": { "user": { "id": "777" } } });
|
||||
assert_eq!(extract_user_id(&data), Some("777".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_user_id_none_when_missing() {
|
||||
let data = json!({ "foo": "bar" });
|
||||
assert!(extract_user_id(&data).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_workspace_ids_from_teams_array() {
|
||||
let data = json!({
|
||||
"teams": [
|
||||
{ "id": "ws1", "name": "Personal" },
|
||||
{ "id": "ws2", "name": "Acme" },
|
||||
]
|
||||
});
|
||||
assert_eq!(extract_workspace_ids(&data), vec!["ws1", "ws2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_workspace_ids_empty_when_no_teams() {
|
||||
let data = json!({ "foo": "bar" });
|
||||
assert!(extract_workspace_ids(&data).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn now_ms_returns_nonzero() {
|
||||
assert!(now_ms() > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Unit tests for the ClickUp provider.
|
||||
|
||||
use super::sync::{
|
||||
extract_task_name, extract_task_updated, extract_tasks, extract_user_id, extract_workspace_ids,
|
||||
};
|
||||
use super::ClickUpProvider;
|
||||
use crate::openhuman::composio::providers::ComposioProvider;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn extract_tasks_walks_common_shapes() {
|
||||
let v1 = json!({ "data": { "tasks": [{"id": "t1"}] } });
|
||||
let v2 = json!({ "tasks": [{"id": "t2"}, {"id": "t3"}] });
|
||||
let v3 = json!({ "data": {} });
|
||||
assert_eq!(extract_tasks(&v1).len(), 1);
|
||||
assert_eq!(extract_tasks(&v2).len(), 2);
|
||||
assert_eq!(extract_tasks(&v3).len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_task_name_finds_name_field() {
|
||||
let task = json!({ "id": "abc", "name": "Build feature X" });
|
||||
assert_eq!(extract_task_name(&task), Some("Build feature X".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_task_name_falls_back_to_wrapped_data() {
|
||||
let task = json!({ "data": { "name": "Wrapped" } });
|
||||
assert_eq!(extract_task_name(&task), Some("Wrapped".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_task_name_returns_none_when_missing() {
|
||||
let task = json!({ "id": "abc" });
|
||||
assert!(extract_task_name(&task).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_task_updated_handles_string_form() {
|
||||
let task = json!({ "date_updated": "1733412345678" });
|
||||
assert_eq!(
|
||||
extract_task_updated(&task),
|
||||
Some("1733412345678".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_task_updated_handles_nested_data() {
|
||||
let task = json!({ "data": { "dateUpdated": "1700000000000" } });
|
||||
assert_eq!(
|
||||
extract_task_updated(&task),
|
||||
Some("1700000000000".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_task_updated_returns_none_when_missing() {
|
||||
let task = json!({ "id": "abc" });
|
||||
assert!(extract_task_updated(&task).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_user_id_handles_numeric_id() {
|
||||
let data = json!({ "user": { "id": 12345 } });
|
||||
assert_eq!(extract_user_id(&data), Some("12345".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_user_id_handles_wrapped_payload() {
|
||||
let data = json!({ "data": { "user": { "id": "777" } } });
|
||||
assert_eq!(extract_user_id(&data), Some("777".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_user_id_none_when_missing() {
|
||||
let data = json!({ "foo": "bar" });
|
||||
assert!(extract_user_id(&data).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_workspace_ids_from_teams_array() {
|
||||
let data = json!({
|
||||
"teams": [
|
||||
{ "id": "ws1", "name": "Personal" },
|
||||
{ "id": "ws2", "name": "Acme" },
|
||||
]
|
||||
});
|
||||
assert_eq!(extract_workspace_ids(&data), vec!["ws1", "ws2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_workspace_ids_handles_wrapped_payload() {
|
||||
let data = json!({
|
||||
"data": {
|
||||
"teams": [
|
||||
{ "id": "ws1" },
|
||||
{ "id": "ws2" },
|
||||
{ "id": "ws3" },
|
||||
]
|
||||
}
|
||||
});
|
||||
assert_eq!(extract_workspace_ids(&data), vec!["ws1", "ws2", "ws3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_workspace_ids_empty_when_no_teams() {
|
||||
let data = json!({ "foo": "bar" });
|
||||
assert!(extract_workspace_ids(&data).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_workspace_ids_skips_entries_without_id() {
|
||||
let data = json!({
|
||||
"teams": [
|
||||
{ "name": "Anonymous" },
|
||||
{ "id": "ws1", "name": "Real" },
|
||||
]
|
||||
});
|
||||
assert_eq!(extract_workspace_ids(&data), vec!["ws1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_metadata_is_stable() {
|
||||
let p = ClickUpProvider::new();
|
||||
assert_eq!(p.toolkit_slug(), "clickup");
|
||||
assert_eq!(p.sync_interval_secs(), Some(30 * 60));
|
||||
assert!(p.curated_tools().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_tools_contains_core_read_surface() {
|
||||
let p = ClickUpProvider::new();
|
||||
let curated = p.curated_tools().expect("CLICKUP_CURATED is registered");
|
||||
let slugs: Vec<&str> = curated.iter().map(|t| t.slug).collect();
|
||||
// The three actions the sync path depends on must be advertised.
|
||||
assert!(slugs.contains(&"CLICKUP_GET_AUTHORIZED_USER"));
|
||||
assert!(slugs.contains(&"CLICKUP_GET_AUTHORIZED_TEAMS_WORKSPACES"));
|
||||
assert!(slugs.contains(&"CLICKUP_GET_FILTERED_TEAM_TASKS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_impl_matches_new() {
|
||||
let _a = ClickUpProvider::new();
|
||||
let _b = ClickUpProvider::default();
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Curated catalog of ClickUp Composio actions exposed to the agent.
|
||||
//!
|
||||
//! Slugs match Composio's naming convention (`<TOOLKIT>_<ACTION>`) for
|
||||
//! the ClickUp REST surface. See <https://composio.dev/docs/toolkits/clickup>
|
||||
//! for the canonical action list; the entries here are the read-oriented
|
||||
//! subset the periodic Memory Tree sync relies on, plus the most common
|
||||
//! task-write surface the agent already uses through generic tool-calling.
|
||||
|
||||
use crate::openhuman::composio::providers::tool_scope::{CuratedTool, ToolScope};
|
||||
|
||||
pub const CLICKUP_CURATED: &[CuratedTool] = &[
|
||||
// ── Read: identity ─────────────────────────────────────────────
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_GET_AUTHORIZED_USER",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_GET_AUTHORIZED_TEAMS_WORKSPACES",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
// ── Read: structure (workspace → space → folder → list) ──────
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_GET_SPACES",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_GET_FOLDERS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_GET_LISTS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_GET_FOLDERLESS_LISTS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
// ── Read: tasks (the main memory ingest surface) ──────────────
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_GET_FILTERED_TEAM_TASKS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_GET_TASKS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_GET_TASK",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_GET_TASK_COMMENTS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_GET_LIST_COMMENTS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
// ── Read: docs / views / time tracking ────────────────────────
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_SEARCH_DOCS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_GET_DOC_PAGES",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_GET_VIEW_TASKS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_GET_TIME_ENTRIES_WITHIN_A_DATE_RANGE",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
// ── Read: members ─────────────────────────────────────────────
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_GET_WORKSPACE_MEMBERS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_GET_TASK_MEMBERS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
// ── Write: create / update tasks ──────────────────────────────
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_CREATE_TASK",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_UPDATE_TASK",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_CREATE_TASK_COMMENT",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_UPDATE_COMMENT",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
// ── Write: structure ──────────────────────────────────────────
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_CREATE_LIST",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_UPDATE_LIST",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
// ── Admin: destructive ────────────────────────────────────────
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_DELETE_TASK",
|
||||
scope: ToolScope::Admin,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_DELETE_COMMENT",
|
||||
scope: ToolScope::Admin,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "CLICKUP_DELETE_LIST",
|
||||
scope: ToolScope::Admin,
|
||||
},
|
||||
];
|
||||
@@ -45,6 +45,7 @@ pub mod catalogs_google;
|
||||
pub mod catalogs_messaging;
|
||||
pub mod catalogs_productivity;
|
||||
pub mod catalogs_social_media;
|
||||
pub mod clickup;
|
||||
pub mod github;
|
||||
pub mod gmail;
|
||||
pub mod notion;
|
||||
|
||||
@@ -78,6 +78,7 @@ pub fn all_providers() -> Vec<ProviderArc> {
|
||||
///
|
||||
/// Idempotent: re-running just re-registers (no-op in practice).
|
||||
pub fn init_default_providers() {
|
||||
register_provider(Arc::new(super::clickup::ClickUpProvider::new()));
|
||||
register_provider(Arc::new(super::gmail::GmailProvider::new()));
|
||||
register_provider(Arc::new(super::notion::NotionProvider::new()));
|
||||
register_provider(Arc::new(super::slack::SlackProvider::new()));
|
||||
|
||||
Reference in New Issue
Block a user