Skip to content
Closed
196 changes: 94 additions & 102 deletions Cargo.lock

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ members = [
"crates/connector-linear",
"tests/e2e",
"crates/focus-plugin-sdk",
"crates/phenotype-observably-macros",
]

[workspace.package]
Expand Down Expand Up @@ -122,6 +123,13 @@ rand_core = "0.6"
# MCP SDK
mcp-sdk = "0.0.3"

# Phenotype cross-cutting deps (vendored locally; replaces the
# brittle ../../../PhenoObservability sibling path-dep that broke CI
# under sparse-checkout cone-mode, and the git dep that fails because
# PhenoObservability's submodule 'ObservabilityKit/python/pheno-logging'
# has no URL configured).
phenotype-observably-macros = { path = "crates/phenotype-observably-macros", version = "0.1.1" }

# Platform-specific paths
dirs = "5.0"

Expand Down
2 changes: 1 addition & 1 deletion crates/connector-canvas/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ tokio = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
tracing = { workspace = true }
phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" }
phenotype-observably-macros = { workspace = true }
url = "2.5"

[dev-dependencies]
Expand Down
2 changes: 1 addition & 1 deletion crates/connector-gcal/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ live-gcal = []
focus-connectors = { path = "../focus-connectors" }
focus-events = { path = "../focus-events" }
focus-crypto = { path = "../focus-crypto", optional = true }
phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" }
phenotype-observably-macros = { workspace = true }
secrecy = { workspace = true, optional = true }
serde = { workspace = true }
serde_json = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion crates/connector-github/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ tokio = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
tracing = { workspace = true }
phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" }
phenotype-observably-macros = { workspace = true }

[dev-dependencies]
wiremock = "0.6"
Expand Down
2 changes: 1 addition & 1 deletion crates/connector-linear/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ async-trait.workspace = true
tracing.workspace = true

# Observability
phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" }
phenotype-observably-macros = { workspace = true }

[dev-dependencies]
wiremock = "0.6"
Expand Down
2 changes: 1 addition & 1 deletion crates/connector-notion/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ async-trait.workspace = true
tracing.workspace = true

# Observability
phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" }
phenotype-observably-macros = { workspace = true }

[dev-dependencies]
wiremock = "0.6"
Expand Down
155 changes: 89 additions & 66 deletions crates/connector-notion/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,39 +13,66 @@ pub struct NotionPage {
pub url: String,
}

fn notion_items(json: &Value) -> Vec<&Value> {
if let Some(results) = json.get("results").and_then(|r| r.as_array()) {
results.iter().collect()
} else if json.get("object").and_then(|o| o.as_str()) == Some("page") {
vec![json]
} else {
vec![]
}
}

fn notion_title(properties: &Value, keys: &[&str]) -> String {
keys.iter()
.find_map(|key| {
properties
.get(*key)
.and_then(|t| t.get("title"))
.and_then(|arr| arr.as_array())
.and_then(|arr| arr.first())
.and_then(|t| {
t.get("plain_text")
.or_else(|| t.get("text").and_then(|text| text.get("content")))
})
.and_then(|t| t.as_str())
})
.unwrap_or("Untitled")
.to_string()
}

impl NotionPage {
pub fn from_notion_json(json: &Value) -> Vec<NotionPage> {
if let Some(results) = json.get("results").and_then(|r| r.as_array()) {
results
.iter()
.filter_map(|page| {
let title = page
.get("properties")
.and_then(|p| p.get("title"))
.and_then(|t| t.get("title"))
.and_then(|arr| arr.as_array())
.and_then(|arr| arr.first())
.and_then(|t| t.get("plain_text"))
notion_items(json)
.into_iter()
.filter_map(|page| {
let properties = page.get("properties").unwrap_or(&Value::Null);
Some(NotionPage {
id: page.get("id")?.as_str()?.into(),
title: notion_title(properties, &["title", "Name", "name"]),
icon: page
.get("icon")
.and_then(|i| i.get("emoji"))
.and_then(|e| e.as_str())
.map(|s| s.into()),
created_time: page
.get("created_time")
.and_then(|t| t.as_str())
.unwrap_or("Untitled");

Some(NotionPage {
id: page.get("id")?.as_str()?.into(),
title: title.into(),
icon: page
.get("icon")
.and_then(|i| i.get("emoji"))
.and_then(|e| e.as_str())
.map(|s| s.into()),
created_time: page.get("created_time")?.as_str()?.into(),
last_edited_time: page.get("last_edited_time")?.as_str()?.into(),
url: page.get("url")?.as_str()?.into(),
})
.unwrap_or_default()
.into(),
last_edited_time: page
.get("last_edited_time")
.and_then(|t| t.as_str())
.unwrap_or_default()
.into(),
url: page
.get("url")
.and_then(|u| u.as_str())
.unwrap_or_default()
.into(),
})
.collect()
} else {
vec![]
}
})
.collect()
}
}

Expand All @@ -60,45 +87,41 @@ pub struct NotionTask {

impl NotionTask {
pub fn from_notion_json(json: &Value) -> Vec<NotionTask> {
if let Some(results) = json.get("results").and_then(|r| r.as_array()) {
results
.iter()
.filter_map(|task| {
let title = task
.get("properties")
.and_then(|p| p.get("title"))
.and_then(|t| t.get("title"))
.and_then(|arr| arr.as_array())
.and_then(|arr| arr.first())
.and_then(|t| t.get("plain_text"))
.and_then(|t| t.as_str())
.unwrap_or("Untitled");
notion_items(json)
.into_iter()
.filter_map(|task| {
let properties = task.get("properties").unwrap_or(&Value::Null);
let status_done = properties
.get("status")
.and_then(|s| s.get("select"))
.and_then(|s| s.get("name"))
.and_then(|s| s.as_str())
Comment on lines +95 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In the Notion API page properties schema, for a property of type "status", where is the status name stored (e.g., status.namevsselect.name)?

💡 Result:

In the Notion API, for a property of type "status," the status name is stored in status.name [1][2]. When accessing a page property value, the object contains a type field (set to "status") and a corresponding status object [1]. The status object contains the specific data for that property, including the name field, which holds the string value of the status as it appears in Notion [1]. For example, when retrieving a page, the response structure for a status property appears as follows [2]: { "Status": { "id": "...", "type": "status", "status": { "id": "...", "name": "In progress", "color": "blue" } } } Similarly, when updating or creating a status property value, you use the status object in your request body [1]: { "properties": { "Status": { "status": { "name": "Not started" } } } }

Citations:


Fix Notion “status” completion parsing to read status.status.name
status_done currently traverses status -> select -> name, but Notion “status” property values store the label under status -> status -> name (e.g., { "type": "status", "status": { "name": "In progress" } }), which can cause done tasks to be parsed as incomplete.

Proposed fix
                 let status_done = properties
                     .get("status")
-                    .and_then(|s| s.get("select"))
+                    .and_then(|s| s.get("status").or_else(|| s.get("select")))
                     .and_then(|s| s.get("name"))
                     .map(|status| status.eq_ignore_ascii_case("done"))
                     .unwrap_or(false);

Add a regression test that uses a Notion-like "status" property value payload.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/connector-notion/src/models.rs` around lines 95 - 98, The code that
extracts the Notion "status" label (referenced as status_done in models.rs)
currently walks status -> select -> name; change the traversal to status ->
status -> name by replacing the .get("select") step with .get("status") so the
chain becomes .get("status").and_then(|s| s.get("status")).and_then(|s|
s.get("name")).and_then(|s| s.as_str()); after fixing the parser, add a
regression test that feeds a Notion-like payload { "type":"status", "status": {
"name": "In progress" } } (or equivalent JSON) to the same parsing function to
assert completed/in-progress detection works correctly.

.map(|status| status.eq_ignore_ascii_case("done"))
.unwrap_or(false);
let completed = properties
.get("Completed")
.and_then(|c| c.get("checkbox"))
.and_then(|c| c.as_bool())
.unwrap_or(status_done);
Comment thread
cursor[bot] marked this conversation as resolved.

let completed = task
.get("properties")
.and_then(|p| p.get("Completed"))
.and_then(|c| c.get("checkbox"))
.and_then(|c| c.as_bool())
.unwrap_or(false);

Some(NotionTask {
id: task.get("id")?.as_str()?.into(),
title: title.into(),
completed,
due_date: task
.get("properties")
.and_then(|p| p.get("Due"))
.and_then(|d| d.get("date"))
.and_then(|d| d.get("start"))
.and_then(|s| s.as_str())
.map(|s| s.into()),
last_edited_time: task.get("last_edited_time")?.as_str()?.into(),
})
Some(NotionTask {
id: task.get("id")?.as_str()?.into(),
title: notion_title(properties, &["title", "Name", "name"]),
completed,
due_date: properties
.get("Due")
.and_then(|d| d.get("date"))
.and_then(|d| d.get("start"))
.and_then(|s| s.as_str())
.map(|s| s.into()),
last_edited_time: task
.get("last_edited_time")
.and_then(|t| t.as_str())
.unwrap_or_default()
.into(),
})
.collect()
} else {
vec![]
}
})
.collect()
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/connector-readwise/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ async-trait.workspace = true
tracing.workspace = true

# Observability
phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" }
phenotype-observably-macros = { workspace = true }

[dev-dependencies]
wiremock = "0.6"
Expand Down
94 changes: 61 additions & 33 deletions crates/connector-readwise/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,37 @@ pub struct Article {

impl Article {
pub fn from_readwise_json(json: &Value) -> Vec<Article> {
if let Some(results) = json.get("results").and_then(|r| r.as_array()) {
results
.iter()
.filter_map(|doc| {
Some(Article {
id: doc.get("id")?.as_str()?.into(),
title: doc.get("title")?.as_str()?.into(),
author: doc.get("author").and_then(|a| a.as_str()).map(|s| s.into()),
source_url: doc.get("source_url").and_then(|u| u.as_str()).map(|s| s.into()),
cover_image_url: doc.get("cover_image_url").and_then(|u| u.as_str()).map(|s| s.into()),
published_date: doc.get("published_date").and_then(|d| d.as_str()).map(|s| s.into()),
created_at: doc.get("created_at")?.as_str()?.into(),
updated_at: doc.get("updated_at")?.as_str()?.into(),
})
})
.collect()
let items: Vec<&Value> = if let Some(results) = json.get("results").and_then(|r| r.as_array()) {
results.iter().collect()
} else if json.get("id").is_some() && json.get("title").is_some() {
vec![json]
} else {
vec![]
}
};

items
.into_iter()
.filter_map(|doc| {
Some(Article {
id: doc.get("id")?.as_str()?.into(),
title: doc.get("title")?.as_str()?.into(),
author: doc.get("author").and_then(|a| a.as_str()).map(|s| s.into()),
source_url: doc.get("source_url").and_then(|u| u.as_str()).map(|s| s.into()),
cover_image_url: doc.get("cover_image_url").and_then(|u| u.as_str()).map(|s| s.into()),
published_date: doc.get("published_date").and_then(|d| d.as_str()).map(|s| s.into()),
created_at: doc
.get("created_at")
.and_then(|c| c.as_str())
.unwrap_or_default()
.into(),
updated_at: doc
.get("updated_at")
.and_then(|c| c.as_str())
.unwrap_or_default()
.into(),
})
})
.collect()
}
}

Expand All @@ -52,24 +64,40 @@ pub struct Highlight {

impl Highlight {
pub fn from_readwise_json(json: &Value) -> Vec<Highlight> {
if let Some(results) = json.get("results").and_then(|r| r.as_array()) {
results
.iter()
.filter_map(|h| {
Some(Highlight {
id: h.get("id")?.as_str()?.into(),
text: h.get("text")?.as_str()?.into(),
note: h.get("note").and_then(|n| n.as_str()).map(|s| s.into()),
document_id: h.get("document_id")?.as_str()?.into(),
color: h.get("color").and_then(|c| c.as_str()).map(|s| s.into()),
created_at: h.get("created_at")?.as_str()?.into(),
updated_at: h.get("updated_at")?.as_str()?.into(),
})
})
.collect()
let items: Vec<&Value> = if let Some(results) = json.get("results").and_then(|r| r.as_array()) {
results.iter().collect()
} else if json.get("id").is_some() && json.get("text").is_some() {
vec![json]
} else {
vec![]
}
};

items
.into_iter()
.filter_map(|h| {
Some(Highlight {
id: h.get("id")?.as_str()?.into(),
text: h.get("text")?.as_str()?.into(),
note: h.get("note").and_then(|n| n.as_str()).map(|s| s.into()),
document_id: h
.get("document_id")
.and_then(|d| d.as_str())
.unwrap_or_default()
.into(),
color: h.get("color").and_then(|c| c.as_str()).map(|s| s.into()),
created_at: h
.get("created_at")
.and_then(|c| c.as_str())
.unwrap_or_default()
.into(),
updated_at: h
.get("updated_at")
.and_then(|c| c.as_str())
.unwrap_or_default()
.into(),
})
})
.collect()
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/connector-strava/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ publish = false
# Workspace
focus-events.workspace = true
focus-connectors.workspace = true
phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" }
phenotype-observably-macros = { workspace = true }

# Core
serde.workspace = true
Expand Down
2 changes: 1 addition & 1 deletion crates/focus-always-on/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ chrono = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
async-trait = { workspace = true }
tracing = { workspace = true }
phenotype-observably-macros = { path = "../../../PhenoObservability/crates/phenotype-observably-macros" }
phenotype-observably-macros = { workspace = true }

# Local crates
focus-events = { path = "../focus-events" }
Expand Down
Loading
Loading