| 1 | //! Comments on commits (migrations/0043_commit_comments.sql): on the whole |
| 2 | //! commit, or on one line of a file it changed, as the commit left it. |
| 3 | //! |
| 4 | //! Anyone who can read the repository reads them, and comments unless the |
| 5 | //! repository turned commit comments off (`allow_commit_comments`); those |
| 6 | //! already written stay readable either way. Only its author edits one; |
| 7 | //! its author or a maintainer (`ManageSettings`, as for issue comments) |
| 8 | //! deletes it. Each change is published as `commit_comment.created`, |
| 9 | //! `.edited` or `.deleted` with the comment, and the inbox tells the |
| 10 | //! commit's author (when their address is a g1t account's) and whoever it |
| 11 | //! mentions (events/src/inbox.rs). |
| 12 | |
| 13 | use std::collections::HashMap; |
| 14 | |
| 15 | use g1t_contracts::access::{self, Capability}; |
| 16 | use g1t_contracts::accounts::{EmailOwner, EmailOwnersArgs}; |
| 17 | use g1t_contracts::events::{ChangedFrom, CommentChanges, CommitCommentEvent}; |
| 18 | use g1t_contracts::repos::{ChangedFilesArgs, Commit, FileList, LogArgs, MAX_LISTED_FILES, Repo, RepoPath}; |
| 19 | use g1t_contracts::time::rfc3339; |
| 20 | use g1t_contracts::work::{AddCommitCommentArgs, CommitComment, CommitCommentArgs, CommitCommentsArgs}; |
| 21 | use g1t_contracts::{FailureCode, Outcome, User, new_id}; |
| 22 | use g1t_kit::now_ms; |
| 23 | use serde::Deserialize; |
| 24 | use worker::wasm_bindgen::JsValue; |
| 25 | use worker::Result; |
| 26 | |
| 27 | use crate::Work; |
| 28 | use crate::inbox::{excerpt, people_mentioned, teams_mentioned}; |
| 29 | use crate::retired::writable; |
| 30 | |
| 31 | /// The longest a comment may be, in characters. |
| 32 | pub(crate) const MAX_BODY_CHARS: usize = 65_536; |
| 33 | /// A repository's comments are listed this many at a time. |
| 34 | const PAGE: u32 = 30; |
| 35 | /// A commit's comments: at most this many are listed. |
| 36 | const MAX_ON_COMMIT: u32 = 1_000; |
| 37 | const MAX_PATH_CHARS: usize = 4_096; |
| 38 | |
| 39 | /// `repo.purged`: a repository's commit comments go with it (retired.rs). |
| 40 | pub(crate) const PURGED: &str = "DELETE FROM commit_comments WHERE repo_id = ?1"; |
| 41 | |
| 42 | /// Unwraps an `Outcome`, returning its failure from the enclosing method. |
| 43 | macro_rules! check { |
| 44 | ($outcome:expr) => { |
| 45 | match $outcome { |
| 46 | Outcome::Ok(value) => value, |
| 47 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 48 | } |
| 49 | }; |
| 50 | } |
| 51 | |
| 52 | #[derive(Deserialize)] |
| 53 | struct Row { |
| 54 | id: String, |
| 55 | repo_id: String, |
| 56 | commit_hash: String, |
| 57 | path: Option<String>, |
| 58 | line: Option<u32>, |
| 59 | author_id: String, |
| 60 | author_name: String, |
| 61 | body: String, |
| 62 | created_at: String, |
| 63 | updated_at: Option<String>, |
| 64 | } |
| 65 | |
| 66 | impl From<Row> for CommitComment { |
| 67 | fn from(row: Row) -> Self { |
| 68 | CommitComment { |
| 69 | id: row.id, |
| 70 | repo_id: row.repo_id, |
| 71 | commit: row.commit_hash, |
| 72 | path: row.path, |
| 73 | line: row.line, |
| 74 | author: row.author_name, |
| 75 | body: row.body, |
| 76 | created_at: row.created_at, |
| 77 | updated_at: row.updated_at, |
| 78 | } |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | /// A trimmed body, or why it cannot be used. |
| 83 | pub(crate) fn valid_body(body: &str) -> std::result::Result<&str, &'static str> { |
| 84 | let body = body.trim(); |
| 85 | if body.is_empty() { |
| 86 | Err("A comment cannot be empty.") |
| 87 | } else if body.chars().count() > MAX_BODY_CHARS { |
| 88 | Err("That comment is too long.") |
| 89 | } else { |
| 90 | Ok(body) |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | /// A commit named by its hash, whole or its start (at least 4 hex digits), |
| 95 | /// lowercased; never a branch or tag name. |
| 96 | pub(crate) fn valid_commit(commit: &str) -> Option<String> { |
| 97 | let commit = commit.trim().to_ascii_lowercase(); |
| 98 | ((4..=64).contains(&commit.len()) && commit.bytes().all(|byte| byte.is_ascii_hexdigit())).then_some(commit) |
| 99 | } |
| 100 | |
| 101 | /// Where on the commit a comment is: nowhere in particular, or one line |
| 102 | /// (from 1) of one file. A path and a line come together. |
| 103 | pub(crate) fn valid_place(path: Option<&str>, line: Option<u32>) -> std::result::Result<Option<(String, u32)>, &'static str> { |
| 104 | let path = path.map(str::trim).filter(|path| !path.is_empty()); |
| 105 | match (path, line) { |
| 106 | (None, None) => Ok(None), |
| 107 | (None, Some(_)) => Err("Say which file the line is in."), |
| 108 | (Some(_), None) => Err("Say which line of the file the comment is on."), |
| 109 | (Some(_), Some(0)) => Err("Lines are numbered from 1."), |
| 110 | (Some(path), Some(line)) => { |
| 111 | let path = path.trim_start_matches('/'); |
| 112 | if path.is_empty() |
| 113 | || path.chars().count() > MAX_PATH_CHARS |
| 114 | || path.contains('\0') |
| 115 | || path.split('/').any(|part| part.is_empty() || part == "." || part == "..") |
| 116 | { |
| 117 | return Err("That is not a file's path."); |
| 118 | } |
| 119 | Ok(Some((path.to_owned(), line))) |
| 120 | } |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | /// Whether `actor` may change a comment written by `author_id`: edit it |
| 125 | /// only as its author; delete it as its author or a maintainer. |
| 126 | pub(crate) fn may_change(author_id: &str, actor_id: &str, maintains: bool, deleting: bool) -> std::result::Result<(), &'static str> { |
| 127 | if author_id == actor_id { |
| 128 | Ok(()) |
| 129 | } else if deleting && maintains { |
| 130 | Ok(()) |
| 131 | } else if deleting { |
| 132 | Err("Only its author or a maintainer can delete this comment.") |
| 133 | } else { |
| 134 | Err("Only its author can edit this comment.") |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | /// Whether a commit's changed files (`list`) include `path` as the commit |
| 139 | /// left it. A list cut short cannot say a file is missing. |
| 140 | pub(crate) fn changes_file(list: &FileList, path: &str) -> bool { |
| 141 | list.truncated || list.files.iter().any(|file| file.path == path && file.hash.is_some()) |
| 142 | } |
| 143 | |
| 144 | /// Where a commit is on g1t.sh. |
| 145 | fn link(repo: &Repo, commit: &str) -> String { |
| 146 | format!("/{}/{}/commit/{commit}", repo.namespace, repo.name) |
| 147 | } |
| 148 | |
| 149 | fn event(repo: &Repo, comment: &CommitComment) -> CommitCommentEvent { |
| 150 | CommitCommentEvent { |
| 151 | comment_id: comment.id.clone(), |
| 152 | repo_id: repo.id.clone(), |
| 153 | commit: comment.commit.clone(), |
| 154 | comment: comment.clone(), |
| 155 | changes: None, |
| 156 | link: link(repo, &comment.commit), |
| 157 | excerpt: None, |
| 158 | mentions: Vec::new(), |
| 159 | team_mentions: Vec::new(), |
| 160 | commit_author: None, |
| 161 | } |
| 162 | } |
| 163 | |
| 164 | impl Work { |
| 165 | /// `commit_comments`: a commit's comments, oldest first; with no |
| 166 | /// commit, the repository's, newest first, a page at a time. |
| 167 | pub(crate) async fn commit_comments(&self, a: CommitCommentsArgs) -> Result<Outcome<Vec<CommitComment>>> { |
| 168 | let commit = match a.commit.as_deref().map(str::trim).filter(|commit| !commit.is_empty()) { |
| 169 | Some(commit) => match valid_commit(commit) { |
| 170 | Some(commit) => Some(commit), |
| 171 | None => return Ok(Outcome::fail(FailureCode::Invalid, "That is not a commit's hash.")), |
| 172 | }, |
| 173 | None => None, |
| 174 | }; |
| 175 | let repo = check!(self.repo(&a.repo, &a.viewer).await?); |
| 176 | let query = match &commit { |
| 177 | // A whole hash is matched as it is; the start of one, as a prefix. |
| 178 | Some(commit) if commit.len() == 40 || commit.len() == 64 => self |
| 179 | .db |
| 180 | .prepare(format!( |
| 181 | "SELECT * FROM commit_comments WHERE repo_id = ? AND commit_hash = ? |
| 182 | ORDER BY created_at, id LIMIT {MAX_ON_COMMIT}" |
| 183 | )) |
| 184 | .bind(&[repo.id.as_str().into(), commit.as_str().into()])?, |
| 185 | Some(commit) => self |
| 186 | .db |
| 187 | .prepare(format!( |
| 188 | "SELECT * FROM commit_comments WHERE repo_id = ? AND commit_hash LIKE ? || '%' |
| 189 | ORDER BY created_at, id LIMIT {MAX_ON_COMMIT}" |
| 190 | )) |
| 191 | .bind(&[repo.id.as_str().into(), commit.as_str().into()])?, |
| 192 | None => { |
| 193 | let offset = a.page.unwrap_or(1).max(1).saturating_sub(1).saturating_mul(PAGE); |
| 194 | self.db |
| 195 | .prepare( |
| 196 | "SELECT * FROM commit_comments WHERE repo_id = ? |
| 197 | ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?", |
| 198 | ) |
| 199 | .bind(&[repo.id.as_str().into(), PAGE.into(), offset.into()])? |
| 200 | } |
| 201 | }; |
| 202 | let rows = query.all().await?.results::<Row>()?; |
| 203 | Ok(Outcome::Ok(rows.into_iter().map(CommitComment::from).collect())) |
| 204 | } |
| 205 | |
| 206 | /// `add_commit_comment`: comments on a commit of the repository, or on |
| 207 | /// one line of a file it changed. |
| 208 | pub(crate) async fn add_commit_comment(&self, a: AddCommitCommentArgs) -> Result<Outcome<CommitComment>> { |
| 209 | if !a.actor.verified { |
| 210 | return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED)); |
| 211 | } |
| 212 | let body = match valid_body(&a.body) { |
| 213 | Ok(body) => body.to_owned(), |
| 214 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), |
| 215 | }; |
| 216 | let Some(asked) = valid_commit(&a.commit) else { |
| 217 | return Ok(Outcome::fail(FailureCode::Invalid, "That is not a commit's hash.")); |
| 218 | }; |
| 219 | let place = match valid_place(a.path.as_deref(), a.line) { |
| 220 | Ok(place) => place, |
| 221 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), |
| 222 | }; |
| 223 | let viewer = Some(a.actor.clone()); |
| 224 | let repo = check!(self.repo(&a.repo, &viewer).await?); |
| 225 | check!(writable(&repo)); |
| 226 | if !self.settings(&repo.id).await?.allow_commit_comments { |
| 227 | return Ok(Outcome::fail(FailureCode::Forbidden, "Commit comments are turned off for this repository.")); |
| 228 | } |
| 229 | let Some(commit) = self.commented_commit(&a.repo, &viewer, &asked).await? else { |
| 230 | return Ok(Outcome::fail(FailureCode::NotFound, "Commit not found.")); |
| 231 | }; |
| 232 | if let Some((path, _)) = &place { |
| 233 | let changed: FileList = g1t_kit::call( |
| 234 | &self.repos, |
| 235 | "changed_files", |
| 236 | &ChangedFilesArgs { |
| 237 | repo_id: repo.id.clone(), |
| 238 | base: commit.parents.first().cloned(), |
| 239 | head: commit.hash.clone(), |
| 240 | skip_dirs: Vec::new(), |
| 241 | limit: MAX_LISTED_FILES, |
| 242 | }, |
| 243 | ) |
| 244 | .await?; |
| 245 | if !changes_file(&changed, path) { |
| 246 | return Ok(Outcome::fail(FailureCode::Invalid, "That file is not one this commit changed.")); |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | let now = now_ms(); |
| 251 | let comment = CommitComment { |
| 252 | id: new_id("ccm", now), |
| 253 | repo_id: repo.id.clone(), |
| 254 | commit: commit.hash.clone(), |
| 255 | path: place.as_ref().map(|(path, _)| path.clone()), |
| 256 | line: place.as_ref().map(|(_, line)| *line), |
| 257 | author: a.actor.username.clone(), |
| 258 | body, |
| 259 | created_at: rfc3339(now), |
| 260 | updated_at: None, |
| 261 | }; |
| 262 | self.db |
| 263 | .prepare( |
| 264 | "INSERT INTO commit_comments |
| 265 | (id, repo_id, commit_hash, path, line, author_id, author_name, body, created_at) |
| 266 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", |
| 267 | ) |
| 268 | .bind(&[ |
| 269 | comment.id.as_str().into(), |
| 270 | repo.id.as_str().into(), |
| 271 | comment.commit.as_str().into(), |
| 272 | comment.path.as_deref().map_or(JsValue::NULL, JsValue::from), |
| 273 | comment.line.map_or(JsValue::NULL, JsValue::from), |
| 274 | a.actor.id.as_str().into(), |
| 275 | a.actor.username.as_str().into(), |
| 276 | comment.body.as_str().into(), |
| 277 | comment.created_at.as_str().into(), |
| 278 | ])? |
| 279 | .run() |
| 280 | .await?; |
| 281 | |
| 282 | // Who hears of it: whoever it mentions, and the commit's author. |
| 283 | let mut data = event(&repo, &comment); |
| 284 | data.excerpt = Some(excerpt(&comment.body)); |
| 285 | data.mentions = people_mentioned(&comment.body); |
| 286 | let teams = teams_mentioned(&comment.body); |
| 287 | match self.team_mentions(&repo.id, &teams, &a.actor.id, &a.actor.username).await { |
| 288 | Ok(told) => data.team_mentions = told, |
| 289 | Err(error) => worker::console_error!("commit comment team mentions not resolved: {error}"), |
| 290 | } |
| 291 | data.commit_author = self.commit_author(&commit, &a.actor).await; |
| 292 | self.publish_quietly("commit_comment.created", &repo, &a.actor, data).await; |
| 293 | Ok(Outcome::Ok(comment)) |
| 294 | } |
| 295 | |
| 296 | /// `edit_commit_comment`: its author changes what it says. |
| 297 | pub(crate) async fn edit_commit_comment(&self, a: CommitCommentArgs) -> Result<Outcome<CommitComment>> { |
| 298 | let body = match valid_body(&a.body) { |
| 299 | Ok(body) => body.to_owned(), |
| 300 | Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)), |
| 301 | }; |
| 302 | let (repo, mut comment) = check!(self.changeable_commit_comment(&a, false).await?); |
| 303 | if comment.body == body { |
| 304 | return Ok(Outcome::Ok(comment)); |
| 305 | } |
| 306 | let now = rfc3339(now_ms()); |
| 307 | self.db |
| 308 | .prepare("UPDATE commit_comments SET body = ?, updated_at = ? WHERE id = ? AND repo_id = ?") |
| 309 | .bind(&[body.as_str().into(), now.as_str().into(), comment.id.as_str().into(), repo.id.as_str().into()])? |
| 310 | .run() |
| 311 | .await?; |
| 312 | let before = std::mem::replace(&mut comment.body, body); |
| 313 | comment.updated_at = Some(now); |
| 314 | let mut data = event(&repo, &comment); |
| 315 | data.changes = Some(CommentChanges { body: ChangedFrom { from: before } }); |
| 316 | self.publish_quietly("commit_comment.edited", &repo, &a.actor, data).await; |
| 317 | Ok(Outcome::Ok(comment)) |
| 318 | } |
| 319 | |
| 320 | /// `delete_commit_comment`: its author or a maintainer deletes it. |
| 321 | pub(crate) async fn delete_commit_comment(&self, a: CommitCommentArgs) -> Result<Outcome<bool>> { |
| 322 | let (repo, comment) = check!(self.changeable_commit_comment(&a, true).await?); |
| 323 | self.db |
| 324 | .prepare("DELETE FROM commit_comments WHERE id = ? AND repo_id = ?") |
| 325 | .bind(&[comment.id.as_str().into(), repo.id.as_str().into()])? |
| 326 | .run() |
| 327 | .await?; |
| 328 | self.publish_quietly("commit_comment.deleted", &repo, &a.actor, event(&repo, &comment)).await; |
| 329 | Ok(Outcome::Ok(true)) |
| 330 | } |
| 331 | |
| 332 | /// A comment in the repository, when `actor` may edit it or, with |
| 333 | /// `deleting`, delete it (`may_change`). Not found for someone who |
| 334 | /// cannot read the repository. |
| 335 | async fn changeable_commit_comment(&self, a: &CommitCommentArgs, deleting: bool) -> Result<Outcome<(Repo, CommitComment)>> { |
| 336 | let repo = check!(self.repo(&a.repo, &Some(a.actor.clone())).await?); |
| 337 | check!(writable(&repo)); |
| 338 | let Some(row) = self |
| 339 | .db |
| 340 | .prepare("SELECT * FROM commit_comments WHERE id = ? AND repo_id = ?") |
| 341 | .bind(&[a.id.trim().into(), repo.id.as_str().into()])? |
| 342 | .first::<Row>(None) |
| 343 | .await? |
| 344 | else { |
| 345 | return Ok(Outcome::fail(FailureCode::NotFound, "Comment not found.")); |
| 346 | }; |
| 347 | let maintains = access::can(Some(&a.actor), &repo, Capability::ManageSettings); |
| 348 | if let Err(refusal) = may_change(&row.author_id, &a.actor.id, maintains, deleting) { |
| 349 | return Ok(Outcome::fail(FailureCode::Forbidden, refusal)); |
| 350 | } |
| 351 | Ok(Outcome::Ok((repo, CommitComment::from(row)))) |
| 352 | } |
| 353 | |
| 354 | /// The commit `asked` names (its hash, or the start of it) in the |
| 355 | /// repository, as `viewer` may read it; none for a branch or tag that |
| 356 | /// happens to look like a hash. |
| 357 | async fn commented_commit(&self, repo: &RepoPath, viewer: &g1t_contracts::Viewer, asked: &str) -> Result<Option<Commit>> { |
| 358 | let found: Outcome<Vec<Commit>> = g1t_kit::call( |
| 359 | &self.repos, |
| 360 | "log", |
| 361 | &LogArgs { |
| 362 | path: repo.clone(), |
| 363 | viewer: viewer.clone(), |
| 364 | git_ref: Some(asked.to_owned()), |
| 365 | limit: 1, |
| 366 | }, |
| 367 | ) |
| 368 | .await?; |
| 369 | Ok(match found { |
| 370 | Outcome::Ok(commits) => commits.into_iter().next().filter(|commit| commit.hash.to_ascii_lowercase().starts_with(asked)), |
| 371 | Outcome::Fail(_) => None, |
| 372 | }) |
| 373 | } |
| 374 | |
| 375 | /// The commit's author's username, when their address is a g1t |
| 376 | /// account's and it is not `actor` commenting on their own commit. |
| 377 | /// Best effort: none when it cannot be told. |
| 378 | async fn commit_author(&self, commit: &Commit, actor: &User) -> Option<String> { |
| 379 | let email = commit.author.email.trim().to_lowercase(); |
| 380 | if email.is_empty() { |
| 381 | return None; |
| 382 | } |
| 383 | let owners: HashMap<String, EmailOwner> = |
| 384 | match g1t_kit::call(&self.identity, "email_owners", &EmailOwnersArgs { emails: vec![email.clone()] }).await { |
| 385 | Ok(owners) => owners, |
| 386 | Err(error) => { |
| 387 | worker::console_error!("commit author not looked up: {error}"); |
| 388 | return None; |
| 389 | } |
| 390 | }; |
| 391 | owners.get(&email).filter(|owner| owner.id != actor.id).map(|owner| owner.username.to_lowercase()) |
| 392 | } |
| 393 | |
| 394 | /// Publishes a change to a comment that is already made: a failure is |
| 395 | /// logged, never undoes it. |
| 396 | async fn publish_quietly(&self, kind: &'static str, repo: &Repo, actor: &User, data: CommitCommentEvent) { |
| 397 | if let Err(error) = self.publish(kind, &repo.id, actor, data).await { |
| 398 | worker::console_error!("{kind} not published: {error}"); |
| 399 | } |
| 400 | } |
| 401 | } |
| 402 | |
| 403 | #[cfg(test)] |
| 404 | mod tests { |
| 405 | use super::*; |
| 406 | use g1t_contracts::repos::FileEntry; |
| 407 | |
| 408 | #[test] |
| 409 | fn a_body_says_something_and_not_too_much() { |
| 410 | assert_eq!(valid_body(" looks good \n"), Ok("looks good")); |
| 411 | assert!(valid_body(" \n\t").is_err()); |
| 412 | assert!(valid_body(&"a".repeat(MAX_BODY_CHARS)).is_ok()); |
| 413 | assert_eq!(valid_body(&"a".repeat(MAX_BODY_CHARS + 1)), Err("That comment is too long.")); |
| 414 | // Counted in characters, not bytes. |
| 415 | assert!(valid_body(&"é".repeat(MAX_BODY_CHARS)).is_ok()); |
| 416 | } |
| 417 | |
| 418 | #[test] |
| 419 | fn a_commit_is_named_by_its_hash_never_a_branch() { |
| 420 | assert_eq!(valid_commit(" ABCDEF12 "), Some("abcdef12".into())); |
| 421 | assert_eq!(valid_commit(&"a".repeat(40)), Some("a".repeat(40))); |
| 422 | assert_eq!(valid_commit(&"b".repeat(64)), Some("b".repeat(64))); |
| 423 | assert_eq!(valid_commit("abc"), None); |
| 424 | assert_eq!(valid_commit(&"a".repeat(65)), None); |
| 425 | assert_eq!(valid_commit("main"), None); |
| 426 | assert_eq!(valid_commit("HEAD~1"), None); |
| 427 | } |
| 428 | |
| 429 | #[test] |
| 430 | fn a_line_comment_names_a_file_and_a_line_from_one() { |
| 431 | assert_eq!(valid_place(None, None), Ok(None)); |
| 432 | assert_eq!(valid_place(Some(" "), None), Ok(None)); |
| 433 | assert_eq!(valid_place(Some("/src/lib.rs"), Some(3)), Ok(Some(("src/lib.rs".into(), 3)))); |
| 434 | assert!(valid_place(Some("src/lib.rs"), Some(0)).is_err()); |
| 435 | assert!(valid_place(Some("src/lib.rs"), None).is_err()); |
| 436 | assert!(valid_place(None, Some(3)).is_err()); |
| 437 | assert!(valid_place(Some("src/../etc"), Some(1)).is_err()); |
| 438 | assert!(valid_place(Some("src//lib.rs"), Some(1)).is_err()); |
| 439 | assert!(valid_place(Some("src/"), Some(1)).is_err()); |
| 440 | } |
| 441 | |
| 442 | #[test] |
| 443 | fn authors_edit_and_maintainers_only_delete() { |
| 444 | assert!(may_change("usr_1", "usr_1", false, false).is_ok()); |
| 445 | assert!(may_change("usr_1", "usr_1", false, true).is_ok()); |
| 446 | assert!(may_change("usr_1", "usr_2", true, true).is_ok()); |
| 447 | assert_eq!(may_change("usr_1", "usr_2", true, false), Err("Only its author can edit this comment.")); |
| 448 | assert!(may_change("usr_1", "usr_2", false, true).is_err()); |
| 449 | assert!(may_change("usr_1", "usr_2", false, false).is_err()); |
| 450 | } |
| 451 | |
| 452 | #[test] |
| 453 | fn a_line_is_on_a_file_the_commit_changed_and_kept() { |
| 454 | let entry = |path: &str, hash: Option<&str>| FileEntry { path: path.into(), hash: hash.map(str::to_owned), kind: None }; |
| 455 | let list = FileList { |
| 456 | commit: Some("c".into()), |
| 457 | date: None, |
| 458 | files: vec![entry("src/lib.rs", Some("h1")), entry("old.rs", None)], |
| 459 | truncated: false, |
| 460 | }; |
| 461 | assert!(changes_file(&list, "src/lib.rs")); |
| 462 | assert!(!changes_file(&list, "src/main.rs")); |
| 463 | // A file the commit deleted has no lines left to comment on. |
| 464 | assert!(!changes_file(&list, "old.rs")); |
| 465 | assert!(changes_file(&FileList { truncated: true, ..list }, "src/main.rs")); |
| 466 | } |
| 467 | |
| 468 | #[test] |
| 469 | fn events_link_to_the_commit_and_say_only_what_they_have() { |
| 470 | let comment = CommitComment { |
| 471 | id: "ccm_1".into(), |
| 472 | repo_id: "rep_1".into(), |
| 473 | commit: "abc123".into(), |
| 474 | path: None, |
| 475 | line: None, |
| 476 | author: "ana".into(), |
| 477 | body: "hi".into(), |
| 478 | created_at: "2026-10-14T00:00:00Z".into(), |
| 479 | updated_at: None, |
| 480 | }; |
| 481 | let mut data = CommitCommentEvent { |
| 482 | comment_id: comment.id.clone(), |
| 483 | repo_id: "rep_1".into(), |
| 484 | commit: comment.commit.clone(), |
| 485 | comment, |
| 486 | changes: None, |
| 487 | link: "/acme/rocket/commit/abc123".into(), |
| 488 | excerpt: None, |
| 489 | mentions: Vec::new(), |
| 490 | team_mentions: Vec::new(), |
| 491 | commit_author: None, |
| 492 | }; |
| 493 | let value = serde_json::to_value(&data).unwrap(); |
| 494 | assert_eq!(value["commentId"], "ccm_1"); |
| 495 | assert_eq!(value["comment"]["author"], "ana"); |
| 496 | assert!(value.get("mentions").is_none() && value.get("commitAuthor").is_none() && value.get("changes").is_none()); |
| 497 | data.mentions = vec!["bo".into()]; |
| 498 | data.commit_author = Some("cy".into()); |
| 499 | let value = serde_json::to_value(&data).unwrap(); |
| 500 | assert_eq!(value["mentions"][0], "bo"); |
| 501 | assert_eq!(value["commitAuthor"], "cy"); |
| 502 | } |
| 503 | |
| 504 | /// The table as the migrations leave it, with the statements this |
| 505 | /// module and a purge run against it. |
| 506 | #[test] |
| 507 | fn the_table_holds_comments_by_commit_and_goes_with_its_repository() { |
| 508 | let db = rusqlite::Connection::open_in_memory().unwrap(); |
| 509 | let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"); |
| 510 | let mut names: Vec<String> = std::fs::read_dir(dir) |
| 511 | .unwrap() |
| 512 | .map(|entry| entry.unwrap().file_name().into_string().unwrap()) |
| 513 | .filter(|name| name.ends_with(".sql")) |
| 514 | .collect(); |
| 515 | names.sort(); |
| 516 | for name in names { |
| 517 | db.execute_batch(&std::fs::read_to_string(format!("{dir}/{name}")).unwrap()).unwrap(); |
| 518 | } |
| 519 | let insert = "INSERT INTO commit_comments |
| 520 | (id, repo_id, commit_hash, path, line, author_id, author_name, body, created_at) |
| 521 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; |
| 522 | for (id, repo, commit, at) in [ |
| 523 | ("ccm_1", "rep_1", "aaaa1111", "2026-10-14T00:00:01Z"), |
| 524 | ("ccm_2", "rep_1", "aaaa1111", "2026-10-14T00:00:02Z"), |
| 525 | ("ccm_3", "rep_1", "bbbb2222", "2026-10-14T00:00:03Z"), |
| 526 | ("ccm_4", "rep_2", "aaaa1111", "2026-10-14T00:00:04Z"), |
| 527 | ] { |
| 528 | db.execute(insert, rusqlite::params![id, repo, commit, None::<String>, None::<u32>, "usr_1", "ana", "hi", at]).unwrap(); |
| 529 | } |
| 530 | let ids = |sql: &str, params: &[&dyn rusqlite::ToSql]| -> Vec<String> { |
| 531 | let mut statement = db.prepare(sql).unwrap(); |
| 532 | statement.query_map(params, |row| row.get(0)).unwrap().map(|id| id.unwrap()).collect() |
| 533 | }; |
| 534 | assert_eq!( |
| 535 | ids("SELECT id FROM commit_comments WHERE repo_id = ? AND commit_hash LIKE ? || '%' ORDER BY created_at, id", &[&"rep_1", &"aaaa"]), |
| 536 | ["ccm_1", "ccm_2"] |
| 537 | ); |
| 538 | assert_eq!( |
| 539 | ids("SELECT id FROM commit_comments WHERE repo_id = ? ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?", &[&"rep_1", &2, &0]), |
| 540 | ["ccm_3", "ccm_2"] |
| 541 | ); |
| 542 | // An account purged: its comments stay, as ghost's (ghost.rs). |
| 543 | for sql in crate::ghost::statements().iter().filter(|sql| sql.contains("commit_comments")) { |
| 544 | db.execute(sql, rusqlite::params!["usr_1"]).unwrap(); |
| 545 | } |
| 546 | assert_eq!(ids("SELECT DISTINCT author_name FROM commit_comments", &[]), ["ghost"]); |
| 547 | db.execute(PURGED, rusqlite::params!["rep_1"]).unwrap(); |
| 548 | assert_eq!(ids("SELECT id FROM commit_comments ORDER BY id", &[]), ["ccm_4"]); |
| 549 | } |
| 550 | } |