diff --git a/CHANGELOG.md b/CHANGELOG.md index 79b3ce41..824813d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,11 @@ User-facing release notes for RelayHistory. Every public package — the `ai-his This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Before 1.0, a breaking change is a minor release. -## [Unreleased] +## [Unreleased - Patch] + +### Fixed + +- Devin remote capture skips provider-classified `code_review` and `code_review_and_analysis` sessions only when their origin is `automation`; person-started reviews and other work remain discoverable. ## [0.45.2] - 2026-10-17 diff --git a/plugins/provider-sources/rust/src/remote/devin_cloud.rs b/plugins/provider-sources/rust/src/remote/devin_cloud.rs index 109d0126..e2c7021b 100644 --- a/plugins/provider-sources/rust/src/remote/devin_cloud.rs +++ b/plugins/provider-sources/rust/src/remote/devin_cloud.rs @@ -168,10 +168,31 @@ fn ensure_devin_status(what: &str, scope: &str, status: u16, body: &str) -> Resu } } +/// `category` values the v3 listing assigns to code-review work. Cognition's +/// review sessions — Devin Review and push-review automations — run inside the +/// org as `automation`-origin sessions, so a review category only skips a row +/// when automation spawned it: a person-directed session whose work Cognition +/// classifies as review still belongs in the catalog. +const REVIEW_CATEGORIES: [&str; 2] = ["code_review", "code_review_and_analysis"]; + +/// The absolute fetch bound on one session-list enumeration. Pages where +/// every item is filtered out — review automations — still count toward this +/// backstop, so the walk always terminates; only yielding pages count toward +/// `MAX_LIST_PAGES`, the shared bound that bounds catalogable results. +const MAX_LIST_FETCHES: usize = MAX_LIST_PAGES * 4; + /// Map one session object from `GET /v3/organizations/{org}/sessions` into a /// catalog row. The listing's `title` is the only human-readable identifier -/// the endpoint offers; `None` for a malformed id. +/// the endpoint offers; `None` for a malformed id or a review session, which +/// is an automation artifact rather than work a person directed. pub(super) fn map_devin_session(value: &Value) -> Option<(Candidate, ShallowSession)> { + if string_field(value, "origin").as_deref() == Some("automation") + && REVIEW_CATEGORIES + .iter() + .any(|category| string_field(value, "category").as_deref() == Some(category)) + { + return None; + } let id = string_field(value, "session_id")?; let id = devin_session_hex(&id)?.to_string(); let created_ms = value @@ -467,7 +488,8 @@ impl ShallowSessionProvider for DevinCloudProvider { let mut candidates = Vec::new(); let mut rows = BTreeMap::new(); let mut cursor: Option = None; - for _ in 0..MAX_LIST_PAGES { + let mut yielding_pages = 0usize; + for _ in 0..MAX_LIST_FETCHES { let page_limit = match self.limit { Some(limit) => limit .saturating_sub(candidates.len()) @@ -483,15 +505,18 @@ impl ShallowSessionProvider for DevinCloudProvider { &response.body, )?; let page = parse_devin_page(&response.body, "session list")?; + let before = candidates.len(); for item in &page.items { if let Some((candidate, session)) = map_devin_session(item) { rows.insert(candidate.locator.clone(), session); candidates.push(candidate); } } + yielding_pages += usize::from(candidates.len() > before); cursor = page.end_cursor.filter(|_| page.has_next_page); let done = cursor.is_none() || page.items.is_empty() + || yielding_pages >= MAX_LIST_PAGES || self.limit.is_some_and(|limit| candidates.len() >= limit); if done { break; @@ -502,7 +527,7 @@ impl ShallowSessionProvider for DevinCloudProvider { // caller-specified limit that was reached is not truncation. anyhow::ensure!( cursor.is_none() || self.limit.is_some_and(|limit| candidates.len() >= limit), - "EVIDENCE_PARTIAL: Devin session list exceeded the 100-page bound" + "EVIDENCE_PARTIAL: Devin session list exceeded the page bound" ); *self.fetched.lock().expect("remote connector row cache") = rows; Ok(candidates) diff --git a/plugins/provider-sources/rust/src/remote/tests.rs b/plugins/provider-sources/rust/src/remote/tests.rs index 899cf0ab..8029fb79 100644 --- a/plugins/provider-sources/rust/src/remote/tests.rs +++ b/plugins/provider-sources/rust/src/remote/tests.rs @@ -1019,6 +1019,57 @@ fn devin_mapping_stamp_tracks_the_mapped_listing_fields() { assert_eq!(base.0.stamp, identical.0.stamp); } +#[test] +fn devin_mapping_skips_review_sessions_and_keeps_uncategorized_work() { + // Devin Review and push-review automations are `automation`-origin + // sessions the classifier files under the review categories; they carry + // no conversational history worth keeping. + for category in ["code_review", "code_review_and_analysis"] { + let mut entry = devin_session(DEVIN_SESSION_ID, "Review this push"); + entry["category"] = serde_json::json!(category); + assert!( + map_devin_session(&entry).is_none(), + "{category} must not map" + ); + } + // Uncategorized and non-review sessions still map: `category` is only + // populated once Cognition's classifier has run. + for category in [ + serde_json::Value::Null, + serde_json::json!("bug_fixing"), + serde_json::json!("other"), + ] { + let mut entry = devin_session(DEVIN_SESSION_ID, "t"); + entry["category"] = category.clone(); + assert!( + map_devin_session(&entry).is_some(), + "{category} must still map" + ); + } + // The review filter is scoped to automation-spawned sessions: a + // person-directed session whose work the classifier files as review + // still belongs in the catalog. + for category in ["code_review", "code_review_and_analysis"] { + for origin in [ + None, + Some("webapp"), + Some("slack"), + Some("teams"), + Some("api"), + Some("desktop"), + Some("cli"), + ] { + let mut entry = devin_session(DEVIN_SESSION_ID, "Review my PR"); + entry["origin"] = serde_json::json!(origin); + entry["category"] = serde_json::json!(category); + assert!( + map_devin_session(&entry).is_some(), + "{origin:?} {category} work must still map" + ); + } + } +} + #[test] fn devin_mapping_skips_malformed_ids_and_normalizes_the_prefixed_form() { for bad_id in [ @@ -1074,6 +1125,44 @@ fn devin_credentials_parse_the_flat_toml() { assert!(devin_org_id(&config).is_none()); } +#[test] +fn devin_enumeration_keeps_walking_past_review_only_pages() { + // An org whose recent history is dense with Devin Review automations + // must not exhaust the page bound on filtered sessions: pages that yield + // nothing do not count toward it, while an absolute fetch bound still + // terminates the walk. + let _cleared = without_credentials_override(); + let home = tempfile::tempdir().unwrap(); + let mut review = devin_session(DEVIN_SESSION_ID, "Review this push"); + review["category"] = serde_json::json!("code_review"); + let mut script: Vec<(u16, String)> = (0..MAX_LIST_PAGES + 20) + .map(|page| { + ( + 200, + devin_page(&[review.clone()], Some(&format!("cursor+{page}")), true), + ) + }) + .collect(); + script.push(( + 200, + devin_page( + &[devin_session("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "real")], + None, + false, + ), + )); + let transport = Arc::new(ScriptedDevinTransport::new(script)); + let provider = devin_provider(home.path(), &transport, None); + let conn = catalog(); + let env = env_at(&conn, home.path()); + let candidates = provider.enumerate(&env, None).unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!( + candidates[0].session_id.as_deref(), + Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); +} + #[test] fn devin_enumeration_pages_with_first_and_after() { let _cleared = without_credentials_override();