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:
justin
2026-05-20 14:43:31 +08:00
parent 1fe8afb1a7
commit b47acc7526
7 changed files with 1031 additions and 0 deletions
@@ -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,
},
];
+1
View File
@@ -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()));