mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(composio): add Linear as a native memory provider (#2452)
This commit is contained in:
@@ -27,6 +27,6 @@ pub use super::catalogs_messaging::{
|
||||
DISCORD_CURATED, MICROSOFT_TEAMS_CURATED, SLACK_CURATED, TELEGRAM_CURATED, WHATSAPP_CURATED,
|
||||
};
|
||||
pub use super::catalogs_productivity::{
|
||||
ASANA_CURATED, DROPBOX_CURATED, JIRA_CURATED, LINEAR_CURATED, OUTLOOK_CURATED, TRELLO_CURATED,
|
||||
ASANA_CURATED, DROPBOX_CURATED, JIRA_CURATED, OUTLOOK_CURATED, TRELLO_CURATED,
|
||||
};
|
||||
pub use super::catalogs_social_media::{SPOTIFY_CURATED, TWITTER_CURATED, YOUTUBE_CURATED};
|
||||
|
||||
@@ -99,94 +99,6 @@ pub const OUTLOOK_CURATED: &[CuratedTool] = &[
|
||||
},
|
||||
];
|
||||
|
||||
// ── linear ──────────────────────────────────────────────────────────
|
||||
pub const LINEAR_CURATED: &[CuratedTool] = &[
|
||||
CuratedTool {
|
||||
slug: "LINEAR_LIST_LINEAR_ISSUES",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_GET_LINEAR_ISSUE",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_LIST_LINEAR_TEAMS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_LIST_LINEAR_PROJECTS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_LIST_LINEAR_STATES",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_SEARCH_ISSUES",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_GET_CYCLES_BY_TEAM_ID",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_LIST_LINEAR_USERS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_LIST_LINEAR_LABELS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_GET_LINEAR_PROJECT",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_CREATE_LINEAR_ISSUE",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_UPDATE_ISSUE",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_CREATE_LINEAR_COMMENT",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_CREATE_ATTACHMENT",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_CREATE_LINEAR_PROJECT",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_CREATE_LINEAR_LABEL",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_UPDATE_LINEAR_COMMENT",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_CREATE_ISSUE_RELATION",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_UPDATE_LINEAR_PROJECT",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_DELETE_LINEAR_ISSUE",
|
||||
scope: ToolScope::Admin,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_REMOVE_ISSUE_LABEL",
|
||||
scope: ToolScope::Admin,
|
||||
},
|
||||
];
|
||||
|
||||
// ── jira ────────────────────────────────────────────────────────────
|
||||
pub const JIRA_CURATED: &[CuratedTool] = &[
|
||||
CuratedTool {
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn toolkit_description(slug: &str) -> &'static str {
|
||||
"google_sheets" => "Read, write, and manage Google Sheets spreadsheets",
|
||||
"outlook" => "Send, read, and manage emails in Microsoft Outlook",
|
||||
"microsoft_teams" => "Send messages and manage channels in Microsoft Teams",
|
||||
"linear" => "Create and manage issues, projects, and cycles in Linear",
|
||||
"linear" => "Create and manage issues, projects, and cycles in Linear; sync assigned issues into Memory Tree",
|
||||
"jira" => "Create and manage issues, projects, and sprints in Jira",
|
||||
"trello" => "Create and manage cards, lists, and boards in Trello",
|
||||
"asana" => "Create and manage tasks, projects, and sections in Asana",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Linear Composio provider — incremental Memory Tree ingest for
|
||||
//! issues assigned to the connected user.
|
||||
//!
|
||||
//! Issue: #2400.
|
||||
|
||||
mod provider;
|
||||
mod sync;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub mod tools;
|
||||
|
||||
pub use provider::LinearProvider;
|
||||
pub use tools::LINEAR_CURATED;
|
||||
@@ -0,0 +1,423 @@
|
||||
//! Linear provider — incremental sync of issues 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. Resolve the viewer ID via `LINEAR_LIST_LINEAR_USERS { isMe: true }`.
|
||||
//! 4. Page through `LINEAR_LIST_LINEAR_ISSUES` filtered to the viewer as
|
||||
//! assignee, ordered by `updatedAt` descending. Stop early once we hit
|
||||
//! issues older than the cursor or a page without a next-page cursor.
|
||||
//! 5. For each issue, persist as a single memory document if it's new
|
||||
//! *or* edited since the last sync.
|
||||
//! 6. Advance the cursor to the newest `updatedAt` seen and save.
|
||||
//!
|
||||
//! Privacy posture: we only pull issues the user is assigned to, never
|
||||
//! the whole workspace's issue graph. This mirrors the
|
||||
//! "fetch-what-the-user-sees" model `gmail` / `notion` already follow
|
||||
//! and avoids accidentally ingesting other teammates' private issues.
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
const ACTION_LIST_USERS: &str = "LINEAR_LIST_LINEAR_USERS";
|
||||
const ACTION_LIST_ISSUES: &str = "LINEAR_LIST_LINEAR_ISSUES";
|
||||
|
||||
/// Page size per API call. We use a small window on steady-state syncs
|
||||
/// to keep response sizes bounded.
|
||||
const PAGE_SIZE: u64 = 50;
|
||||
|
||||
/// Larger initial-sync page size so the first backfill catches up faster.
|
||||
const INITIAL_PAGE_SIZE: u64 = 100;
|
||||
|
||||
/// Maximum pages per sync pass before yielding. Caps initial backfill
|
||||
/// churn — anything beyond this rolls over to the next sync interval.
|
||||
const MAX_PAGES_PER_SYNC: u32 = 20;
|
||||
|
||||
/// Paths for extracting a Linear issue's unique ID.
|
||||
const ISSUE_ID_PATHS: &[&str] = &["id", "data.id", "identifier", "data.identifier"];
|
||||
|
||||
pub struct LinearProvider;
|
||||
|
||||
impl LinearProvider {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LinearProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ComposioProvider for LinearProvider {
|
||||
fn toolkit_slug(&self) -> &'static str {
|
||||
"linear"
|
||||
}
|
||||
|
||||
fn curated_tools(&self) -> Option<&'static [CuratedTool]> {
|
||||
Some(super::tools::LINEAR_CURATED)
|
||||
}
|
||||
|
||||
fn sync_interval_secs(&self) -> Option<u64> {
|
||||
// 30 minutes — same cadence as ClickUp/Notion. Linear issues change
|
||||
// more slowly than chat but faster than email.
|
||||
Some(30 * 60)
|
||||
}
|
||||
|
||||
async fn fetch_user_profile(
|
||||
&self,
|
||||
ctx: &ProviderContext,
|
||||
) -> Result<ProviderUserProfile, String> {
|
||||
tracing::debug!(
|
||||
connection_id = ?ctx.connection_id,
|
||||
"[composio:linear] fetch_user_profile via {ACTION_LIST_USERS}"
|
||||
);
|
||||
|
||||
let resp = ctx
|
||||
.execute(ACTION_LIST_USERS, Some(json!({ "isMe": true })))
|
||||
.await
|
||||
.map_err(|e| format!("[composio:linear] {ACTION_LIST_USERS} failed: {e:#}"))?;
|
||||
|
||||
if !resp.successful {
|
||||
let err = resp
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| "provider reported failure".to_string());
|
||||
return Err(format!("[composio:linear] {ACTION_LIST_USERS}: {err}"));
|
||||
}
|
||||
|
||||
let data = &resp.data;
|
||||
let viewer = sync::extract_viewer(data);
|
||||
let viewer_ref = viewer.as_ref().unwrap_or(data);
|
||||
|
||||
let display_name = pick_str(viewer_ref, &["name", "data.name", "displayName"]);
|
||||
let email = pick_str(viewer_ref, &["email", "data.email"]);
|
||||
let username = pick_str(viewer_ref, &["id", "data.id"]);
|
||||
let avatar_url = pick_str(viewer_ref, &["avatarUrl", "data.avatarUrl"]);
|
||||
let profile_url = pick_str(viewer_ref, &["url", "data.url"]);
|
||||
|
||||
Ok(ProviderUserProfile {
|
||||
toolkit: "linear".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:linear] incremental sync starting"
|
||||
);
|
||||
|
||||
// ── Step 1: load persistent sync state ──────────────────────
|
||||
let Some(memory) = ctx.memory_client() else {
|
||||
return Err("[composio:linear] memory client not ready".to_string());
|
||||
};
|
||||
let mut state = SyncState::load(&memory, "linear", &connection_id).await?;
|
||||
|
||||
// ── Step 2: check daily budget ──────────────────────────────
|
||||
if state.budget_exhausted() {
|
||||
tracing::info!(
|
||||
connection_id = %connection_id,
|
||||
"[composio:linear] daily request budget exhausted, skipping sync"
|
||||
);
|
||||
return Ok(SyncOutcome {
|
||||
toolkit: "linear".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: "linear sync skipped: daily budget exhausted".to_string(),
|
||||
details: json!({ "budget_exhausted": true }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Step 3: resolve the authenticated user's ID ─────────────
|
||||
let viewer_id = match self.resolve_viewer_id(ctx, &mut state).await {
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
let _ = state.save(&memory).await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Re-check budget after the viewer-id probe.
|
||||
if state.budget_exhausted() {
|
||||
tracing::info!(
|
||||
connection_id = %connection_id,
|
||||
"[composio:linear] budget exhausted after viewer-id probe, skipping sync"
|
||||
);
|
||||
state.save(&memory).await?;
|
||||
return Ok(SyncOutcome {
|
||||
toolkit: "linear".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: "linear sync skipped: daily budget exhausted after viewer-id probe"
|
||||
.to_string(),
|
||||
details: json!({ "budget_exhausted": true, "viewer_id_resolved": true }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Step 4: paginated incremental fetch ──────────────────────
|
||||
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 had_persist_failures = false;
|
||||
let mut newest_updated: Option<String> = None;
|
||||
let mut after_cursor: Option<String> = None;
|
||||
let mut hit_cursor_boundary = false;
|
||||
|
||||
for page_num in 0..MAX_PAGES_PER_SYNC {
|
||||
if state.budget_exhausted() {
|
||||
tracing::info!(
|
||||
page = page_num,
|
||||
"[composio:linear] budget exhausted mid-sync, stopping pagination"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
let mut args = json!({
|
||||
"assigneeId": &viewer_id,
|
||||
"first": page_size,
|
||||
"orderBy": "updatedAt",
|
||||
});
|
||||
|
||||
if let Some(ref cursor) = after_cursor {
|
||||
args["after"] = json!(cursor);
|
||||
}
|
||||
|
||||
let resp = ctx
|
||||
.execute(ACTION_LIST_ISSUES, Some(args))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
format!("[composio:linear] {ACTION_LIST_ISSUES} 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:linear] {ACTION_LIST_ISSUES} page={page_num}: {err}"
|
||||
));
|
||||
}
|
||||
|
||||
let issues = sync::extract_issues(&resp.data);
|
||||
total_fetched += issues.len();
|
||||
|
||||
if issues.is_empty() {
|
||||
tracing::debug!(
|
||||
page = page_num,
|
||||
"[composio:linear] empty page, stopping pagination"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// ── Per-item dedup + persist ─────────────────────────────
|
||||
for issue in &issues {
|
||||
let Some(issue_id) =
|
||||
crate::openhuman::composio::providers::sync_state::extract_item_id(
|
||||
issue,
|
||||
ISSUE_ID_PATHS,
|
||||
)
|
||||
else {
|
||||
tracing::debug!("[composio:linear] issue missing ID, skipping");
|
||||
continue;
|
||||
};
|
||||
|
||||
let updated = sync::extract_issue_updated(issue);
|
||||
|
||||
// Track newest `updatedAt` 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());
|
||||
}
|
||||
}
|
||||
|
||||
// Composite (issue_id, updatedAt) key so re-edited
|
||||
// issues are re-persisted on the next sync.
|
||||
let sync_key = match &updated {
|
||||
Some(ts) => format!("{issue_id}@{ts}"),
|
||||
None => issue_id.clone(),
|
||||
};
|
||||
|
||||
// If `updatedAt` is at or older than our cursor *and*
|
||||
// we already synced this key, the rest of the page is
|
||||
// by definition older — stop 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_issue_title(issue)
|
||||
.unwrap_or_else(|| format!("Linear issue {issue_id}"));
|
||||
let doc_id = format!("composio-linear-issue-{issue_id}");
|
||||
let title = format!("Linear: {title_text}");
|
||||
|
||||
match persist_single_item(
|
||||
&memory,
|
||||
"linear",
|
||||
&doc_id,
|
||||
&title,
|
||||
issue,
|
||||
"linear",
|
||||
ctx.connection_id.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
state.mark_synced(&sync_key);
|
||||
total_persisted += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
had_persist_failures = true;
|
||||
tracing::warn!(
|
||||
issue_id = %issue_id,
|
||||
error = %e,
|
||||
"[composio:linear] failed to persist issue (continuing)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hit_cursor_boundary {
|
||||
tracing::debug!(
|
||||
page = page_num,
|
||||
"[composio:linear] reached cursor boundary, stopping pagination"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Advance to the next page using Linear's cursor-based pagination.
|
||||
match sync::extract_pagination_cursor(&resp.data) {
|
||||
Some(next_cursor) => {
|
||||
after_cursor = Some(next_cursor);
|
||||
}
|
||||
None => {
|
||||
tracing::debug!(
|
||||
page = page_num,
|
||||
"[composio:linear] no next page cursor, end of results"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 5: advance cursor and save state ────────────────────
|
||||
if had_persist_failures {
|
||||
tracing::warn!(
|
||||
"[composio:linear] persist failures seen; keeping previous cursor for retry"
|
||||
);
|
||||
} else 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!(
|
||||
"linear sync ({reason}): fetched {total_fetched}, persisted {total_persisted} new, \
|
||||
budget remaining {remaining}",
|
||||
reason = reason.as_str(),
|
||||
remaining = state.budget_remaining(),
|
||||
);
|
||||
tracing::info!(
|
||||
connection_id = %connection_id,
|
||||
elapsed_ms = finished_at_ms.saturating_sub(started_at_ms),
|
||||
total_fetched,
|
||||
total_persisted,
|
||||
budget_remaining = state.budget_remaining(),
|
||||
"[composio:linear] incremental sync complete"
|
||||
);
|
||||
|
||||
Ok(SyncOutcome {
|
||||
toolkit: "linear".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!({
|
||||
"issues_fetched": total_fetched,
|
||||
"issues_persisted": total_persisted,
|
||||
"budget_remaining": state.budget_remaining(),
|
||||
"cursor": state.cursor,
|
||||
"synced_ids_total": state.synced_ids.len(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl LinearProvider {
|
||||
/// Look up (and budget-record) the authenticated viewer's ID.
|
||||
///
|
||||
/// The ID is stable for the connection's lifetime. We re-fetch on
|
||||
/// every sync rather than caching it in `SyncState` because (a) the
|
||||
/// call is cheap, (b) it implicitly validates that the OAuth
|
||||
/// connection is still good before we start paginating.
|
||||
async fn resolve_viewer_id(
|
||||
&self,
|
||||
ctx: &ProviderContext,
|
||||
state: &mut SyncState,
|
||||
) -> Result<String, String> {
|
||||
let resp = ctx
|
||||
.execute(ACTION_LIST_USERS, Some(json!({ "isMe": true })))
|
||||
.await
|
||||
.map_err(|e| format!("[composio:linear] {ACTION_LIST_USERS} 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:linear] {ACTION_LIST_USERS}: {err}"));
|
||||
}
|
||||
|
||||
sync::extract_viewer_id(&resp.data).ok_or_else(|| {
|
||||
"[composio:linear] LINEAR_LIST_LINEAR_USERS returned no viewer id".to_string()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//! Linear sync helpers — result extraction, issue-title extraction,
|
||||
//! viewer identity, cursor extraction, and time utilities.
|
||||
//!
|
||||
//! Linear's GraphQL API (and therefore Composio's wrapping of it) returns
|
||||
//! connection-style lists (`{ nodes: [...], pageInfo: {...} }`) at the top
|
||||
//! level or nested under `data`. The functions here walk the union of
|
||||
//! common shapes so the provider does not have to branch per Composio
|
||||
//! envelope variant.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::openhuman::composio::providers::pick_str;
|
||||
|
||||
/// Walk the Composio response envelope for Linear issue list results.
|
||||
///
|
||||
/// Linear's list endpoints return `{ nodes: [...] }` or
|
||||
/// `{ issues: { nodes: [...] } }` shapes; Composio may re-wrap the
|
||||
/// upstream payload under `data` or `data.data`. We probe each shape
|
||||
/// in order and return the first array we find.
|
||||
pub(crate) fn extract_issues(data: &Value) -> Vec<Value> {
|
||||
let candidates = [
|
||||
data.pointer("/data/nodes"),
|
||||
data.pointer("/nodes"),
|
||||
data.pointer("/data/data/nodes"),
|
||||
data.pointer("/data/issues/nodes"),
|
||||
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 Linear issue object.
|
||||
///
|
||||
/// Linear issues store the name at `title` (or `data.title` after
|
||||
/// Composio envelope wrapping). Falls back to `name` / `identifier`
|
||||
/// so the chunk remains identifiable even for unusual response shapes.
|
||||
pub(crate) fn extract_issue_title(issue: &Value) -> Option<String> {
|
||||
pick_str(
|
||||
issue,
|
||||
&[
|
||||
"title",
|
||||
"data.title",
|
||||
"name",
|
||||
"data.name",
|
||||
"identifier",
|
||||
"data.identifier",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract a stable cursor timestamp from a Linear issue object.
|
||||
///
|
||||
/// Linear uses ISO-8601 strings for timestamps (`updatedAt`). We keep
|
||||
/// the value as a string so lexicographic comparison against the stored
|
||||
/// cursor is valid.
|
||||
pub(crate) fn extract_issue_updated(issue: &Value) -> Option<String> {
|
||||
pick_str(
|
||||
issue,
|
||||
&[
|
||||
"updatedAt",
|
||||
"data.updatedAt",
|
||||
"updated_at",
|
||||
"data.updated_at",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract the viewer (authenticated user) object from a
|
||||
/// `LINEAR_LIST_LINEAR_USERS { isMe: true }` response.
|
||||
///
|
||||
/// Linear's GraphQL viewer endpoint returns `{ nodes: [{ id, email, … }] }`.
|
||||
/// Composio may wrap this under `data` or `data.data`. We probe each
|
||||
/// shape and return the first element of the nodes array, falling back
|
||||
/// to the payload itself if it looks like a direct user object (has
|
||||
/// `id` or `email`).
|
||||
pub(crate) fn extract_viewer(data: &Value) -> Option<Value> {
|
||||
let array_candidates = [
|
||||
data.pointer("/data/nodes"),
|
||||
data.pointer("/nodes"),
|
||||
data.pointer("/data/data/nodes"),
|
||||
data.pointer("/data/users/nodes"),
|
||||
];
|
||||
for cand in array_candidates.into_iter().flatten() {
|
||||
if let Some(arr) = cand.as_array() {
|
||||
if let Some(first) = arr.first() {
|
||||
return Some(first.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: if the payload itself looks like a user object, return it.
|
||||
if data.get("id").is_some() || data.get("email").is_some() {
|
||||
return Some(data.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the viewer's ID string from a `LINEAR_LIST_LINEAR_USERS`
|
||||
/// response. Returns `None` if the payload does not contain a
|
||||
/// recognizable user ID.
|
||||
pub(crate) fn extract_viewer_id(data: &Value) -> Option<String> {
|
||||
let viewer = extract_viewer(data)?;
|
||||
pick_str(&viewer, &["id", "data.id"])
|
||||
}
|
||||
|
||||
/// Extract a pagination cursor from a Linear connection `pageInfo` block.
|
||||
///
|
||||
/// Returns `Some(endCursor)` only when `hasNextPage` is `true`;
|
||||
/// `None` when the last page has been reached or when the envelope does
|
||||
/// not carry `pageInfo` at all.
|
||||
pub(crate) fn extract_pagination_cursor(data: &Value) -> Option<String> {
|
||||
let page_info_candidates = [
|
||||
data.pointer("/data/pageInfo"),
|
||||
data.pointer("/pageInfo"),
|
||||
data.pointer("/data/data/pageInfo"),
|
||||
data.pointer("/data/issues/pageInfo"),
|
||||
];
|
||||
for cand in page_info_candidates.into_iter().flatten() {
|
||||
let has_next = cand
|
||||
.get("hasNextPage")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
if has_next {
|
||||
if let Some(cursor) = cand.get("endCursor").and_then(|v| v.as_str()) {
|
||||
let trimmed = cursor.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── extract_issues ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_issues_from_data_nodes() {
|
||||
let data = json!({ "data": { "nodes": [{"id": "i1"}, {"id": "i2"}] } });
|
||||
assert_eq!(extract_issues(&data).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_issues_from_top_level_nodes() {
|
||||
let data = json!({ "nodes": [{"id": "i3"}] });
|
||||
assert_eq!(extract_issues(&data).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_issues_from_data_issues_nodes() {
|
||||
let data = json!({ "data": { "issues": { "nodes": [{"id": "i4"}, {"id": "i5"}, {"id": "i6"}] } } });
|
||||
assert_eq!(extract_issues(&data).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_issues_from_results() {
|
||||
let data = json!({ "results": [{"id": "i7"}] });
|
||||
assert_eq!(extract_issues(&data).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_issues_empty_when_missing() {
|
||||
let data = json!({ "foo": "bar" });
|
||||
assert!(extract_issues(&data).is_empty());
|
||||
}
|
||||
|
||||
// ── extract_issue_title ──────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_issue_title_from_title_field() {
|
||||
let issue = json!({ "id": "i1", "title": "Fix the login bug" });
|
||||
assert_eq!(
|
||||
extract_issue_title(&issue),
|
||||
Some("Fix the login bug".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_issue_title_falls_back_to_wrapped_data() {
|
||||
let issue = json!({ "data": { "title": "Wrapped issue" } });
|
||||
assert_eq!(extract_issue_title(&issue), Some("Wrapped issue".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_issue_title_falls_back_to_identifier() {
|
||||
let issue = json!({ "identifier": "ENG-42" });
|
||||
assert_eq!(extract_issue_title(&issue), Some("ENG-42".into()));
|
||||
}
|
||||
|
||||
// ── extract_issue_updated ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_issue_updated_from_updated_at() {
|
||||
let issue = json!({ "updatedAt": "2026-03-01T12:00:00.000Z" });
|
||||
assert_eq!(
|
||||
extract_issue_updated(&issue),
|
||||
Some("2026-03-01T12:00:00.000Z".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_issue_updated_falls_back_to_snake_case() {
|
||||
let issue = json!({ "data": { "updated_at": "2026-01-15T08:30:00.000Z" } });
|
||||
assert_eq!(
|
||||
extract_issue_updated(&issue),
|
||||
Some("2026-01-15T08:30:00.000Z".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// ── extract_viewer ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_viewer_from_data_nodes() {
|
||||
let data = json!({ "data": { "nodes": [{ "id": "usr_1", "email": "a@b.com" }] } });
|
||||
let v = extract_viewer(&data).expect("should find viewer");
|
||||
assert_eq!(v["id"], "usr_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_viewer_from_top_level_nodes() {
|
||||
let data = json!({ "nodes": [{ "id": "usr_2" }] });
|
||||
let v = extract_viewer(&data).expect("should find viewer");
|
||||
assert_eq!(v["id"], "usr_2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_viewer_fallback_direct_object() {
|
||||
let data = json!({ "id": "usr_direct", "name": "Direct User" });
|
||||
let v = extract_viewer(&data).expect("should return direct object");
|
||||
assert_eq!(v["id"], "usr_direct");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_viewer_returns_none_when_absent() {
|
||||
let data = json!({ "foo": "bar" });
|
||||
assert!(extract_viewer(&data).is_none());
|
||||
}
|
||||
|
||||
// ── extract_pagination_cursor ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_pagination_cursor_returns_cursor_when_has_next_page() {
|
||||
let data = json!({
|
||||
"data": {
|
||||
"pageInfo": {
|
||||
"hasNextPage": true,
|
||||
"endCursor": "cursor_abc"
|
||||
}
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
extract_pagination_cursor(&data),
|
||||
Some("cursor_abc".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_pagination_cursor_returns_none_when_last_page() {
|
||||
let data = json!({
|
||||
"pageInfo": {
|
||||
"hasNextPage": false,
|
||||
"endCursor": "cursor_xyz"
|
||||
}
|
||||
});
|
||||
assert!(extract_pagination_cursor(&data).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_pagination_cursor_returns_none_when_absent() {
|
||||
let data = json!({ "nodes": [{"id": "i1"}] });
|
||||
assert!(extract_pagination_cursor(&data).is_none());
|
||||
}
|
||||
|
||||
// ── now_ms ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn now_ms_returns_nonzero() {
|
||||
assert!(now_ms() > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//! Unit tests for the Linear provider.
|
||||
|
||||
use super::sync::{
|
||||
extract_issue_title, extract_issue_updated, extract_issues, extract_pagination_cursor,
|
||||
extract_viewer, extract_viewer_id,
|
||||
};
|
||||
use super::LinearProvider;
|
||||
use crate::openhuman::composio::providers::ComposioProvider;
|
||||
use serde_json::json;
|
||||
|
||||
// ── extract_issues ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_issues_walks_common_shapes() {
|
||||
let v1 = json!({ "data": { "nodes": [{"id": "i1"}] } });
|
||||
let v2 = json!({ "nodes": [{"id": "i2"}, {"id": "i3"}] });
|
||||
let v3 = json!({ "data": { "issues": { "nodes": [{"id": "i4"}] } } });
|
||||
let v4 = json!({ "foo": "bar" });
|
||||
assert_eq!(extract_issues(&v1).len(), 1);
|
||||
assert_eq!(extract_issues(&v2).len(), 2);
|
||||
assert_eq!(extract_issues(&v3).len(), 1);
|
||||
assert_eq!(extract_issues(&v4).len(), 0);
|
||||
}
|
||||
|
||||
// ── extract_issue_title ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_issue_title_finds_title_field() {
|
||||
let issue = json!({ "id": "i1", "title": "Fix the login bug" });
|
||||
assert_eq!(
|
||||
extract_issue_title(&issue),
|
||||
Some("Fix the login bug".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_issue_title_falls_back_to_wrapped_data() {
|
||||
let issue = json!({ "data": { "title": "Wrapped issue" } });
|
||||
assert_eq!(extract_issue_title(&issue), Some("Wrapped issue".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_issue_title_falls_back_to_identifier() {
|
||||
let issue = json!({ "identifier": "ENG-99" });
|
||||
assert_eq!(extract_issue_title(&issue), Some("ENG-99".into()));
|
||||
}
|
||||
|
||||
// ── extract_issue_updated ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_issue_updated_handles_camel_case() {
|
||||
let issue = json!({ "updatedAt": "2026-03-01T12:00:00.000Z" });
|
||||
assert_eq!(
|
||||
extract_issue_updated(&issue),
|
||||
Some("2026-03-01T12:00:00.000Z".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_issue_updated_handles_wrapped_data() {
|
||||
let issue = json!({ "data": { "updatedAt": "2026-01-15T08:30:00.000Z" } });
|
||||
assert_eq!(
|
||||
extract_issue_updated(&issue),
|
||||
Some("2026-01-15T08:30:00.000Z".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// ── extract_viewer ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_viewer_finds_first_node() {
|
||||
let data = json!({ "data": { "nodes": [{ "id": "usr_1", "email": "a@b.com" }] } });
|
||||
let v = extract_viewer(&data).expect("viewer found");
|
||||
assert_eq!(v["id"], "usr_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_viewer_from_top_level_nodes() {
|
||||
let data = json!({ "nodes": [{ "id": "usr_2" }] });
|
||||
let v = extract_viewer(&data).expect("viewer found");
|
||||
assert_eq!(v["id"], "usr_2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_viewer_fallback_direct_object() {
|
||||
let data = json!({ "id": "usr_direct", "name": "Alice" });
|
||||
let v = extract_viewer(&data).expect("viewer found");
|
||||
assert_eq!(v["id"], "usr_direct");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_viewer_returns_none_when_absent() {
|
||||
let data = json!({ "foo": "bar" });
|
||||
assert!(extract_viewer(&data).is_none());
|
||||
}
|
||||
|
||||
// ── extract_pagination_cursor ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_pagination_cursor_returns_cursor_on_has_next_page() {
|
||||
let data = json!({
|
||||
"data": {
|
||||
"pageInfo": { "hasNextPage": true, "endCursor": "abc123" }
|
||||
}
|
||||
});
|
||||
assert_eq!(extract_pagination_cursor(&data), Some("abc123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_pagination_cursor_returns_none_on_last_page() {
|
||||
let data = json!({
|
||||
"pageInfo": { "hasNextPage": false, "endCursor": "xyz" }
|
||||
});
|
||||
assert!(extract_pagination_cursor(&data).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_pagination_cursor_returns_none_when_absent() {
|
||||
let data = json!({ "nodes": [{"id": "i1"}] });
|
||||
assert!(extract_pagination_cursor(&data).is_none());
|
||||
}
|
||||
|
||||
// ── extract_viewer_id ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn extract_viewer_id_from_data_nodes() {
|
||||
let data = json!({ "data": { "nodes": [{ "id": "usr_abc" }] } });
|
||||
assert_eq!(extract_viewer_id(&data), Some("usr_abc".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_viewer_id_returns_none_when_absent() {
|
||||
let data = json!({ "foo": "bar" });
|
||||
assert!(extract_viewer_id(&data).is_none());
|
||||
}
|
||||
|
||||
// ── provider metadata ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn provider_metadata_is_stable() {
|
||||
let p = LinearProvider::new();
|
||||
assert_eq!(p.toolkit_slug(), "linear");
|
||||
assert_eq!(p.sync_interval_secs(), Some(30 * 60));
|
||||
assert!(p.curated_tools().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curated_tools_contains_core_sync_surface() {
|
||||
let p = LinearProvider::new();
|
||||
let curated = p.curated_tools().expect("LINEAR_CURATED is registered");
|
||||
let slugs: Vec<&str> = curated.iter().map(|t| t.slug).collect();
|
||||
assert!(
|
||||
slugs.contains(&"LINEAR_LIST_LINEAR_USERS"),
|
||||
"LINEAR_LIST_LINEAR_USERS must be in curated catalog"
|
||||
);
|
||||
assert!(
|
||||
slugs.contains(&"LINEAR_LIST_LINEAR_ISSUES"),
|
||||
"LINEAR_LIST_LINEAR_ISSUES must be in curated catalog"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_impl_matches_new() {
|
||||
let a = LinearProvider::new();
|
||||
let b = LinearProvider::default();
|
||||
assert_eq!(a.toolkit_slug(), b.toolkit_slug());
|
||||
assert_eq!(a.sync_interval_secs(), b.sync_interval_secs());
|
||||
assert_eq!(
|
||||
a.curated_tools().map(<[_]>::len),
|
||||
b.curated_tools().map(<[_]>::len),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Curated catalog of Linear Composio actions.
|
||||
|
||||
use crate::openhuman::composio::providers::tool_scope::{CuratedTool, ToolScope};
|
||||
|
||||
pub const LINEAR_CURATED: &[CuratedTool] = &[
|
||||
CuratedTool {
|
||||
slug: "LINEAR_LIST_LINEAR_USERS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_LIST_LINEAR_ISSUES",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_GET_LINEAR_ISSUE",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_SEARCH_ISSUES",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_LIST_LINEAR_TEAMS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_LIST_LINEAR_PROJECTS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_GET_LINEAR_PROJECT",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_LIST_LINEAR_STATES",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_GET_CYCLES_BY_TEAM_ID",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_LIST_LINEAR_LABELS",
|
||||
scope: ToolScope::Read,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_CREATE_LINEAR_ISSUE",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_UPDATE_ISSUE",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_CREATE_LINEAR_COMMENT",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_UPDATE_LINEAR_COMMENT",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_CREATE_ATTACHMENT",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_CREATE_ISSUE_RELATION",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_CREATE_LINEAR_PROJECT",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_UPDATE_LINEAR_PROJECT",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_CREATE_LINEAR_LABEL",
|
||||
scope: ToolScope::Write,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_DELETE_LINEAR_ISSUE",
|
||||
scope: ToolScope::Admin,
|
||||
},
|
||||
CuratedTool {
|
||||
slug: "LINEAR_REMOVE_ISSUE_LABEL",
|
||||
scope: ToolScope::Admin,
|
||||
},
|
||||
];
|
||||
@@ -49,6 +49,7 @@ pub mod catalogs_social_media;
|
||||
pub mod clickup;
|
||||
pub mod github;
|
||||
pub mod gmail;
|
||||
pub mod linear;
|
||||
pub mod notion;
|
||||
pub mod profile;
|
||||
pub mod profile_md;
|
||||
@@ -95,13 +96,14 @@ fn native_provider_sync_interval(toolkit: &str) -> Option<u64> {
|
||||
"notion" => Some(notion::NotionProvider::new().sync_interval_secs()),
|
||||
"slack" => Some(slack::SlackProvider::new().sync_interval_secs()),
|
||||
"clickup" => Some(clickup::ClickUpProvider::new().sync_interval_secs()),
|
||||
"linear" => Some(linear::LinearProvider::new().sync_interval_secs()),
|
||||
_ => None,
|
||||
}
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn has_native_provider(toolkit: &str) -> bool {
|
||||
matches!(toolkit, "gmail" | "notion" | "slack" | "clickup")
|
||||
matches!(toolkit, "gmail" | "notion" | "slack" | "clickup" | "linear")
|
||||
}
|
||||
|
||||
/// Static overview of the Composio integrations supported by this core build.
|
||||
@@ -191,7 +193,7 @@ pub fn catalog_for_toolkit(toolkit: &str) -> Option<&'static [CuratedTool]> {
|
||||
"outlook" => Some(catalogs::OUTLOOK_CURATED),
|
||||
// MICROSOFT_TEAMS_* slugs extract to "microsoft" via toolkit_from_slug.
|
||||
"microsoft" | "microsoft_teams" => Some(catalogs::MICROSOFT_TEAMS_CURATED),
|
||||
"linear" => Some(catalogs::LINEAR_CURATED),
|
||||
"linear" => Some(linear::LINEAR_CURATED),
|
||||
"jira" => Some(catalogs::JIRA_CURATED),
|
||||
"trello" => Some(catalogs::TRELLO_CURATED),
|
||||
"asana" => Some(catalogs::ASANA_CURATED),
|
||||
@@ -354,6 +356,35 @@ mod tests {
|
||||
assert!(clickup.memory_ingest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_matrix_includes_linear_as_native_memory_provider() {
|
||||
// Locks in the per-issue #2400 registration: a Linear row must
|
||||
// appear in the capability matrix with the same native-provider
|
||||
// flags Gmail/Notion/Slack/ClickUp already carry (`memory_ingest`,
|
||||
// `periodic_sync`, non-zero `sync_interval_secs`). If a future
|
||||
// change drops one of the four registration touchpoints
|
||||
// (CAPABILITY_TOOLKITS, has_native_provider,
|
||||
// native_provider_sync_interval, catalog_for_toolkit) this test
|
||||
// fails loud rather than silently degrading the provider to
|
||||
// catalog-only status.
|
||||
let matrix = capability_matrix();
|
||||
let linear = matrix
|
||||
.iter()
|
||||
.find(|entry| entry.toolkit == "linear")
|
||||
.expect("linear capability row");
|
||||
assert!(linear.native_provider, "linear must be native");
|
||||
assert!(linear.curated_tools, "linear must have a curated catalog");
|
||||
assert!(
|
||||
linear.curated_tool_count > 0,
|
||||
"linear catalog must be non-empty"
|
||||
);
|
||||
assert!(linear.user_profile);
|
||||
assert!(linear.initial_sync);
|
||||
assert!(linear.periodic_sync);
|
||||
assert_eq!(linear.sync_interval_secs, Some(30 * 60));
|
||||
assert!(linear.memory_ingest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toolkit_description_known_slugs_are_distinct_and_non_empty() {
|
||||
let known = [
|
||||
|
||||
@@ -81,6 +81,7 @@ pub fn init_default_providers() {
|
||||
register_provider(Arc::new(super::clickup::ClickUpProvider::new()));
|
||||
register_provider(Arc::new(super::github::GitHubProvider::new()));
|
||||
register_provider(Arc::new(super::gmail::GmailProvider::new()));
|
||||
register_provider(Arc::new(super::linear::LinearProvider::new()));
|
||||
register_provider(Arc::new(super::notion::NotionProvider::new()));
|
||||
register_provider(Arc::new(super::slack::SlackProvider::new()));
|
||||
tracing::info!(
|
||||
|
||||
Reference in New Issue
Block a user