pub mod atlassian;
pub mod config;
pub mod confluence;
pub mod confluence_parse;
pub mod github_issues;
pub mod jira;
pub mod jira_parse;
pub mod orchestrator;
pub use config::{ContextSourcesConfig, ContextSourcesFileConfig, SourceConfig, SourceFileConfig};
pub use confluence::ConfluenceSource;
pub use github_issues::GithubIssuesSource;
pub use jira::JiraSource;
pub use orchestrator::{gather_external_context, render_sections};
use async_trait::async_trait;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum RetrievalMode {
#[default]
Live,
Semantic,
}
impl std::fmt::Display for RetrievalMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RetrievalMode::Live => write!(f, "live"),
RetrievalMode::Semantic => write!(f, "semantic"),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ReviewSubject {
pub owner: String,
pub repo: String,
pub title: String,
pub body: String,
pub changed_files: Vec<String>,
pub identifiers: Vec<String>,
}
impl ReviewSubject {
pub fn keyword_query(&self, max_identifiers: usize) -> String {
let mut parts: Vec<&str> = Vec::new();
let title = self.title.trim();
if !title.is_empty() {
parts.push(title);
}
let body = truncate_on_char_boundary(self.body.trim(), BODY_QUERY_CHARS).trim();
if !body.is_empty() && !parts.contains(&body) {
parts.push(body);
}
for id in self.identifiers.iter().take(max_identifiers) {
let id = id.trim();
if !id.is_empty() && !parts.contains(&id) {
parts.push(id);
}
}
parts.join(" ")
}
}
const BODY_QUERY_CHARS: usize = 500;
pub const SNIPPET_BODY_CHARS: usize = 500;
pub fn truncate_on_char_boundary(s: &str, max: usize) -> &str {
match s.char_indices().nth(max) {
Some((idx, _)) => &s[..idx],
None => s,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextSnippet {
pub title: String,
pub subtitle: Option<String>,
pub body: Option<String>,
pub link: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextSection {
pub heading: String,
pub snippets: Vec<ContextSnippet>,
}
#[derive(Debug, thiserror::Error)]
pub enum ContextSourceError {
#[error("{src} skipped: {reason}")]
NotConfigured {
src: &'static str,
reason: String,
},
#[error("{src} transport error: {err}")]
Transport {
src: &'static str,
err: TransportErr,
},
#[error("{src} API returned {status}: {body}")]
Api {
src: &'static str,
status: u16,
body: String,
},
#[error("{src} response parse error: {detail}")]
Parse {
src: &'static str,
detail: String,
},
#[error("{src}: semantic mode not yet implemented (see PR-B / APEX indexed knowledgebase)")]
SemanticNotImplemented {
src: &'static str,
},
}
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct TransportErr(pub String);
#[async_trait]
pub trait ContextSource: Send + Sync {
fn name(&self) -> &'static str;
fn is_enabled(&self) -> bool;
fn mode(&self) -> RetrievalMode;
async fn gather(&self, subject: &ReviewSubject) -> Result<ContextSection, ContextSourceError>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retrieval_mode_serde_roundtrip() {
let live: RetrievalMode = serde_json::from_str("\"live\"").unwrap();
assert_eq!(live, RetrievalMode::Live);
let sem: RetrievalMode = serde_json::from_str("\"semantic\"").unwrap();
assert_eq!(sem, RetrievalMode::Semantic);
assert_eq!(RetrievalMode::default(), RetrievalMode::Live);
assert_eq!(
serde_json::to_string(&RetrievalMode::Semantic).unwrap(),
"\"semantic\""
);
}
#[test]
fn keyword_query_combines_title_and_identifiers() {
let subj = ReviewSubject {
title: "Add auth flow".to_string(),
identifiers: vec!["authenticate".to_string(), "TokenStore".to_string()],
..Default::default()
};
let q = subj.keyword_query(8);
assert_eq!(q, "Add auth flow authenticate TokenStore");
}
#[test]
fn keyword_query_dedupes_and_caps_identifiers() {
let subj = ReviewSubject {
title: "fix".to_string(),
identifiers: vec![
"fix".to_string(), "a".to_string(),
"b".to_string(),
"c".to_string(),
],
..Default::default()
};
let q = subj.keyword_query(2);
assert_eq!(q, "fix a");
}
#[test]
fn keyword_query_includes_body() {
let subj = ReviewSubject {
title: "Add auth flow".to_string(),
body: "Implements PROJ-123: refresh tokens before expiry.".to_string(),
identifiers: vec!["TokenStore".to_string()],
..Default::default()
};
let q = subj.keyword_query(8);
assert_eq!(
q,
"Add auth flow Implements PROJ-123: refresh tokens before expiry. TokenStore"
);
}
#[test]
fn keyword_query_truncates_long_body() {
let long = "x".repeat(BODY_QUERY_CHARS + 200);
let subj = ReviewSubject {
title: "t".to_string(),
body: long,
..Default::default()
};
let q = subj.keyword_query(0);
assert_eq!(q.len(), 1 + 1 + BODY_QUERY_CHARS);
}
#[test]
fn truncate_on_char_boundary_short_input_unchanged() {
assert_eq!(truncate_on_char_boundary("hello", 10), "hello");
assert_eq!(truncate_on_char_boundary("hello", 5), "hello");
}
#[test]
fn truncate_on_char_boundary_long_input_clipped() {
assert_eq!(truncate_on_char_boundary("hello world", 5), "hello");
}
#[test]
fn truncate_on_char_boundary_respects_multibyte() {
let s = "héllo wörld"; let out = truncate_on_char_boundary(s, 5);
assert_eq!(out, "héllo");
let out2 = truncate_on_char_boundary(s, 2);
assert_eq!(out2, "hé");
}
#[test]
fn keyword_query_empty_when_no_signal() {
let subj = ReviewSubject {
title: " ".to_string(),
identifiers: vec![String::new(), " ".to_string()],
..Default::default()
};
assert_eq!(subj.keyword_query(8), "");
}
#[test]
fn context_source_error_display() {
let e = ContextSourceError::NotConfigured {
src: "jira",
reason: "ATLASSIAN_API_TOKEN unset".to_string(),
};
assert!(e.to_string().contains("jira skipped"));
assert!(e.to_string().contains("ATLASSIAN_API_TOKEN"));
let e = ContextSourceError::SemanticNotImplemented { src: "jira" };
assert!(e.to_string().contains("semantic mode not yet implemented"));
assert!(e.to_string().contains("PR-B"));
let e = ContextSourceError::Api {
src: "confluence",
status: 503,
body: "overloaded".to_string(),
};
let s = e.to_string();
assert!(s.contains("confluence"));
assert!(s.contains("503"));
}
#[test]
fn context_source_object_safe() {
fn _accepts(_s: &dyn ContextSource) {}
}
}