feat: implement per-note search and document pipeline
- Add SourceType::Note with extract_note_document() and ParentMetadataCache - Migration 022: composite indexes for notes queries + author_id column - Migration 024: table rebuild adding 'note' to CHECK constraints, defense triggers - Migration 025: backfill existing non-system notes into dirty queue - Add lore notes CLI command with 17 filter options (author, path, resolution, etc.) - Support table/json/jsonl/csv output formats with field selection - Wire note dirty tracking through discussion and MR discussion ingestion - Fix test_migration_024_preserves_existing_data off-by-one (tested wrong migration) - Fix upsert_document_inner returning false for label/path-only changes
This commit is contained in:
@@ -2,13 +2,14 @@ use chrono::DateTime;
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use super::truncation::{
|
||||
MAX_DISCUSSION_BYTES, NoteContent, truncate_discussion, truncate_hard_cap,
|
||||
};
|
||||
use crate::core::error::Result;
|
||||
use crate::core::time::ms_to_iso;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -16,6 +17,7 @@ pub enum SourceType {
|
||||
Issue,
|
||||
MergeRequest,
|
||||
Discussion,
|
||||
Note,
|
||||
}
|
||||
|
||||
impl SourceType {
|
||||
@@ -24,6 +26,7 @@ impl SourceType {
|
||||
Self::Issue => "issue",
|
||||
Self::MergeRequest => "merge_request",
|
||||
Self::Discussion => "discussion",
|
||||
Self::Note => "note",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +35,7 @@ impl SourceType {
|
||||
"issue" | "issues" => Some(Self::Issue),
|
||||
"mr" | "mrs" | "merge_request" | "merge_requests" => Some(Self::MergeRequest),
|
||||
"discussion" | "discussions" => Some(Self::Discussion),
|
||||
"note" | "notes" => Some(Self::Note),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -515,6 +519,521 @@ pub fn extract_discussion_document(
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn extract_note_document(conn: &Connection, note_id: i64) -> Result<Option<DocumentData>> {
|
||||
let row = conn.query_row(
|
||||
"SELECT n.id, n.gitlab_id, n.author_username, n.body, n.note_type, n.is_system,
|
||||
n.created_at, n.updated_at, n.position_new_path, n.position_new_line,
|
||||
n.position_old_path, n.position_old_line, n.resolvable, n.resolved, n.resolved_by,
|
||||
d.noteable_type, d.issue_id, d.merge_request_id,
|
||||
p.path_with_namespace, p.id AS project_id
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN projects p ON n.project_id = p.id
|
||||
WHERE n.id = ?1",
|
||||
rusqlite::params![note_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
row.get::<_, Option<String>>(4)?,
|
||||
row.get::<_, bool>(5)?,
|
||||
row.get::<_, i64>(6)?,
|
||||
row.get::<_, i64>(7)?,
|
||||
row.get::<_, Option<String>>(8)?,
|
||||
row.get::<_, Option<i64>>(9)?,
|
||||
row.get::<_, Option<String>>(10)?,
|
||||
row.get::<_, Option<i64>>(11)?,
|
||||
row.get::<_, bool>(12)?,
|
||||
row.get::<_, bool>(13)?,
|
||||
row.get::<_, Option<String>>(14)?,
|
||||
row.get::<_, String>(15)?,
|
||||
row.get::<_, Option<i64>>(16)?,
|
||||
row.get::<_, Option<i64>>(17)?,
|
||||
row.get::<_, String>(18)?,
|
||||
row.get::<_, i64>(19)?,
|
||||
))
|
||||
},
|
||||
);
|
||||
|
||||
let (
|
||||
_id,
|
||||
gitlab_id,
|
||||
author_username,
|
||||
body,
|
||||
note_type,
|
||||
is_system,
|
||||
created_at,
|
||||
updated_at,
|
||||
position_new_path,
|
||||
position_new_line,
|
||||
position_old_path,
|
||||
_position_old_line,
|
||||
resolvable,
|
||||
resolved,
|
||||
_resolved_by,
|
||||
noteable_type,
|
||||
issue_id,
|
||||
merge_request_id,
|
||||
path_with_namespace,
|
||||
project_id,
|
||||
) = match row {
|
||||
Ok(r) => r,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
if is_system {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let (parent_iid, parent_title, parent_web_url, parent_type_label, labels) =
|
||||
match noteable_type.as_str() {
|
||||
"Issue" => {
|
||||
let parent_id = match issue_id {
|
||||
Some(pid) => pid,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let parent = conn.query_row(
|
||||
"SELECT i.iid, i.title, i.web_url FROM issues i WHERE i.id = ?1",
|
||||
rusqlite::params![parent_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
},
|
||||
);
|
||||
let (iid, title, web_url) = match parent {
|
||||
Ok(r) => r,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mut label_stmt = conn.prepare_cached(
|
||||
"SELECT l.name FROM issue_labels il
|
||||
JOIN labels l ON l.id = il.label_id
|
||||
WHERE il.issue_id = ?1
|
||||
ORDER BY l.name",
|
||||
)?;
|
||||
let labels: Vec<String> = label_stmt
|
||||
.query_map(rusqlite::params![parent_id], |row| row.get(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
(iid, title, web_url, "Issue", labels)
|
||||
}
|
||||
"MergeRequest" => {
|
||||
let parent_id = match merge_request_id {
|
||||
Some(pid) => pid,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let parent = conn.query_row(
|
||||
"SELECT m.iid, m.title, m.web_url FROM merge_requests m WHERE m.id = ?1",
|
||||
rusqlite::params![parent_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
},
|
||||
);
|
||||
let (iid, title, web_url) = match parent {
|
||||
Ok(r) => r,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mut label_stmt = conn.prepare_cached(
|
||||
"SELECT l.name FROM mr_labels ml
|
||||
JOIN labels l ON l.id = ml.label_id
|
||||
WHERE ml.merge_request_id = ?1
|
||||
ORDER BY l.name",
|
||||
)?;
|
||||
let labels: Vec<String> = label_stmt
|
||||
.query_map(rusqlite::params![parent_id], |row| row.get(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
|
||||
(iid, title, web_url, "MergeRequest", labels)
|
||||
}
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
build_note_document(
|
||||
note_id,
|
||||
gitlab_id,
|
||||
author_username,
|
||||
body,
|
||||
note_type,
|
||||
created_at,
|
||||
updated_at,
|
||||
position_new_path,
|
||||
position_new_line,
|
||||
position_old_path,
|
||||
resolvable,
|
||||
resolved,
|
||||
parent_iid,
|
||||
parent_title.as_deref(),
|
||||
parent_web_url.as_deref(),
|
||||
&labels,
|
||||
parent_type_label,
|
||||
&path_with_namespace,
|
||||
project_id,
|
||||
)
|
||||
}
|
||||
|
||||
pub struct ParentMetadata {
|
||||
pub iid: i64,
|
||||
pub title: Option<String>,
|
||||
pub web_url: Option<String>,
|
||||
pub labels: Vec<String>,
|
||||
pub project_path: String,
|
||||
}
|
||||
|
||||
pub struct ParentMetadataCache {
|
||||
cache: HashMap<(String, i64), Option<ParentMetadata>>,
|
||||
}
|
||||
|
||||
impl Default for ParentMetadataCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentMetadataCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
cache: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_or_fetch(
|
||||
&mut self,
|
||||
conn: &Connection,
|
||||
noteable_type: &str,
|
||||
parent_id: i64,
|
||||
project_path: &str,
|
||||
) -> Result<Option<&ParentMetadata>> {
|
||||
let key = (noteable_type.to_string(), parent_id);
|
||||
if !self.cache.contains_key(&key) {
|
||||
let meta = fetch_parent_metadata(conn, noteable_type, parent_id, project_path)?;
|
||||
self.cache.insert(key.clone(), meta);
|
||||
}
|
||||
Ok(self.cache.get(&key).and_then(|m| m.as_ref()))
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_parent_metadata(
|
||||
conn: &Connection,
|
||||
noteable_type: &str,
|
||||
parent_id: i64,
|
||||
project_path: &str,
|
||||
) -> Result<Option<ParentMetadata>> {
|
||||
match noteable_type {
|
||||
"Issue" => {
|
||||
let parent = conn.query_row(
|
||||
"SELECT i.iid, i.title, i.web_url FROM issues i WHERE i.id = ?1",
|
||||
rusqlite::params![parent_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
},
|
||||
);
|
||||
let (iid, title, web_url) = match parent {
|
||||
Ok(r) => r,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mut label_stmt = conn.prepare_cached(
|
||||
"SELECT l.name FROM issue_labels il
|
||||
JOIN labels l ON l.id = il.label_id
|
||||
WHERE il.issue_id = ?1
|
||||
ORDER BY l.name",
|
||||
)?;
|
||||
let labels: Vec<String> = label_stmt
|
||||
.query_map(rusqlite::params![parent_id], |row| row.get(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
Ok(Some(ParentMetadata {
|
||||
iid,
|
||||
title,
|
||||
web_url,
|
||||
labels,
|
||||
project_path: project_path.to_string(),
|
||||
}))
|
||||
}
|
||||
"MergeRequest" => {
|
||||
let parent = conn.query_row(
|
||||
"SELECT m.iid, m.title, m.web_url FROM merge_requests m WHERE m.id = ?1",
|
||||
rusqlite::params![parent_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
},
|
||||
);
|
||||
let (iid, title, web_url) = match parent {
|
||||
Ok(r) => r,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let mut label_stmt = conn.prepare_cached(
|
||||
"SELECT l.name FROM mr_labels ml
|
||||
JOIN labels l ON l.id = ml.label_id
|
||||
WHERE ml.merge_request_id = ?1
|
||||
ORDER BY l.name",
|
||||
)?;
|
||||
let labels: Vec<String> = label_stmt
|
||||
.query_map(rusqlite::params![parent_id], |row| row.get(0))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
Ok(Some(ParentMetadata {
|
||||
iid,
|
||||
title,
|
||||
web_url,
|
||||
labels,
|
||||
project_path: project_path.to_string(),
|
||||
}))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_note_document_cached(
|
||||
conn: &Connection,
|
||||
note_id: i64,
|
||||
cache: &mut ParentMetadataCache,
|
||||
) -> Result<Option<DocumentData>> {
|
||||
let row = conn.query_row(
|
||||
"SELECT n.id, n.gitlab_id, n.author_username, n.body, n.note_type, n.is_system,
|
||||
n.created_at, n.updated_at, n.position_new_path, n.position_new_line,
|
||||
n.position_old_path, n.position_old_line, n.resolvable, n.resolved, n.resolved_by,
|
||||
d.noteable_type, d.issue_id, d.merge_request_id,
|
||||
p.path_with_namespace, p.id AS project_id
|
||||
FROM notes n
|
||||
JOIN discussions d ON n.discussion_id = d.id
|
||||
JOIN projects p ON n.project_id = p.id
|
||||
WHERE n.id = ?1",
|
||||
rusqlite::params![note_id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, i64>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
row.get::<_, Option<String>>(4)?,
|
||||
row.get::<_, bool>(5)?,
|
||||
row.get::<_, i64>(6)?,
|
||||
row.get::<_, i64>(7)?,
|
||||
row.get::<_, Option<String>>(8)?,
|
||||
row.get::<_, Option<i64>>(9)?,
|
||||
row.get::<_, Option<String>>(10)?,
|
||||
row.get::<_, Option<i64>>(11)?,
|
||||
row.get::<_, bool>(12)?,
|
||||
row.get::<_, bool>(13)?,
|
||||
row.get::<_, Option<String>>(14)?,
|
||||
row.get::<_, String>(15)?,
|
||||
row.get::<_, Option<i64>>(16)?,
|
||||
row.get::<_, Option<i64>>(17)?,
|
||||
row.get::<_, String>(18)?,
|
||||
row.get::<_, i64>(19)?,
|
||||
))
|
||||
},
|
||||
);
|
||||
|
||||
let (
|
||||
_id,
|
||||
gitlab_id,
|
||||
author_username,
|
||||
body,
|
||||
note_type,
|
||||
is_system,
|
||||
created_at,
|
||||
updated_at,
|
||||
position_new_path,
|
||||
position_new_line,
|
||||
position_old_path,
|
||||
_position_old_line,
|
||||
resolvable,
|
||||
resolved,
|
||||
_resolved_by,
|
||||
noteable_type,
|
||||
issue_id,
|
||||
merge_request_id,
|
||||
path_with_namespace,
|
||||
project_id,
|
||||
) = match row {
|
||||
Ok(r) => r,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
if is_system {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let parent_id = match noteable_type.as_str() {
|
||||
"Issue" => match issue_id {
|
||||
Some(pid) => pid,
|
||||
None => return Ok(None),
|
||||
},
|
||||
"MergeRequest" => match merge_request_id {
|
||||
Some(pid) => pid,
|
||||
None => return Ok(None),
|
||||
},
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let parent = cache.get_or_fetch(conn, ¬eable_type, parent_id, &path_with_namespace)?;
|
||||
let parent = match parent {
|
||||
Some(p) => p,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let parent_iid = parent.iid;
|
||||
let parent_title = parent.title.as_deref();
|
||||
let parent_web_url = parent.web_url.as_deref();
|
||||
let labels = parent.labels.clone();
|
||||
let parent_type_label = noteable_type.as_str();
|
||||
|
||||
build_note_document(
|
||||
note_id,
|
||||
gitlab_id,
|
||||
author_username,
|
||||
body,
|
||||
note_type,
|
||||
created_at,
|
||||
updated_at,
|
||||
position_new_path,
|
||||
position_new_line,
|
||||
position_old_path,
|
||||
resolvable,
|
||||
resolved,
|
||||
parent_iid,
|
||||
parent_title,
|
||||
parent_web_url,
|
||||
&labels,
|
||||
parent_type_label,
|
||||
&path_with_namespace,
|
||||
project_id,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_note_document(
|
||||
note_id: i64,
|
||||
gitlab_id: i64,
|
||||
author_username: Option<String>,
|
||||
body: Option<String>,
|
||||
note_type: Option<String>,
|
||||
created_at: i64,
|
||||
updated_at: i64,
|
||||
position_new_path: Option<String>,
|
||||
position_new_line: Option<i64>,
|
||||
position_old_path: Option<String>,
|
||||
resolvable: bool,
|
||||
resolved: bool,
|
||||
parent_iid: i64,
|
||||
parent_title: Option<&str>,
|
||||
parent_web_url: Option<&str>,
|
||||
labels: &[String],
|
||||
parent_type_label: &str,
|
||||
path_with_namespace: &str,
|
||||
project_id: i64,
|
||||
) -> Result<Option<DocumentData>> {
|
||||
let mut path_set = BTreeSet::new();
|
||||
if let Some(ref p) = position_old_path
|
||||
&& !p.is_empty()
|
||||
{
|
||||
path_set.insert(p.clone());
|
||||
}
|
||||
if let Some(ref p) = position_new_path
|
||||
&& !p.is_empty()
|
||||
{
|
||||
path_set.insert(p.clone());
|
||||
}
|
||||
let paths: Vec<String> = path_set.into_iter().collect();
|
||||
|
||||
let url = parent_web_url.map(|wu| format!("{}#note_{}", wu, gitlab_id));
|
||||
|
||||
let display_title = parent_title.unwrap_or("(untitled)");
|
||||
let display_note_type = note_type.as_deref().unwrap_or("Note");
|
||||
let display_author = author_username.as_deref().unwrap_or("unknown");
|
||||
let parent_prefix = if parent_type_label == "Issue" {
|
||||
format!("Issue #{}", parent_iid)
|
||||
} else {
|
||||
format!("MR !{}", parent_iid)
|
||||
};
|
||||
|
||||
let title = format!(
|
||||
"Note by @{} on {}: {}",
|
||||
display_author, parent_prefix, display_title
|
||||
);
|
||||
|
||||
let labels_csv = labels.join(", ");
|
||||
|
||||
let mut content = String::new();
|
||||
let _ = writeln!(content, "[[Note]]");
|
||||
let _ = writeln!(content, "source_type: note");
|
||||
let _ = writeln!(content, "note_gitlab_id: {}", gitlab_id);
|
||||
let _ = writeln!(content, "project: {}", path_with_namespace);
|
||||
let _ = writeln!(content, "parent_type: {}", parent_type_label);
|
||||
let _ = writeln!(content, "parent_iid: {}", parent_iid);
|
||||
let _ = writeln!(content, "parent_title: {}", display_title);
|
||||
let _ = writeln!(content, "note_type: {}", display_note_type);
|
||||
let _ = writeln!(content, "author: @{}", display_author);
|
||||
let _ = writeln!(content, "created_at: {}", ms_to_iso(created_at));
|
||||
if resolvable {
|
||||
let _ = writeln!(content, "resolved: {}", resolved);
|
||||
}
|
||||
if display_note_type == "DiffNote"
|
||||
&& let Some(ref p) = position_new_path
|
||||
{
|
||||
if let Some(line) = position_new_line {
|
||||
let _ = writeln!(content, "path: {}:{}", p, line);
|
||||
} else {
|
||||
let _ = writeln!(content, "path: {}", p);
|
||||
}
|
||||
}
|
||||
if !labels.is_empty() {
|
||||
let _ = writeln!(content, "labels: {}", labels_csv);
|
||||
}
|
||||
if let Some(ref u) = url {
|
||||
let _ = writeln!(content, "url: {}", u);
|
||||
}
|
||||
|
||||
content.push_str("\n--- Body ---\n\n");
|
||||
content.push_str(body.as_deref().unwrap_or(""));
|
||||
|
||||
let labels_hash = compute_list_hash(labels);
|
||||
let paths_hash = compute_list_hash(&paths);
|
||||
|
||||
let hard_cap = truncate_hard_cap(&content);
|
||||
let content_hash = compute_content_hash(&hard_cap.content);
|
||||
|
||||
Ok(Some(DocumentData {
|
||||
source_type: SourceType::Note,
|
||||
source_id: note_id,
|
||||
project_id,
|
||||
author_username,
|
||||
labels: labels.to_vec(),
|
||||
paths,
|
||||
labels_hash,
|
||||
paths_hash,
|
||||
created_at,
|
||||
updated_at,
|
||||
url,
|
||||
title: Some(title),
|
||||
content_text: hard_cap.content,
|
||||
content_hash,
|
||||
is_truncated: hard_cap.is_truncated,
|
||||
truncated_reason: hard_cap.reason.map(|r| r.as_str().to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -545,6 +1064,26 @@ mod tests {
|
||||
assert_eq!(SourceType::parse("ISSUE"), Some(SourceType::Issue));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_source_type_parse_note() {
|
||||
assert_eq!(SourceType::parse("note"), Some(SourceType::Note));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_source_type_note_as_str() {
|
||||
assert_eq!(SourceType::Note.as_str(), "note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_source_type_note_display() {
|
||||
assert_eq!(format!("{}", SourceType::Note), "note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_source_type_parse_notes_alias() {
|
||||
assert_eq!(SourceType::parse("notes"), Some(SourceType::Note));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_source_type_as_str() {
|
||||
assert_eq!(SourceType::Issue.as_str(), "issue");
|
||||
@@ -1449,4 +1988,354 @@ mod tests {
|
||||
let result = extract_discussion_document(&conn, 1).unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn insert_note_with_type(
|
||||
conn: &Connection,
|
||||
id: i64,
|
||||
gitlab_id: i64,
|
||||
discussion_id: i64,
|
||||
author: Option<&str>,
|
||||
body: Option<&str>,
|
||||
created_at: i64,
|
||||
is_system: bool,
|
||||
old_path: Option<&str>,
|
||||
new_path: Option<&str>,
|
||||
old_line: Option<i64>,
|
||||
new_line: Option<i64>,
|
||||
note_type: Option<&str>,
|
||||
resolvable: bool,
|
||||
resolved: bool,
|
||||
) {
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system, position_old_path, position_new_path, position_old_line, position_new_line, note_type, resolvable, resolved) VALUES (?1, ?2, ?3, 1, ?4, ?5, ?6, ?6, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
rusqlite::params![id, gitlab_id, discussion_id, author, body, created_at, is_system as i32, old_path, new_path, old_line, new_line, note_type, resolvable as i32, resolved as i32],
|
||||
).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_document_basic_format() {
|
||||
let conn = setup_discussion_test_db();
|
||||
insert_issue(
|
||||
&conn,
|
||||
1,
|
||||
42,
|
||||
Some("Fix login bug"),
|
||||
Some("desc"),
|
||||
"opened",
|
||||
Some("johndoe"),
|
||||
Some("https://gitlab.example.com/group/project-one/-/issues/42"),
|
||||
);
|
||||
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
||||
insert_note(
|
||||
&conn,
|
||||
1,
|
||||
12345,
|
||||
1,
|
||||
Some("alice"),
|
||||
Some("This looks like a race condition"),
|
||||
1710460800000,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||
assert_eq!(doc.source_type, SourceType::Note);
|
||||
assert_eq!(doc.source_id, 1);
|
||||
assert_eq!(doc.project_id, 1);
|
||||
assert_eq!(doc.author_username, Some("alice".to_string()));
|
||||
assert!(doc.content_text.contains("[[Note]]"));
|
||||
assert!(doc.content_text.contains("source_type: note"));
|
||||
assert!(doc.content_text.contains("note_gitlab_id: 12345"));
|
||||
assert!(doc.content_text.contains("project: group/project-one"));
|
||||
assert!(doc.content_text.contains("parent_type: Issue"));
|
||||
assert!(doc.content_text.contains("parent_iid: 42"));
|
||||
assert!(doc.content_text.contains("parent_title: Fix login bug"));
|
||||
assert!(doc.content_text.contains("author: @alice"));
|
||||
assert!(doc.content_text.contains("--- Body ---"));
|
||||
assert!(
|
||||
doc.content_text
|
||||
.contains("This looks like a race condition")
|
||||
);
|
||||
assert_eq!(
|
||||
doc.title,
|
||||
Some("Note by @alice on Issue #42: Fix login bug".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
doc.url,
|
||||
Some("https://gitlab.example.com/group/project-one/-/issues/42#note_12345".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_document_diffnote_with_path() {
|
||||
let conn = setup_discussion_test_db();
|
||||
insert_issue(
|
||||
&conn,
|
||||
1,
|
||||
10,
|
||||
Some("Refactor auth"),
|
||||
Some("desc"),
|
||||
"opened",
|
||||
None,
|
||||
Some("https://gitlab.example.com/group/project-one/-/issues/10"),
|
||||
);
|
||||
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
||||
insert_note_with_type(
|
||||
&conn,
|
||||
1,
|
||||
555,
|
||||
1,
|
||||
Some("bob"),
|
||||
Some("Unused variable here"),
|
||||
1000,
|
||||
false,
|
||||
Some("src/old_auth.rs"),
|
||||
Some("src/auth.rs"),
|
||||
Some(10),
|
||||
Some(25),
|
||||
Some("DiffNote"),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
|
||||
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||
assert!(doc.content_text.contains("note_type: DiffNote"));
|
||||
assert!(doc.content_text.contains("path: src/auth.rs:25"));
|
||||
assert!(doc.content_text.contains("resolved: false"));
|
||||
assert_eq!(doc.paths, vec!["src/auth.rs", "src/old_auth.rs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_document_inherits_parent_labels() {
|
||||
let conn = setup_discussion_test_db();
|
||||
insert_issue(
|
||||
&conn,
|
||||
1,
|
||||
10,
|
||||
Some("Test"),
|
||||
Some("desc"),
|
||||
"opened",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
insert_label(&conn, 1, "backend");
|
||||
insert_label(&conn, 2, "api");
|
||||
link_issue_label(&conn, 1, 1);
|
||||
link_issue_label(&conn, 1, 2);
|
||||
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
||||
insert_note(
|
||||
&conn,
|
||||
1,
|
||||
100,
|
||||
1,
|
||||
Some("alice"),
|
||||
Some("Note body"),
|
||||
1000,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||
assert_eq!(doc.labels, vec!["api", "backend"]);
|
||||
assert!(doc.content_text.contains("labels: api, backend"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_document_mr_parent() {
|
||||
let conn = setup_discussion_test_db();
|
||||
insert_mr(
|
||||
&conn,
|
||||
1,
|
||||
456,
|
||||
Some("JWT Auth"),
|
||||
Some("desc"),
|
||||
Some("opened"),
|
||||
Some("johndoe"),
|
||||
Some("feature/jwt"),
|
||||
Some("main"),
|
||||
Some("https://gitlab.example.com/group/project-one/-/merge_requests/456"),
|
||||
);
|
||||
insert_discussion(&conn, 1, "MergeRequest", None, Some(1));
|
||||
insert_note(
|
||||
&conn,
|
||||
1,
|
||||
200,
|
||||
1,
|
||||
Some("reviewer"),
|
||||
Some("Needs tests"),
|
||||
1000,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||
assert!(doc.content_text.contains("parent_type: MergeRequest"));
|
||||
assert!(doc.content_text.contains("parent_iid: 456"));
|
||||
assert_eq!(
|
||||
doc.title,
|
||||
Some("Note by @reviewer on MR !456: JWT Auth".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_document_system_note_returns_none() {
|
||||
let conn = setup_discussion_test_db();
|
||||
insert_issue(
|
||||
&conn,
|
||||
1,
|
||||
10,
|
||||
Some("Test"),
|
||||
Some("desc"),
|
||||
"opened",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
||||
insert_note(
|
||||
&conn,
|
||||
1,
|
||||
100,
|
||||
1,
|
||||
Some("bot"),
|
||||
Some("assigned to @alice"),
|
||||
1000,
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let result = extract_note_document(&conn, 1).unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_document_not_found() {
|
||||
let conn = setup_discussion_test_db();
|
||||
let result = extract_note_document(&conn, 999).unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_document_orphaned_discussion() {
|
||||
let conn = setup_discussion_test_db();
|
||||
insert_discussion(&conn, 1, "Issue", None, None);
|
||||
insert_note(
|
||||
&conn,
|
||||
1,
|
||||
100,
|
||||
1,
|
||||
Some("alice"),
|
||||
Some("Comment"),
|
||||
1000,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let result = extract_note_document(&conn, 1).unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_document_hash_deterministic() {
|
||||
let conn = setup_discussion_test_db();
|
||||
insert_issue(
|
||||
&conn,
|
||||
1,
|
||||
10,
|
||||
Some("Test"),
|
||||
Some("desc"),
|
||||
"opened",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
||||
insert_note(
|
||||
&conn,
|
||||
1,
|
||||
100,
|
||||
1,
|
||||
Some("alice"),
|
||||
Some("Comment"),
|
||||
1000,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let doc1 = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||
let doc2 = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||
assert_eq!(doc1.content_hash, doc2.content_hash);
|
||||
assert_eq!(doc1.labels_hash, doc2.labels_hash);
|
||||
assert_eq!(doc1.paths_hash, doc2.paths_hash);
|
||||
assert_eq!(doc1.content_hash.len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_document_empty_body() {
|
||||
let conn = setup_discussion_test_db();
|
||||
insert_issue(
|
||||
&conn,
|
||||
1,
|
||||
10,
|
||||
Some("Test"),
|
||||
Some("desc"),
|
||||
"opened",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
||||
insert_note(
|
||||
&conn,
|
||||
1,
|
||||
100,
|
||||
1,
|
||||
Some("alice"),
|
||||
Some(""),
|
||||
1000,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||
assert!(doc.content_text.contains("--- Body ---\n\n"));
|
||||
assert!(!doc.is_truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_document_null_body() {
|
||||
let conn = setup_discussion_test_db();
|
||||
insert_issue(
|
||||
&conn,
|
||||
1,
|
||||
10,
|
||||
Some("Test"),
|
||||
Some("desc"),
|
||||
"opened",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
insert_discussion(&conn, 1, "Issue", Some(1), None);
|
||||
insert_note(
|
||||
&conn,
|
||||
1,
|
||||
100,
|
||||
1,
|
||||
Some("alice"),
|
||||
None,
|
||||
1000,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let doc = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||
assert!(doc.content_text.contains("--- Body ---\n\n"));
|
||||
assert!(doc.content_text.ends_with("--- Body ---\n\n"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ mod regenerator;
|
||||
mod truncation;
|
||||
|
||||
pub use extractor::{
|
||||
DocumentData, SourceType, compute_content_hash, compute_list_hash, extract_discussion_document,
|
||||
extract_issue_document, extract_mr_document,
|
||||
DocumentData, ParentMetadataCache, SourceType, compute_content_hash, compute_list_hash,
|
||||
extract_discussion_document, extract_issue_document, extract_mr_document,
|
||||
extract_note_document, extract_note_document_cached,
|
||||
};
|
||||
pub use regenerator::{RegenerateResult, regenerate_dirty_documents};
|
||||
pub use truncation::{
|
||||
|
||||
@@ -4,8 +4,8 @@ use tracing::{debug, instrument, warn};
|
||||
|
||||
use crate::core::error::Result;
|
||||
use crate::documents::{
|
||||
DocumentData, SourceType, extract_discussion_document, extract_issue_document,
|
||||
extract_mr_document,
|
||||
DocumentData, ParentMetadataCache, SourceType, extract_discussion_document,
|
||||
extract_issue_document, extract_mr_document, extract_note_document_cached,
|
||||
};
|
||||
use crate::ingestion::dirty_tracker::{clear_dirty, get_dirty_sources, record_dirty_error};
|
||||
|
||||
@@ -27,6 +27,7 @@ pub fn regenerate_dirty_documents(
|
||||
let mut result = RegenerateResult::default();
|
||||
|
||||
let mut estimated_total: usize = 0;
|
||||
let mut cache = ParentMetadataCache::new();
|
||||
|
||||
loop {
|
||||
let dirty = get_dirty_sources(conn)?;
|
||||
@@ -41,7 +42,7 @@ pub fn regenerate_dirty_documents(
|
||||
estimated_total = estimated_total.max(processed_so_far + remaining);
|
||||
|
||||
for (source_type, source_id) in &dirty {
|
||||
match regenerate_one(conn, *source_type, *source_id) {
|
||||
match regenerate_one(conn, *source_type, *source_id, &mut cache) {
|
||||
Ok(changed) => {
|
||||
if changed {
|
||||
result.regenerated += 1;
|
||||
@@ -83,11 +84,17 @@ pub fn regenerate_dirty_documents(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn regenerate_one(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<bool> {
|
||||
fn regenerate_one(
|
||||
conn: &Connection,
|
||||
source_type: SourceType,
|
||||
source_id: i64,
|
||||
cache: &mut ParentMetadataCache,
|
||||
) -> Result<bool> {
|
||||
let doc = match source_type {
|
||||
SourceType::Issue => extract_issue_document(conn, source_id)?,
|
||||
SourceType::MergeRequest => extract_mr_document(conn, source_id)?,
|
||||
SourceType::Discussion => extract_discussion_document(conn, source_id)?,
|
||||
SourceType::Note => extract_note_document_cached(conn, source_id, cache)?,
|
||||
};
|
||||
|
||||
let Some(doc) = doc else {
|
||||
@@ -122,11 +129,7 @@ fn upsert_document_inner(conn: &Connection, doc: &DocumentData) -> Result<bool>
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
let content_changed = match &existing {
|
||||
Some((_, old_content_hash, _, _)) => old_content_hash != &doc.content_hash,
|
||||
None => true,
|
||||
};
|
||||
|
||||
// Fast path: if all three hashes match, nothing changed at all.
|
||||
if let Some((_, ref old_content_hash, ref old_labels_hash, ref old_paths_hash)) = existing
|
||||
&& old_content_hash == &doc.content_hash
|
||||
&& old_labels_hash == &doc.labels_hash
|
||||
@@ -134,6 +137,7 @@ fn upsert_document_inner(conn: &Connection, doc: &DocumentData) -> Result<bool>
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
// Past this point at least one hash differs, so the document will be updated.
|
||||
|
||||
let labels_json = serde_json::to_string(&doc.labels).unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
@@ -243,7 +247,8 @@ fn upsert_document_inner(conn: &Connection, doc: &DocumentData) -> Result<bool>
|
||||
}
|
||||
}
|
||||
|
||||
Ok(content_changed)
|
||||
// We passed the triple-hash fast path, so at least one hash differs.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn delete_document(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<()> {
|
||||
@@ -473,4 +478,316 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(label_count, 1);
|
||||
}
|
||||
|
||||
fn setup_note_db() -> Connection {
|
||||
let conn = setup_db();
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE merge_requests (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_id INTEGER UNIQUE NOT NULL,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
iid INTEGER NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
state TEXT,
|
||||
draft INTEGER NOT NULL DEFAULT 0,
|
||||
author_username TEXT,
|
||||
source_branch TEXT,
|
||||
target_branch TEXT,
|
||||
head_sha TEXT,
|
||||
references_short TEXT,
|
||||
references_full TEXT,
|
||||
detailed_merge_status TEXT,
|
||||
merge_user_username TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER,
|
||||
merged_at INTEGER,
|
||||
closed_at INTEGER,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
discussions_synced_for_updated_at INTEGER,
|
||||
discussions_sync_last_attempt_at INTEGER,
|
||||
discussions_sync_attempts INTEGER DEFAULT 0,
|
||||
discussions_sync_last_error TEXT,
|
||||
resource_events_synced_for_updated_at INTEGER,
|
||||
web_url TEXT,
|
||||
raw_payload_id INTEGER
|
||||
);
|
||||
CREATE TABLE mr_labels (
|
||||
merge_request_id INTEGER REFERENCES merge_requests(id),
|
||||
label_id INTEGER REFERENCES labels(id),
|
||||
PRIMARY KEY(merge_request_id, label_id)
|
||||
);
|
||||
CREATE TABLE discussions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_discussion_id TEXT NOT NULL,
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
issue_id INTEGER REFERENCES issues(id),
|
||||
merge_request_id INTEGER,
|
||||
noteable_type TEXT NOT NULL,
|
||||
individual_note INTEGER NOT NULL DEFAULT 0,
|
||||
first_note_at INTEGER,
|
||||
last_note_at INTEGER,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
resolvable INTEGER NOT NULL DEFAULT 0,
|
||||
resolved INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE notes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
gitlab_id INTEGER UNIQUE NOT NULL,
|
||||
discussion_id INTEGER NOT NULL REFERENCES discussions(id),
|
||||
project_id INTEGER NOT NULL REFERENCES projects(id),
|
||||
note_type TEXT,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
author_username TEXT,
|
||||
body TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL,
|
||||
position INTEGER,
|
||||
resolvable INTEGER NOT NULL DEFAULT 0,
|
||||
resolved INTEGER NOT NULL DEFAULT 0,
|
||||
resolved_by TEXT,
|
||||
resolved_at INTEGER,
|
||||
position_old_path TEXT,
|
||||
position_new_path TEXT,
|
||||
position_old_line INTEGER,
|
||||
position_new_line INTEGER,
|
||||
raw_payload_id INTEGER
|
||||
);
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_note_document() {
|
||||
let conn = setup_note_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Test Issue', 'opened', 'alice', 1000, 2000, 3000, 'https://example.com/issues/42')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'This is a note', 1000, 2000, 3000, 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Note, 1).unwrap();
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 1);
|
||||
assert_eq!(result.unchanged, 0);
|
||||
assert_eq!(result.errored, 0);
|
||||
|
||||
let (source_type, content): (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT source_type, content_text FROM documents WHERE source_id = 1",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(source_type, "note");
|
||||
assert!(content.contains("[[Note]]"));
|
||||
assert!(content.contains("author: @bob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_note_system_note_deletes() {
|
||||
let conn = setup_note_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bot', 'assigned to @alice', 1000, 2000, 3000, 1)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
// Pre-insert a document for this note (simulating a previously-generated doc)
|
||||
conn.execute(
|
||||
"INSERT INTO documents (source_type, source_id, project_id, content_text, content_hash) VALUES ('note', 1, 1, 'old content', 'oldhash')",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Note, 1).unwrap();
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 1);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regenerate_note_unchanged() {
|
||||
let conn = setup_note_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Test', 'opened', 1000, 2000, 3000, 'https://example.com/issues/42')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'Some note', 1000, 2000, 3000, 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Note, 1).unwrap();
|
||||
let r1 = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(r1.regenerated, 1);
|
||||
|
||||
mark_dirty(&conn, SourceType::Note, 1).unwrap();
|
||||
let r2 = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(r2.unchanged, 1);
|
||||
assert_eq!(r2.regenerated, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_regeneration_batch_uses_cache() {
|
||||
let conn = setup_note_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Shared Issue', 'opened', 'alice', 1000, 2000, 3000, 'https://example.com/issues/42')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
for i in 1..=10 {
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (?1, ?2, 1, 1, 'bob', ?3, 1000, 2000, 3000, 0)",
|
||||
rusqlite::params![i, i * 100, format!("Note body {}", i)],
|
||||
).unwrap();
|
||||
mark_dirty(&conn, SourceType::Note, i).unwrap();
|
||||
}
|
||||
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 10);
|
||||
assert_eq!(result.errored, 0);
|
||||
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM documents WHERE source_type = 'note'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(count, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_regeneration_cache_consistent_with_direct_extraction() {
|
||||
let conn = setup_note_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, author_username, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Consistency Check', 'opened', 'alice', 1000, 2000, 3000, 'https://example.com/issues/42')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO labels (id, project_id, name) VALUES (1, 1, 'backend')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO issue_labels (issue_id, label_id) VALUES (1, 1)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'Some content', 1000, 2000, 3000, 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
use crate::documents::extract_note_document;
|
||||
let direct = extract_note_document(&conn, 1).unwrap().unwrap();
|
||||
|
||||
let mut cache = ParentMetadataCache::new();
|
||||
let cached = extract_note_document_cached(&conn, 1, &mut cache)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(direct.content_text, cached.content_text);
|
||||
assert_eq!(direct.content_hash, cached.content_hash);
|
||||
assert_eq!(direct.labels, cached.labels);
|
||||
assert_eq!(direct.labels_hash, cached.labels_hash);
|
||||
assert_eq!(direct.paths_hash, cached.paths_hash);
|
||||
assert_eq!(direct.title, cached.title);
|
||||
assert_eq!(direct.url, cached.url);
|
||||
assert_eq!(direct.author_username, cached.author_username);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_note_regeneration_cache_invalidates_across_parents() {
|
||||
let conn = setup_note_db();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at, web_url) VALUES (1, 10, 1, 42, 'Issue Alpha', 'opened', 1000, 2000, 3000, 'https://example.com/issues/42')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO issues (id, gitlab_id, project_id, iid, title, state, created_at, updated_at, last_seen_at, web_url) VALUES (2, 20, 1, 99, 'Issue Beta', 'opened', 1000, 2000, 3000, 'https://example.com/issues/99')",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (1, 'disc_1', 1, 1, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, last_seen_at) VALUES (2, 'disc_2', 1, 2, 'Issue', 3000)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (1, 100, 1, 1, 'bob', 'Alpha note', 1000, 2000, 3000, 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO notes (id, gitlab_id, discussion_id, project_id, author_username, body, created_at, updated_at, last_seen_at, is_system) VALUES (2, 200, 2, 1, 'alice', 'Beta note', 1000, 2000, 3000, 0)",
|
||||
[],
|
||||
).unwrap();
|
||||
|
||||
mark_dirty(&conn, SourceType::Note, 1).unwrap();
|
||||
mark_dirty(&conn, SourceType::Note, 2).unwrap();
|
||||
|
||||
let result = regenerate_dirty_documents(&conn, None).unwrap();
|
||||
assert_eq!(result.regenerated, 2);
|
||||
assert_eq!(result.errored, 0);
|
||||
|
||||
let alpha_content: String = conn
|
||||
.query_row(
|
||||
"SELECT content_text FROM documents WHERE source_type = 'note' AND source_id = 1",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
let beta_content: String = conn
|
||||
.query_row(
|
||||
"SELECT content_text FROM documents WHERE source_type = 'note' AND source_id = 2",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(alpha_content.contains("parent_iid: 42"));
|
||||
assert!(alpha_content.contains("parent_title: Issue Alpha"));
|
||||
assert!(beta_content.contains("parent_iid: 99"));
|
||||
assert!(beta_content.contains("parent_title: Issue Beta"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user