feat: persist diff file review checkmarks
This commit is contained in:
+134
-16
@@ -86,6 +86,15 @@ pub struct Comment {
|
||||
pub resolved: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReviewedFile {
|
||||
pub revisions: [String; 2],
|
||||
pub path: PathBuf,
|
||||
pub old_path: Option<PathBuf>,
|
||||
pub after: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Review {
|
||||
@@ -94,6 +103,7 @@ pub struct Review {
|
||||
pub summary: String,
|
||||
pub verdict: Option<Verdict>,
|
||||
pub comments: Vec<Comment>,
|
||||
pub reviewed_files: Vec<ReviewedFile>,
|
||||
#[serde(skip)]
|
||||
pub storage_id: Option<i64>,
|
||||
#[serde(skip)]
|
||||
@@ -126,11 +136,12 @@ impl Review {
|
||||
fs::canonicalize(&root).unwrap_or(root)
|
||||
};
|
||||
Self {
|
||||
version: 3,
|
||||
version: 4,
|
||||
root,
|
||||
summary: String::new(),
|
||||
verdict: None,
|
||||
comments: Vec::new(),
|
||||
reviewed_files: Vec::new(),
|
||||
storage_id: None,
|
||||
storage_revision: 0,
|
||||
saved: RefCell::new(None),
|
||||
@@ -138,7 +149,7 @@ impl Review {
|
||||
}
|
||||
|
||||
fn validate(&self) -> Result<(), String> {
|
||||
if self.version != 3 {
|
||||
if self.version != 4 {
|
||||
return Err(format!("Unsupported review version {}", self.version));
|
||||
}
|
||||
if !self.root.is_absolute() || !self.root.is_dir() {
|
||||
@@ -150,10 +161,7 @@ impl Review {
|
||||
return Err("Review comment IDs must be unique and nonzero".into());
|
||||
}
|
||||
let anchor = &comment.anchor;
|
||||
crate::repository::safe_path(&anchor.path)?;
|
||||
if anchor.path.to_str().is_none_or(|path| path.contains('\0')) {
|
||||
return Err("Review paths must be UTF-8 without NUL bytes".into());
|
||||
}
|
||||
validate_path(&anchor.path)?;
|
||||
if anchor.revision.is_empty() || anchor.revision.chars().any(char::is_control) {
|
||||
return Err("Review anchors require an immutable revision identity".into());
|
||||
}
|
||||
@@ -181,6 +189,23 @@ impl Review {
|
||||
return Err("Review rows must be within the anchor text".into());
|
||||
}
|
||||
}
|
||||
let mut files = BTreeSet::new();
|
||||
for file in &self.reviewed_files {
|
||||
validate_path(&file.path)?;
|
||||
if let Some(path) = &file.old_path {
|
||||
validate_path(path)?;
|
||||
}
|
||||
if file
|
||||
.revisions
|
||||
.iter()
|
||||
.any(|revision| revision.is_empty() || revision.chars().any(char::is_control))
|
||||
{
|
||||
return Err("Reviewed files require immutable revision identities".into());
|
||||
}
|
||||
if !files.insert((&file.revisions, &file.path)) {
|
||||
return Err("Reviewed files must be unique by revision pair and path".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -191,7 +216,7 @@ impl Review {
|
||||
}
|
||||
let header: Header = serde_json::from_slice(bytes)
|
||||
.map_err(|error| format!("Invalid review JSON: {error}"))?;
|
||||
if header.version != 3 {
|
||||
if header.version != 4 {
|
||||
return Err(format!("Unsupported review version {}", header.version));
|
||||
}
|
||||
serde_json::from_slice(bytes).map_err(|error| format!("Invalid review JSON: {error}"))
|
||||
@@ -264,6 +289,14 @@ impl Review {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_path(path: &Path) -> Result<(), String> {
|
||||
crate::repository::safe_path(path)?;
|
||||
if path.to_str().is_none_or(|path| path.contains('\0')) {
|
||||
return Err("Review paths must be UTF-8 without NUL bytes".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn default_directory() -> Result<PathBuf, String> {
|
||||
directory_from(
|
||||
std::env::var_os("XDG_DATA_HOME").as_deref(),
|
||||
@@ -313,6 +346,18 @@ const COMMENTS_SCHEMA: &str = "CREATE TABLE comments (
|
||||
CHECK (end_row > start_row OR (end_row = start_row AND end_column >= start_column))
|
||||
) STRICT";
|
||||
|
||||
const REVIEWED_FILES_SCHEMA: &str = "CREATE TABLE reviewed_files (
|
||||
review_id INTEGER NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL CHECK (position >= 0),
|
||||
before_revision TEXT NOT NULL CHECK (length(before_revision) > 0),
|
||||
after_revision TEXT NOT NULL CHECK (length(after_revision) > 0),
|
||||
path TEXT NOT NULL,
|
||||
old_path TEXT,
|
||||
after_content TEXT,
|
||||
PRIMARY KEY (review_id, before_revision, after_revision, path),
|
||||
UNIQUE (review_id, position)
|
||||
) STRICT";
|
||||
|
||||
pub struct Store {
|
||||
connection: Connection,
|
||||
}
|
||||
@@ -357,7 +402,7 @@ impl Store {
|
||||
.map_err(database_error)?
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(database_error)?;
|
||||
if version != 2 || schema != [COMMENTS_SCHEMA, REVIEWS_SCHEMA] {
|
||||
if version != 3 || schema != [COMMENTS_SCHEMA, REVIEWED_FILES_SCHEMA, REVIEWS_SCHEMA] {
|
||||
return Err(format!(
|
||||
"Unsupported review database schema (version {version}); existing data was not changed"
|
||||
));
|
||||
@@ -422,7 +467,10 @@ impl Store {
|
||||
.execute_batch(COMMENTS_SCHEMA)
|
||||
.map_err(database_error)?;
|
||||
transaction
|
||||
.pragma_update(None, "user_version", 2)
|
||||
.execute_batch(REVIEWED_FILES_SCHEMA)
|
||||
.map_err(database_error)?;
|
||||
transaction
|
||||
.pragma_update(None, "user_version", 3)
|
||||
.map_err(database_error)?;
|
||||
transaction.commit().map_err(database_error)?;
|
||||
} else {
|
||||
@@ -518,6 +566,21 @@ impl Store {
|
||||
)
|
||||
.ok_or("Review exceeds the 64 MiB limit")?;
|
||||
}
|
||||
for file in &review.reviewed_files {
|
||||
size = size
|
||||
.checked_add(
|
||||
file.revisions[0].len()
|
||||
+ file.revisions[1].len()
|
||||
+ file.path.as_os_str().len()
|
||||
+ file
|
||||
.old_path
|
||||
.as_ref()
|
||||
.map_or(0, |path| path.as_os_str().len())
|
||||
+ file.after.as_ref().map_or(0, String::len)
|
||||
+ 64,
|
||||
)
|
||||
.ok_or("Review exceeds the 64 MiB limit")?;
|
||||
}
|
||||
if size > LIMIT {
|
||||
return Err("Review exceeds the 64 MiB limit".into());
|
||||
}
|
||||
@@ -550,7 +613,7 @@ impl Store {
|
||||
.map_err(database_error)?;
|
||||
transaction.last_insert_rowid()
|
||||
};
|
||||
// ponytail: replace comment rows per save; diff rows if snapshot writes become costly.
|
||||
// ponytail: replace child rows per save; diff rows if snapshot writes become costly.
|
||||
transaction
|
||||
.execute("DELETE FROM comments WHERE review_id = ?1", [id])
|
||||
.map_err(database_error)?;
|
||||
@@ -584,6 +647,31 @@ impl Store {
|
||||
.map_err(database_error)?;
|
||||
}
|
||||
}
|
||||
transaction
|
||||
.execute("DELETE FROM reviewed_files WHERE review_id = ?1", [id])
|
||||
.map_err(database_error)?;
|
||||
{
|
||||
let mut insert = transaction
|
||||
.prepare(
|
||||
"INSERT INTO reviewed_files (review_id, position, before_revision,
|
||||
after_revision, path, old_path, after_content)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
)
|
||||
.map_err(database_error)?;
|
||||
for (position, file) in review.reviewed_files.iter().enumerate() {
|
||||
insert
|
||||
.execute(params![
|
||||
id,
|
||||
sql_index(position)?,
|
||||
file.revisions[0],
|
||||
file.revisions[1],
|
||||
file.path.to_str().unwrap(),
|
||||
file.old_path.as_ref().map(|path| path.to_str().unwrap()),
|
||||
file.after,
|
||||
])
|
||||
.map_err(database_error)?;
|
||||
}
|
||||
}
|
||||
transaction.commit().map_err(database_error)?;
|
||||
review.storage_id = Some(id);
|
||||
review.storage_revision = revision;
|
||||
@@ -624,17 +712,23 @@ fn stored_review(
|
||||
.map_err(database_error)?
|
||||
.as_str()
|
||||
.map_err(|error| error.to_string())?;
|
||||
let comment_bytes: i64 = connection
|
||||
let stored_bytes: i64 = connection
|
||||
.query_row(
|
||||
"SELECT COALESCE(SUM(length(CAST(body AS BLOB)) + length(CAST(anchor_revision AS BLOB))
|
||||
+ length(CAST(path AS BLOB)) + length(CAST(content AS BLOB)) + 64), 0)
|
||||
FROM comments WHERE review_id = ?1",
|
||||
"SELECT COALESCE(SUM(size), 0) FROM (
|
||||
SELECT length(CAST(body AS BLOB)) + length(CAST(anchor_revision AS BLOB))
|
||||
+ length(CAST(path AS BLOB)) + length(CAST(content AS BLOB)) + 64 AS size
|
||||
FROM comments WHERE review_id = ?1
|
||||
UNION ALL
|
||||
SELECT length(CAST(before_revision AS BLOB)) + length(CAST(after_revision AS BLOB))
|
||||
+ length(CAST(path AS BLOB)) + COALESCE(length(CAST(old_path AS BLOB)), 0)
|
||||
+ COALESCE(length(CAST(after_content AS BLOB)), 0) + 64
|
||||
FROM reviewed_files WHERE review_id = ?1)",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(database_error)?;
|
||||
if comment_bytes < 0
|
||||
|| comment_bytes as u64 + stored_root.len() as u64 + summary.len() as u64 > LIMIT as u64
|
||||
if stored_bytes < 0
|
||||
|| stored_bytes as u64 + stored_root.len() as u64 + summary.len() as u64 > LIMIT as u64
|
||||
{
|
||||
return Err("Review exceeds the 64 MiB limit".into());
|
||||
}
|
||||
@@ -699,6 +793,30 @@ fn stored_review(
|
||||
},
|
||||
});
|
||||
}
|
||||
let mut statement = connection
|
||||
.prepare(
|
||||
"SELECT position, before_revision, after_revision, path, old_path, after_content
|
||||
FROM reviewed_files WHERE review_id = ?1 ORDER BY position",
|
||||
)
|
||||
.map_err(database_error)?;
|
||||
let mut rows = statement.query([id]).map_err(database_error)?;
|
||||
while let Some(row) = rows.next().map_err(database_error)? {
|
||||
if stored_index(row, 0)? != review.reviewed_files.len() {
|
||||
return Err("Invalid stored reviewed-file order".into());
|
||||
}
|
||||
review.reviewed_files.push(ReviewedFile {
|
||||
revisions: [
|
||||
row.get(1).map_err(database_error)?,
|
||||
row.get(2).map_err(database_error)?,
|
||||
],
|
||||
path: PathBuf::from(row.get::<_, String>(3).map_err(database_error)?),
|
||||
old_path: row
|
||||
.get::<_, Option<String>>(4)
|
||||
.map_err(database_error)?
|
||||
.map(PathBuf::from),
|
||||
after: row.get(5).map_err(database_error)?,
|
||||
});
|
||||
}
|
||||
review.validate()?;
|
||||
review.storage_id = Some(id);
|
||||
review.storage_revision = revision;
|
||||
|
||||
Reference in New Issue
Block a user