use std::sync::Arc;
use anyhow::Result;
use serde::Serialize;
use walkdir::WalkDir;
use crate::{
embed::Embedder,
memory::{
store::{compute_fingerprint_pub, MemoryStore},
types::{CreateMemory, MemoryType, Scope, Source},
},
};
#[derive(Debug, Serialize)]
pub struct IngestResult {
pub memories_stored: u64,
pub memories_skipped: u64,
pub errors: u64,
pub duplicate_labels: Vec<String>,
}
pub async fn ingest_github_prs(
repo: &str,
limit: Option<i32>,
project: Option<&str>,
memory_store: &MemoryStore,
embedder: Arc<dyn Embedder>,
) -> Result<IngestResult> {
let limit = limit.unwrap_or(50).min(200).max(1) as u32;
let parts: Vec<&str> = repo.split('/').collect();
if parts.len() != 2
|| parts.iter().any(|p| {
p.is_empty()
|| !p
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
})
{
anyhow::bail!(
"repo must be in 'owner/repo' format (alphanumeric, hyphens, underscores, dots only)"
);
}
let project = project
.map(|p| p.to_string())
.unwrap_or_else(|| repo.split('/').last().unwrap_or("unknown").to_string());
let output = tokio::process::Command::new("gh")
.args([
"pr",
"list",
"--repo",
repo,
"--state",
"merged",
"--limit",
&limit.to_string(),
"--json",
"number,title,body,mergedAt,author,url",
])
.output()
.await
.map_err(|e| {
anyhow::anyhow!(
"Failed to run gh CLI. Is GitHub CLI installed and on PATH? Error: {e}"
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("gh CLI failed: {stderr}");
}
let prs: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout)
.map_err(|e| anyhow::anyhow!("Failed to parse gh output: {e}"))?;
let mut memories_stored: u64 = 0;
let mut memories_skipped: u64 = 0;
let mut errors: u64 = 0;
for pr in &prs {
let title = pr["title"].as_str().unwrap_or("");
let body = pr["body"].as_str().unwrap_or("");
let number = pr["number"].as_u64().unwrap_or(0);
let url = pr["url"].as_str().unwrap_or("");
let author = pr["author"]
.as_object()
.and_then(|a| a["login"].as_str())
.unwrap_or("unknown");
if body.trim().len() < 50 {
memories_skipped += 1;
continue;
}
let content = format!("PR #{number}: {title}\n\n{body}");
let fingerprint = compute_fingerprint_pub(&content);
match memory_store.find_by_fingerprint(&fingerprint).await {
Ok(Some(_)) => {
memories_skipped += 1;
continue;
}
Err(e) => {
tracing::warn!("fingerprint check failed for PR #{number}: {e}");
errors += 1;
continue;
}
Ok(None) => {}
}
let embedder_clone = Arc::clone(&embedder);
let content_clone = content.clone();
let embedding =
match tokio::task::spawn_blocking(move || embedder_clone.embed(&content_clone)).await {
Ok(Ok(emb)) => emb,
Ok(Err(e)) => {
tracing::warn!("embedding failed for PR #{number}: {e}");
errors += 1;
continue;
}
Err(e) => {
tracing::warn!("spawn_blocking panicked for PR #{number}: {e}");
errors += 1;
continue;
}
};
let title_lower = title.to_lowercase();
let memory_type = if title_lower.contains("fix") || title_lower.contains("bug") {
MemoryType::ErrorPattern
} else if title_lower.contains("refactor") {
MemoryType::Pattern
} else {
MemoryType::Decision
};
let input = CreateMemory {
content,
summary: Some(format!("PR #{number}: {title}")),
memory_type,
source: Source {
system: "github".to_string(),
identifier: url.to_string(),
author: Some(author.to_string()),
},
scope: Scope {
organization: None,
team: None,
project: Some(project.clone()),
},
tags: vec!["github".to_string(), "pull-request".to_string()],
metadata: None,
importance: Some(0.6),
expires_at: None,
};
match memory_store.store(input, embedding).await {
Ok(_) => memories_stored += 1,
Err(e) => {
tracing::warn!("store failed for PR #{number}: {e}");
errors += 1;
}
}
}
Ok(IngestResult {
memories_stored,
memories_skipped,
errors,
duplicate_labels: Vec::new(),
})
}
pub async fn ingest_docs(
path: &str,
project: Option<&str>,
memory_store: &MemoryStore,
embedder: Arc<dyn Embedder>,
) -> Result<IngestResult> {
let project_name = project
.map(|p| p.to_string())
.unwrap_or_else(|| {
std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
});
const SKIP_DIRS: &[&str] = &[
"node_modules",
".git",
"vendor",
"target",
".venv",
"__pycache__",
".tox",
"dist",
"build",
".cache",
".next",
".nuxt",
];
let md_paths: Vec<std::path::PathBuf> = WalkDir::new(path)
.follow_links(false)
.into_iter()
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
if e.file_type().is_dir() {
if name.starts_with('.') {
return false;
}
if SKIP_DIRS.iter().any(|&d| d == name.as_ref()) {
return false;
}
}
true
})
.filter_map(|entry| entry.ok())
.filter(|e| {
e.file_type().is_file()
&& e.path()
.extension()
.map(|ext| ext.eq_ignore_ascii_case("md"))
.unwrap_or(false)
})
.map(|e| e.into_path())
.collect();
let mut memories_stored: u64 = 0;
let mut memories_skipped: u64 = 0;
let mut errors: u64 = 0;
for file_path in &md_paths {
let raw = match std::fs::read(file_path) {
Ok(bytes) => bytes,
Err(e) => {
tracing::warn!("skipping {}: read error: {e}", file_path.display());
errors += 1;
continue;
}
};
let content = match std::str::from_utf8(&raw) {
Ok(s) => s.to_string(),
Err(_) => {
tracing::debug!("skipping {} (not valid UTF-8)", file_path.display());
memories_skipped += 1;
continue;
}
};
let display_name = file_path
.strip_prefix(path)
.unwrap_or(file_path)
.display()
.to_string();
let file_stem = file_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let memory_type = classify_memory_type(&file_stem);
let importance = match memory_type {
MemoryType::Architecture => 0.8,
MemoryType::Guideline => 0.7,
_ => 0.6,
};
let sections = split_markdown_sections(&content, &display_name);
for (summary, section_content) in sections {
if section_content.trim().len() < 200 {
memories_skipped += 1;
continue;
}
let fingerprint = compute_fingerprint_pub(§ion_content);
match memory_store.find_by_fingerprint(&fingerprint).await {
Ok(Some(_)) => {
memories_skipped += 1;
continue;
}
Err(e) => {
tracing::warn!("fingerprint check failed for {display_name}: {e}");
errors += 1;
continue;
}
Ok(None) => {}
}
let embedder_clone = Arc::clone(&embedder);
let content_for_embed = section_content.clone();
let embedding = match tokio::task::spawn_blocking(move || {
embedder_clone.embed(&content_for_embed)
})
.await
{
Ok(Ok(emb)) => emb,
Ok(Err(e)) => {
tracing::warn!("embedding failed for {display_name}: {e}");
errors += 1;
continue;
}
Err(e) => {
tracing::warn!("spawn_blocking panicked for {display_name}: {e}");
errors += 1;
continue;
}
};
let tags = vec![
"docs".to_string(),
"markdown".to_string(),
file_stem.to_lowercase().replace([' ', '/'], "-"),
];
let input = CreateMemory {
content: section_content,
summary: Some(summary),
memory_type: memory_type.clone(),
source: Source {
system: "ingest_docs".to_string(),
identifier: file_path.display().to_string(),
author: None,
},
scope: Scope {
organization: None,
team: None,
project: Some(project_name.clone()),
},
tags,
metadata: None,
importance: Some(importance),
expires_at: None,
};
match memory_store.store(input, embedding).await {
Ok(_) => memories_stored += 1,
Err(e) => {
tracing::warn!("store failed for {display_name}: {e}");
errors += 1;
}
}
}
}
Ok(IngestResult {
memories_stored,
memories_skipped,
errors,
duplicate_labels: Vec::new(),
})
}
pub fn split_markdown_sections(content: &str, file_name: &str) -> Vec<(String, String)> {
let mut sections: Vec<(String, String)> = Vec::new();
let mut current_header: Option<String> = None;
let mut current_lines: Vec<&str> = Vec::new();
for line in content.lines() {
if line.starts_with("## ") {
if !current_lines.is_empty() {
let body = current_lines.join("\n");
let summary = match ¤t_header {
Some(h) => format!("{file_name}: {h}"),
None => file_name.to_string(),
};
sections.push((summary, body));
current_lines.clear();
}
let title = line.trim_start_matches('#').trim().to_string();
current_header = Some(title);
}
current_lines.push(line);
}
if !current_lines.is_empty() {
let body = current_lines.join("\n");
let summary = match ¤t_header {
Some(h) => format!("{file_name}: {h}"),
None => file_name.to_string(),
};
sections.push((summary, body));
}
sections
}
pub fn classify_memory_type(stem: &str) -> MemoryType {
let lower = stem.to_lowercase();
if lower.contains("architecture")
|| lower.contains("design")
|| lower.ends_with("-adr")
|| lower.ends_with("-decision")
|| lower.starts_with("adr-")
|| lower.starts_with("adr_")
{
return MemoryType::Architecture;
}
if lower.contains("contributing")
|| lower.contains("guideline")
|| lower.contains("style")
|| lower.contains("code_of_conduct")
|| lower.contains("conduct")
{
return MemoryType::Guideline;
}
MemoryType::CodeContext
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_split_no_h2_returns_single_section() {
let content = "# Title\n\nSome content here.\n\nMore content.";
let sections = split_markdown_sections(content, "readme");
assert_eq!(sections.len(), 1);
assert_eq!(sections[0].0, "readme");
assert!(sections[0].1.contains("Some content here."));
}
#[test]
fn test_split_two_h2_sections() {
let content = "## Overview\n\nFirst section.\n\n## Details\n\nSecond section.";
let sections = split_markdown_sections(content, "doc.md");
assert_eq!(sections.len(), 2);
assert_eq!(sections[0].0, "doc.md: Overview");
assert!(sections[0].1.contains("First section."));
assert_eq!(sections[1].0, "doc.md: Details");
assert!(sections[1].1.contains("Second section."));
}
#[test]
fn test_split_preamble_before_first_h2() {
let content = "Preamble text.\n\n## Section\n\nBody.";
let sections = split_markdown_sections(content, "file");
assert_eq!(sections.len(), 2);
assert_eq!(sections[0].0, "file"); assert_eq!(sections[1].0, "file: Section");
}
#[test]
fn test_split_empty_content() {
let sections = split_markdown_sections("", "empty");
assert_eq!(sections.len(), 0);
}
#[test]
fn test_classify_architecture() {
assert!(matches!(
classify_memory_type("ARCHITECTURE"),
MemoryType::Architecture
));
assert!(matches!(
classify_memory_type("design-overview"),
MemoryType::Architecture
));
assert!(matches!(
classify_memory_type("adr-001-use-postgres"),
MemoryType::Architecture
));
assert!(matches!(
classify_memory_type("adr_002"),
MemoryType::Architecture
));
assert!(matches!(
classify_memory_type("choose-redis-decision"),
MemoryType::Architecture
));
}
#[test]
fn test_classify_guideline() {
assert!(matches!(
classify_memory_type("CONTRIBUTING"),
MemoryType::Guideline
));
assert!(matches!(
classify_memory_type("style-guide"),
MemoryType::Guideline
));
assert!(matches!(
classify_memory_type("code_of_conduct"),
MemoryType::Guideline
));
assert!(matches!(
classify_memory_type("guidelines"),
MemoryType::Guideline
));
}
#[test]
fn test_classify_code_context_default() {
assert!(matches!(
classify_memory_type("README"),
MemoryType::CodeContext
));
assert!(matches!(
classify_memory_type("changelog"),
MemoryType::CodeContext
));
assert!(matches!(
classify_memory_type("setup"),
MemoryType::CodeContext
));
}
}